diff --git a/.deepsource.toml b/.deepsource.toml deleted file mode 100644 index a081251483b..00000000000 --- a/.deepsource.toml +++ /dev/null @@ -1,19 +0,0 @@ -version = 1 - -test_patterns = ["tests/**"] - -exclude_patterns = [ - "tests/**", - "docs/**", - "setup.py", - "setup-raw.py" -] - -[[analyzers]] -name = "python" -enabled = true - - [analyzers.meta] - runtime_version = "3.x.x" - max_line_length = 99 - skip_doc_coverage = ["module", "magic", "init", "nonpublic"] diff --git a/.github/CONTRIBUTING.rst b/.github/CONTRIBUTING.rst index 935e8d8f659..db8a54bd371 100644 --- a/.github/CONTRIBUTING.rst +++ b/.github/CONTRIBUTING.rst @@ -22,12 +22,16 @@ Setting things up $ git remote add upstream https://github.com/python-telegram-bot/python-telegram-bot -4. Install dependencies: +4. Install the package in development mode as well as optional dependencies and development dependencies. + Note that the `--group` argument requires `pip` 25.1 or later. + + Alternatively, you can use your preferred package manager (such as uv, hatch, poetry, etc.) instead of pip. .. code-block:: bash - $ pip install -r requirements-all.txt + $ pip install -e .[all] --group all + Installing the package itself is necessary because python-telegram-bot uses a src-based layout where the package code is located in the ``src/`` directory. 5. Install pre-commit hooks: @@ -83,7 +87,7 @@ Here's how to make a one-off code change. - Documenting types of global variables and complex types of class members can be done using the Sphinx docstring convention. - - In addition, PTB uses some formatting/styling and linting tools in the pre-commit setup. Some of those tools also have command line tools that can help to run these tools outside of the pre-commit step. If you'd like to leverage that, please have a look at the `pre-commit config file`_ for an overview of which tools (and which versions of them) are used. For example, we use `Black`_ for code formatting. Plugins for Black exist for some `popular editors`_. You can use those instead of manually formatting everything. + - In addition, PTB uses some formatting/styling and linting tools in the pre-commit setup. Some of those tools also have command line tools that can help to run these tools outside of the pre-commit step. If you'd like to leverage that, please have a look at the `pre-commit config file`_ for an overview of which tools (and which versions of them) are used. For example, we use `Ruff`_ for linting and formatting. - Please ensure that the code you write is well-tested and that all automated tests still pass. We have dedicated an `testing page`_ to help you with that. @@ -157,44 +161,47 @@ Check-list for PRs This checklist is a non-exhaustive reminder of things that should be done before a PR is merged, both for you as contributor and for the maintainers. Feel free to copy (parts of) the checklist to the PR description to remind you or the maintainers of open points or if you have questions on anything. -- Added ``.. versionadded:: NEXT.VERSION``, ``.. versionchanged:: NEXT.VERSION`` or ``.. deprecated:: NEXT.VERSION`` to the docstrings for user facing changes (for methods/class descriptions, arguments and attributes) -- Created new or adapted existing unit tests -- Documented code changes according to the `CSI standard `__ -- Added myself alphabetically to ``AUTHORS.rst`` (optional) -- Added new classes & modules to the docs and all suitable ``__all__`` s -- Checked the `Stability Policy `_ in case of deprecations or changes to documented behavior +.. code-block:: markdown -**If the PR contains API changes (otherwise, you can ignore this passage)** + ## Check-list for PRs -- Checked the Bot API specific sections of the `Stability Policy `_ + - [ ] Added `.. versionadded:: NEXT.VERSION`, ``.. versionchanged:: NEXT.VERSION``, ``.. deprecated:: NEXT.VERSION`` or ``.. versionremoved:: NEXT.VERSION` to the docstrings for user facing changes (for methods/class descriptions, arguments and attributes) + - [ ] Created new or adapted existing unit tests + - [ ] Documented code changes according to the [CSI standard](https://standards.mousepawmedia.com/en/stable/csi.html) + - [ ] Added myself alphabetically to `AUTHORS.rst` (optional) + - [ ] Added new classes & modules to the docs and all suitable ``__all__`` s + - [ ] Checked the [Stability Policy](https://docs.python-telegram-bot.org/stability_policy.html) in case of deprecations or changes to documented behavior -- New classes: + **If the PR contains API changes (otherwise, you can ignore this passage)** - - Added ``self._id_attrs`` and corresponding documentation - - ``__init__`` accepts ``api_kwargs`` as kw-only + - [ ] Checked the Bot API specific sections of the [Stability Policy](https://docs.python-telegram-bot.org/stability_policy.html) + - [ ] Created a PR to remove functionality deprecated in the previous Bot API release ([see here](https://docs.python-telegram-bot.org/en/stable/stability_policy.html#case-2)) -- Added new shortcuts: + - New Classes - - In :class:`~telegram.Chat` & :class:`~telegram.User` for all methods that accept ``chat/user_id`` - - In :class:`~telegram.Message` for all methods that accept ``chat_id`` and ``message_id`` - - For new :class:`~telegram.Message` shortcuts: Added ``quote`` argument if methods accepts ``reply_to_message_id`` - - In :class:`~telegram.CallbackQuery` for all methods that accept either ``chat_id`` and ``message_id`` or ``inline_message_id`` + - [ ] Added `self._id_attrs` and corresponding documentation + - [ ] `__init__` accepts `api_kwargs` as keyword-only -- If relevant: + - Added New Shortcuts - - Added new constants at :mod:`telegram.constants` and shortcuts to them as class variables - - Link new and existing constants in docstrings instead of hard-coded numbers and strings - - Add new message types to :attr:`telegram.Message.effective_attachment` - - Added new handlers for new update types + - [ ] In [`telegram.Chat`](https://python-telegram-bot.readthedocs.io/en/stable/telegram.chat.html) \& [`telegram.User`](https://python-telegram-bot.readthedocs.io/en/stable/telegram.user.html) for all methods that accept `chat/user_id` + - [ ] In [`telegram.Message`](https://python-telegram-bot.readthedocs.io/en/stable/telegram.message.html) for all methods that accept `chat_id` and `message_id` + - [ ] For new `telegram.Message` shortcuts: Added `quote` argument if methods accept `reply_to_message_id` + - [ ] In [`telegram.CallbackQuery`](https://python-telegram-bot.readthedocs.io/en/stable/telegram.callbackquery.html) for all methods that accept either `chat_id` and `message_id` or `inline_message_id` - - Add the handlers to the warning loop in the :class:`~telegram.ext.ConversationHandler` + - If Relevant - - Added new filters for new message (sub)types - - 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`` - - 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` + - [ ] Added new constants at `telegram.constants` and shortcuts to them as class variables + - [ ] Linked new and existing constants in docstrings instead of hard-coded numbers and strings + - [ ] Added new message types to `telegram.Message.effective_attachment` + - [ ] Added new handlers for new update types + - [ ] Added the handlers to the warning loop in the [`telegram.ext.ConversationHandler`](https://python-telegram-bot.readthedocs.io/en/stable/telegram.ext.conversationhandler.html) + - [ ] Added new filters for new message (sub)types + - [ ] 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` (including the badge) and `telegram.constants.BOT_API_VERSION_INFO` + - [ ] Added logic for arbitrary callback data in `telegram.ext.ExtBot` for new methods that either accept a `reply_markup` in some form or have a return type that is/contains [`telegram.Message`](https://python-telegram-bot.readthedocs.io/en/stable/telegram.message.html) Documenting =========== @@ -209,13 +216,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.10 or above and have the required dependencies installed as explained above. +Then, run the following from the PTB root directory: .. code-block:: bash @@ -274,27 +276,8 @@ callable we prefer that the call also uses keyword arg syntax. For example: This gives us the flexibility to re-order arguments and more importantly to add new required arguments. It's also more explicit and easier to read. -Properly defining optional arguments ------------------------------------- - -It's always good to not initialize optional arguments at class creation, -instead use ``**kwargs`` to get them. It's well known Telegram API can -change without notice, in that case if a new argument is added it won't -break the API classes. For example: - -.. code-block:: python - - # GOOD - def __init__(self, id, name, last_name=None, **kwargs): - self.last_name = last_name - - - # BAD - def __init__(self, id, name, last_name=None): - self.last_name = last_name - -.. _`Code of Conduct`: https://www.python.org/psf/conduct/ +.. _`Code of Conduct`: https://policies.python.org/python.org/code-of-conduct/ .. _`issue tracker`: https://github.com/python-telegram-bot/python-telegram-bot/issues .. _`Telegram group`: https://telegram.me/pythontelegrambotgroup .. _`PEP 8 Style Guide`: https://peps.python.org/pep-0008/ @@ -305,8 +288,7 @@ break the API classes. For example: .. _`MyPy`: https://mypy.readthedocs.io/en/stable/index.html .. _`here`: https://mypy.readthedocs.io/en/stable/cheat_sheet_py3.html .. _`pre-commit config file`: https://github.com/python-telegram-bot/python-telegram-bot/blob/master/.pre-commit-config.yaml -.. _`Black`: https://black.readthedocs.io/en/stable/index.html -.. _`popular editors`: https://black.readthedocs.io/en/stable/integrations/editors.html +.. _`Ruff`: https://docs.astral.sh/ruff/ .. _`RTD`: https://docs.python-telegram-bot.org/ .. _`RTD build`: https://docs.python-telegram-bot.org/en/doc-fixes .. _`CSI`: https://standards.mousepawmedia.com/en/stable/csi.html diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 509f689df40..53c89496b21 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,7 +1,7 @@ name: Bug Report description: Create a report to help us improve -title: "[BUG]" -labels: ["bug :bug:"] +labels: ["📋 triage"] +type: '🐛 bug' body: - type: markdown diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 6c7ff80390e..e6cc817a1bd 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,7 +1,7 @@ name: Feature Request description: Suggest an idea for this project -title: "[FEATURE]" -labels: ["enhancement"] +labels: ["📋 triage"] +type: '💡 feature' body: - type: textarea diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml index c3ad2fb6df3..220da04007e 100644 --- a/.github/ISSUE_TEMPLATE/question.yml +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -1,7 +1,7 @@ name: Question description: Get help with errors or general questions -title: "[QUESTION]" labels: ["question"] +type: '❔ question' body: - type: markdown @@ -12,6 +12,8 @@ body: To make it easier for us to help you, please read this [article](https://github.com/python-telegram-bot/python-telegram-bot/wiki/Ask-Right). Please mind that there is also a users' [Telegram group](https://t.me/pythontelegrambotgroup) for questions about the library. Questions asked there might be answered quicker than here. Moreover, [GitHub Discussions](https://github.com/python-telegram-bot/python-telegram-bot/discussions) offer a slightly better format to discuss usage questions. + + If you have asked the same question elsewhere (e.g. the [Telegram group](https://t.me/pythontelegrambotgroup) or [StackOverflow](https://stackoverflow.com/questions/tagged/python-telegram-bot)), provide a link to that thread. - type: textarea id: issue-faced diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..069c52f3afa --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,40 @@ +This is a python project which is a wrapper for the Telegram Bot API. Please read the contributing +guidelines mentioned in .github/CONTRIBUTING.rst to know how to contribute to this project. The +README.rst file lists the features and usage of the project. + +### Development Environment: + +Your development environment is set up using `uv`, a tool for managing Python environments and dependencies. +Your environment has all extra dependencies and groups installed, on Python 3.13. Please continue using `uv` for managing your development environment, +and for any scripts or tools you need to run. + +Some example commands on `uv`: +- `uv sync --all-extras --all-groups --locked` to install all dependencies and groups required by the project. +- `uv run -p 3.14 --all-groups --all-extras --locked tests/` to run tests on a specific Python version. Please use the `-p` flag often. +- `uv pip install ` to install a package in the current environment. + +If uv is somehow not available, you can install it using `pip install uv`. + +### Repository Structure: + +The repository follows a standard structure for Python projects. Here are some key directories and files: + +- `src/`: This directory contains the main source code for the project. +- `tests/`: This directory contains test cases for the project. +- `pyproject.toml`: This file contains the project metadata and dependencies. +- `.github/`: This directory contains GitHub-specific files, including workflows and issue templates. + + +### Things to keep in mind while coding: + +- Ensure that your code is properly and fully typed. All your code should be compatible from + Python 3.9 to 3.14. Don't use the `typing_extensions` module. +- Read the stability guide mentioned at docs/source/stability_policy.rst to understand if your changes + are breaking or incompatible. +- Try to make sure your code is asyncio-friendly and thread-safe. +- Run `uv run pre-commit` to run pre-commit hooks before committing your changes, but after `git add`ing them. +- Make sure you always test your changes. Either update or write new tests in the `tests/` directory. + +### Pull Requests: + +When you create a pull request, please also add the appropriate labels to it. diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 9d79fbdf366..00000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,14 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" - day: "friday" - - # Updates the dependencies of the GitHub Actions workflows - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "monthly" - day: "friday" diff --git a/.github/labeler.yml b/.github/labeler.yml index 120c88f4b36..3d2eb437df9 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -3,7 +3,7 @@ version: 1 labels: -- label: "dependencies" +- label: "⚙️ dependencies" authors: ["dependabot[bot]", "pre-commit-ci[bot]"] -- label: "code quality ✨" +- label: "🛠 code-quality" authors: ["pre-commit-ci[bot]"] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 38b9e92a951..33668266002 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,6 @@ diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 00000000000..c52377794c0 --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,88 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ // See what config:best-practices does: https://docs.renovatebot.com/presets-config/#configbest-practices + "config:best-practices", + // Opt-in to updating the pre-commit-config.yaml file too: + ":enablePreCommit", + ":prConcurrentLimitNone" // No limits on the number of open PRs. + ], + + // Add pull request labels: + "labels": ["⚙️ ci-cd"], + + // Bump even patch versions: + "bumpVersion": "patch", + + // Let Renovate decide how to update. See docs: https://docs.renovatebot.com/configuration-options/#rangestrategy + "rangeStrategy": "auto", + + // Update the lock files: + "lockFileMaintenance": { + "enabled": true, + "schedule": ["* 0-3 1 * *"], // https://docs.renovatebot.com/presets-schedule/#schedulemonthly + "automerge": true + }, + + // Enable automerge globally: + "automerge": true, + + // Group package updates together: + "packageRules": [ + // Linting dependencies in pyproject.toml in sync with the pre-commit-config hooks: + // Unfortunately it seems we need to do this for every dependency group (https://github.com/python-telegram-bot/python-telegram-bot/pull/4887#discussion_r2272025832): + { + "description": "Group Ruff updates together", + "matchPackageNames": ["ruff", "astral-sh/ruff-pre-commit"], + "groupName": "Ruff" + }, + { + "description": "Group mypy updates together", + "matchPackageNames": ["mypy", "pre-commit/mirrors-mypy"], + "groupName": "Mypy" + }, + { + "description": "Group pylint updates together", + "matchPackageNames": ["pylint", "PyCQA/pylint"], + "groupName": "Pylint" + }, + { + "description": "Group chango updates together", + "matchPackageNames": ["chango", "Bibo-Joshi/chango"], + "groupName": "Chango" + }, + + // Don't automerge major updates for project dependencies: + { + "matchUpdateTypes": ["major"], + "matchDepTypes": ["project.dependencies", "project.optional-dependencies"], + "automerge": false + }, + + // Apply the "dependencies" label to all updates of optional/required dependencies: + { + "matchDepTypes": ["project.optional-dependencies", "project.dependencies"], + "labels": ["⚙️ dependencies"] + }, + + // Workflow and dev-dependencies update once a month + // https://docs.renovatebot.com/presets-schedule/#schedulemonthly + { + "matchFileNames": [".github/workflows/**"], + "schedule": ["* 0-3 1 * *"] + }, + { + "matchDepTypes": ["dependency-groups"], + "schedule": ["* 0-3 1 * *"] + } + ], + + // Increase the number of PR's Renovate can create in a hour. Default is 2. + "prHourlyLimit": 5, + + // Temporarily disabled: + "ignoreDeps": ["pytest-asyncio"], + + // schedule to allow PR's from Renovate: + "schedule": ["* * * * 0"] // Every Sunday + +} diff --git a/.github/workflows/assets/release_template.html b/.github/workflows/assets/release_template.html new file mode 100644 index 00000000000..2e672ca8482 --- /dev/null +++ b/.github/workflows/assets/release_template.html @@ -0,0 +1,5 @@ +We've just released {tag}. +Thank you to everyone who contributed to this release. +As usual, upgrade using pip install -U python-telegram-bot. + +The release notes can be found here. \ No newline at end of file diff --git a/.github/workflows/chango.yml b/.github/workflows/chango.yml new file mode 100644 index 00000000000..ef1dd9bc6ea --- /dev/null +++ b/.github/workflows/chango.yml @@ -0,0 +1,69 @@ +name: Chango +on: + pull_request: + types: + - opened + - reopened + - synchronize + +permissions: {} + +jobs: + create-chango-fragment: + permissions: + # Give the default GITHUB_TOKEN write permission to commit and push the + # added or changed files to the repository. + contents: write + name: Create chango Fragment + runs-on: ubuntu-latest + outputs: + IS_RELEASE_PR: ${{ steps.check_title.outputs.IS_RELEASE_PR }} + + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + # needed for commit and push step at the end + persist-credentials: true + - name: Check PR Title + id: check_title + run: | # zizmor: ignore[template-injection] + if [[ "$(echo "${{ github.event.pull_request.title }}" | tr '[:upper:]' '[:lower:]')" =~ ^bump\ version\ to\ .* ]]; then + echo "COMMIT_AND_PUSH=false" >> $GITHUB_OUTPUT + echo "IS_RELEASE_PR=true" >> $GITHUB_OUTPUT + else + echo "COMMIT_AND_PUSH=true" >> $GITHUB_OUTPUT + echo "IS_RELEASE_PR=false" >> $GITHUB_OUTPUT + fi + + # Create the new fragment + - uses: Bibo-Joshi/chango@bc58df46ef3ba8f15b8d744929998b7ae8a222d4 # 0.6.1 + with: + # passing this custom token has two purposes + # 1. it allows us to fetch info about issue types + # 2. it ensures that the push will also re-trigger workflows + github-token: ${{ secrets.CHANGO_PAT }} + query-issue-types: true + commit-and-push: ${{ steps.check_title.outputs.COMMIT_AND_PUSH }} + + # Run `chango release` if applicable - needs some additional setup. + - name: Set up Python + if: steps.check_title.outputs.IS_RELEASE_PR == 'true' + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.x" + + - name: Do Release + if: steps.check_title.outputs.IS_RELEASE_PR == 'true' + run: | + cd ./target-repo + git add changes/unreleased/* + pip install . --group docs + VERSION_TAG=$(python -c "from telegram import __version__; print(f'{__version__}')") + chango release --uid $VERSION_TAG + + - name: Commit & Push + if: steps.check_title.outputs.IS_RELEASE_PR == 'true' + uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v7.0.0 + with: + commit_message: "Do chango Release" + repository: ./target-repo diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 00000000000..5d1eec79d59 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,42 @@ +# This file is for the copilot agent on Github. This helps to set up the development environment +# See the docs here: https://docs.github.com/en/enterprise-cloud@latest/copilot/how-tos/use-copilot-agents/coding-agent/customize-the-agent-environment#preinstalling-tools-or-dependencies-in-copilots-environment +name: "Copilot Setup Steps" + +# Automatically run the setup steps when they are changed to allow for easy validation, and +# allow manual testing through the repository's "Actions" tab +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. + copilot-setup-steps: + runs-on: ubuntu-latest + + # Set the permissions to the lowest permissions possible needed for your steps. + # Copilot will be given its own token for its operations. + permissions: + # If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete. + contents: read + pull-requests: write # So copilot can add labels to the PR + + # You can define any steps you want, and they will run before the agent starts. + # If you do not check out your code, Copilot will do this for you. + steps: + - name: Checkout code + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 + with: + # Install a specific version of uv. + version: "0.9.28" + # Install 3.13: + python-version: 3.13 + + - name: Install the project + run: uv sync --all-extras --all-groups --locked diff --git a/.github/workflows/dependabot-prs.yml b/.github/workflows/dependabot-prs.yml deleted file mode 100644 index d5e61e17738..00000000000 --- a/.github/workflows/dependabot-prs.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Process Dependabot PRs - -on: - pull_request: - types: [opened, synchronize, reopened] - -jobs: - process-dependabot-prs: - permissions: - pull-requests: read - contents: write - - runs-on: ubuntu-latest - if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }} - steps: - - - name: Fetch Dependabot metadata - id: dependabot-metadata - uses: dependabot/fetch-metadata@v1.5.1 - - - uses: actions/checkout@v3.5.2 - with: - ref: ${{ github.event.pull_request.head.ref }} - - - name: Update Version Number in Other Files - uses: jacobtomlinson/gha-find-replace@v3 - with: - find: ${{ steps.dependabot-metadata.outputs.previous-version }} - replace: ${{ steps.dependabot-metadata.outputs.new-version }} - regex: false - exclude: CHANGES.rst - - - name: Commit & Push Changes to PR - uses: EndBug/add-and-commit@v9.1.3 - with: - message: 'Update version number in other files' - committer_name: GitHub Actions - committer_email: 41898282+github-actions[bot]@users.noreply.github.com \ No newline at end of file diff --git a/.github/workflows/docs-admonitions.yml b/.github/workflows/docs-admonitions.yml new file mode 100644 index 00000000000..581b174d3df --- /dev/null +++ b/.github/workflows/docs-admonitions.yml @@ -0,0 +1,41 @@ +name: Test Admonitions Generation +on: + pull_request: + paths: + - src/telegram/** + - docs/** + - .github/workflows/docs-admonitions.yml + push: + branches: + - master + +permissions: {} + +jobs: + test-admonitions: + name: Test Admonitions Generation + runs-on: ${{matrix.os}} + permissions: + # for uploading artifacts + actions: write + strategy: + matrix: + python-version: ['3.12'] + os: [ubuntu-latest] + fail-fast: False + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: 'pyproject.toml' + - name: Install dependencies + run: | + python -W ignore -m pip install --upgrade pip + python -W ignore -m pip install .[all] --group all + - name: Test autogeneration of admonitions + run: pytest -v --tb=short tests/docs/admonition_inserter.py \ No newline at end of file diff --git a/.github/workflows/docs-linkcheck.yml b/.github/workflows/docs-linkcheck.yml index e5c54dade30..e73ad4a0b49 100644 --- a/.github/workflows/docs-linkcheck.yml +++ b/.github/workflows/docs-linkcheck.yml @@ -3,6 +3,11 @@ on: schedule: # First day of month at 05:46 in every 2nd month - cron: '46 5 1 */2 *' + pull_request: + paths: + - .github/workflows/docs-linkcheck.yml + +permissions: {} jobs: test-sphinx-build: @@ -10,18 +15,27 @@ jobs: runs-on: ${{matrix.os}} strategy: matrix: - python-version: [3.9] + python-version: ['3.12'] os: [ubuntu-latest] fail-fast: False steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} - 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 .[all] --group all - name: Check Links - run: sphinx-build docs/source docs/build/html -W --keep-going -j auto -b linkcheck + run: sphinx-build docs/source docs/build/html --keep-going -j auto -b linkcheck + - name: Upload linkcheck output + # Run also if the previous steps failed + if: always() + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: linkcheck-output + path: docs/build/html/output.* diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 64bdbe636e8..00000000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Test Documentation Build -on: - pull_request: - branches: - - master - - doc-fixes - push: - branches: - - master - - doc-fixes - -jobs: - test-sphinx-build: - name: test-sphinx-build - runs-on: ${{matrix.os}} - strategy: - matrix: - python-version: [3.9] - os: [ubuntu-latest] - fail-fast: False - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - cache-dependency-path: '**/requirements*.txt' - - name: Install dependencies - run: | - python -W ignore -m pip install --upgrade pip - python -W ignore -m pip install -r requirements-all.txt - - name: Test autogeneration of admonitions - run: pytest -v --tb=short tests/docs/admonition_inserter.py - - name: Build docs - run: sphinx-build docs/source docs/build/html -W --keep-going -j auto - - name: Upload docs - uses: actions/upload-artifact@v3 - with: - name: HTML Docs - retention-days: 7 - path: | - # Exclude the .doctrees folder and .buildinfo file from the artifact - # since they are not needed and add to the size - docs/build/html/* - !docs/build/html/.doctrees - !docs/build/html/.buildinfo diff --git a/.github/workflows/gha_security.yml b/.github/workflows/gha_security.yml new file mode 100644 index 00000000000..c7625a492b0 --- /dev/null +++ b/.github/workflows/gha_security.yml @@ -0,0 +1,33 @@ +name: GitHub Actions Security Analysis + +on: + push: + branches: + - master + pull_request: + +permissions: {} + +jobs: + zizmor: + name: Security Analysis with zizmor + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + - name: Install the latest version of uv + uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1 + - name: Run zizmor + run: uvx zizmor --persona=pedantic --format sarif . > results.sarif + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5 + with: + sarif_file: results.sarif + category: zizmor diff --git a/.github/workflows/labelling.yml b/.github/workflows/labelling.yml index 4512d2e1f72..21a4d6733ba 100644 --- a/.github/workflows/labelling.yml +++ b/.github/workflows/labelling.yml @@ -4,6 +4,8 @@ on: pull_request: types: [opened] +permissions: {} + jobs: pre-commit-ci: permissions: @@ -11,7 +13,7 @@ jobs: pull-requests: write # for srvaroa/labeler to add labels in PR runs-on: ubuntu-latest steps: - - uses: srvaroa/labeler@v1.5.0 + - uses: srvaroa/labeler@0a20eccb8c94a1ee0bed5f16859aece1c45c3e55 # v1.13.0 # Config file at .github/labeler.yml env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 066daaaee27..e32ece0ff4e 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -4,14 +4,22 @@ on: schedule: - cron: '8 4 * * *' +permissions: {} + jobs: lock: runs-on: ubuntu-latest + permissions: + # For locking the threads + issues: write + pull-requests: write steps: - - uses: dessant/lock-threads@v4.0.0 + - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1 with: github-token: ${{ github.token }} issue-inactive-days: '7' issue-lock-reason: '' pr-inactive-days: '7' pr-lock-reason: '' + # Don't lock Discussions + process-only: 'issues, prs' 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..14616c9e5c1 --- /dev/null +++ b/.github/workflows/release_pypi.yml @@ -0,0 +1,172 @@ +name: Publish to PyPI + +on: + # manually trigger the workflow + workflow_dispatch: + +permissions: {} + +jobs: + build: + name: Build Distribution + runs-on: ubuntu-latest + outputs: + TAG: ${{ steps.get_tag.outputs.TAG }} + permissions: + # for uploading artifacts + actions: write + + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + 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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: python-package-distributions + path: dist/ + - name: Get Tag Name + id: get_tag + run: | + pip install . + TAG=$(python -c "from telegram import __version__; print(f'v{__version__}')") + echo "TAG=$TAG" >> $GITHUB_OUTPUT + + publish-to-pypi: + name: Publish to PyPI + 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 + actions: read # for downloading artifacts + + steps: + - name: Download all the dists + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: python-package-distributions + path: dist/ + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + + compute-signatures: + name: Compute SHA1 Sums and Sign with Sigstore + runs-on: ubuntu-latest + needs: + - publish-to-pypi + + permissions: + id-token: write # IMPORTANT: mandatory for sigstore + actions: write # for up/downloading artifacts + + steps: + - name: Download all the dists + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + 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@f832326173235dcb00dd5d92cd3f353de3188e6c # v3.1.0 + with: + inputs: >- + ./dist/*.tar.gz + ./dist/*.whl + - name: Store the distribution packages and signatures + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: python-package-distributions-and-signatures + path: dist/ + + github-release: + name: Upload to GitHub Release + needs: + - build + - compute-signatures + + runs-on: ubuntu-latest + + permissions: + contents: write # IMPORTANT: mandatory for making GitHub Releases + actions: read # for downloading artifacts + + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + - name: Download all the dists + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: python-package-distributions-and-signatures + path: dist/ + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + TAG: ${{ needs.build.outputs.TAG }} + # Create a tag and a GitHub Release. The description is filled by the static template, we + # just insert the correct tag in the template. + run: >- + sed "s/{tag}/$TAG/g" .github/workflows/assets/release_template.html | + gh release create + "$TAG" + --repo '${{ github.repository }}' + --notes-file - + - name: Upload artifact signatures to GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + TAG: ${{ needs.build.outputs.TAG }} + # Upload to GitHub Release using the `gh` CLI. + # `dist/` contains the built packages, and the + # sigstore-produced signatures and certificates. + run: >- + gh release upload + "$TAG" dist/** + --repo '${{ github.repository }}' + + telegram-channel: + name: Publish to Telegram Channel + needs: + # required to have the output available for the env var + - build + - github-release + + runs-on: ubuntu-latest + environment: + name: release_pypi + permissions: {} + + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + - name: Publish to Telegram Channel + env: + TAG: ${{ needs.build.outputs.TAG }} + # This secret is configured only for the `pypi-release` branch + BOT_TOKEN: ${{ secrets.CHANNEL_BOT_TOKEN }} + run: >- + sed "s/{tag}/$TAG/g" .github/workflows/assets/release_template.html | + curl + -X POST "https://api.telegram.org/bot$BOT_TOKEN/sendMessage" + -d "chat_id=@pythontelegrambotchannel" + -d "parse_mode=HTML" + --data-urlencode "text@-" diff --git a/.github/workflows/release_test_pypi.yml b/.github/workflows/release_test_pypi.yml new file mode 100644 index 00000000000..cf38eb899f8 --- /dev/null +++ b/.github/workflows/release_test_pypi.yml @@ -0,0 +1,146 @@ +name: Publish to Test PyPI + +on: + # manually trigger the workflow + workflow_dispatch: + +permissions: {} + +jobs: + build: + name: Build Distribution + runs-on: ubuntu-latest + outputs: + TAG: ${{ steps.get_tag.outputs.TAG }} + permissions: + # for uploading artifacts + actions: write + + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + 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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: python-package-distributions + path: dist/ + - name: Get Tag Name + id: get_tag + run: | + pip install . + TAG=$(python -c "from telegram import __version__; print(f'v{__version__}')") + echo "TAG=$TAG" >> $GITHUB_OUTPUT + + 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 + actions: read # for downloading artifacts + + steps: + - name: Download all the dists + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: python-package-distributions + path: dist/ + - name: Publish to Test PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + 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-test-pypi + + permissions: + id-token: write # IMPORTANT: mandatory for sigstore + actions: write # for up/downloading artifacts + + steps: + - name: Download all the dists + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + 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@f832326173235dcb00dd5d92cd3f353de3188e6c # v3.1.0 + with: + inputs: >- + ./dist/*.tar.gz + ./dist/*.whl + - name: Store the distribution packages and signatures + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: python-package-distributions-and-signatures + path: dist/ + + github-test-release: + name: Upload to GitHub Release Draft + needs: + - build + - compute-signatures + + runs-on: ubuntu-latest + + permissions: + contents: write # IMPORTANT: mandatory for making GitHub Releases + actions: read # for downloading artifacts + + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + - name: Download all the dists + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + with: + name: python-package-distributions-and-signatures + path: dist/ + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + TAG: ${{ needs.build.outputs.TAG }} + # Create a tag and a GitHub Release *draft*. The description is filled by the static + # template, we just insert the correct tag in the template. + run: >- + sed "s/{tag}/$TAG/g" .github/workflows/assets/release_template.html | + gh release create + "$TAG" + --repo '${{ github.repository }}' + --draft + --notes-file - + - name: Upload artifact signatures to GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + TAG: ${{ needs.build.outputs.TAG }} + # Upload to GitHub Release using the `gh` CLI. + # `dist/` contains the built packages, and the + # sigstore-produced signatures and certificates. + run: >- + gh release upload + "$TAG" dist/** + --repo '${{ github.repository }}' diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 7e62f21c776..e925399cdc8 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -3,17 +3,22 @@ on: schedule: - cron: '42 2 * * *' +permissions: {} + jobs: stale: runs-on: ubuntu-latest + permissions: + # For adding labels and closing + issues: write steps: - - uses: actions/stale@v8 + - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 with: # PRs never get stale days-before-stale: 3 days-before-close: 2 days-before-pr-stale: -1 - stale-issue-label: 'stale' - only-labels: 'question' + stale-issue-label: '📋 stale' + only-issue-types: '❔ question' stale-issue-message: '' close-issue-message: 'This issue has been automatically closed due to inactivity. Feel free to comment in order to reopen or ask again in our Telegram support group at https://t.me/pythontelegrambotgroup.' diff --git a/.github/workflows/test_official.yml b/.github/workflows/test_official.yml new file mode 100644 index 00000000000..3274a04b681 --- /dev/null +++ b/.github/workflows/test_official.yml @@ -0,0 +1,50 @@ +name: Bot API Tests +on: + pull_request: + paths: + - src/telegram/** + - tests/** + push: + branches: + - master + schedule: + # Run monday and friday morning at 03:07 - odd time to spread load on GitHub Actions + - cron: '7 3 * * 1,5' + +permissions: {} + +jobs: + check-conformity: + name: check-conformity + runs-on: ${{matrix.os}} + strategy: + matrix: + python-version: [3.11] + os: [ubuntu-latest] + fail-fast: False + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -W ignore -m pip install --upgrade pip + python -W ignore -m pip install .[all] --group tests + - name: Compare to official api + run: | + pytest -v tests/test_official/test_official.py --junit-xml=.test_report_official.xml + exit $? + env: + TEST_OFFICIAL: "true" + shell: bash --noprofile --norc {0} + + - name: Test Summary + id: test_summary + uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4 + if: always() # always run, even if tests fail + with: + paths: .test_report_official.xml diff --git a/.github/workflows/type_completeness.yml b/.github/workflows/type_completeness.yml index 4dbd58e6366..947f931c2f8 100644 --- a/.github/workflows/type_completeness.yml +++ b/.github/workflows/type_completeness.yml @@ -1,69 +1,23 @@ name: Check Type Completeness on: pull_request: - branches: - - master + paths: + - src/telegram/** + - pyproject.toml + - .github/workflows/type_completeness.yml push: branches: - master +permissions: {} + jobs: test-type-completeness: name: test-type-completeness runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - 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@v4 + - uses: Bibo-Joshi/pyright-type-completeness@c85a67ff3c66f51dcbb2d06bfcf4fe83a57d69cc # 1.0.1 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.291 - - 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 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/type_completeness_monthly.yml b/.github/workflows/type_completeness_monthly.yml new file mode 100644 index 00000000000..30a8a1c8a3b --- /dev/null +++ b/.github/workflows/type_completeness_monthly.yml @@ -0,0 +1,35 @@ +name: Check Type Completeness Monthly Run +on: + schedule: + # Run first friday of the month at 03:17 - odd time to spread load on GitHub Actions + - cron: '17 3 1-7 * 5' + +permissions: {} + +jobs: + test-type-completeness: + name: test-type-completeness + runs-on: ubuntu-latest + steps: + - uses: Bibo-Joshi/pyright-type-completeness@c85a67ff3c66f51dcbb2d06bfcf4fe83a57d69cc # 1.0.1 + id: pyright-type-completeness + with: + package-name: telegram + python-version: 3.12 + pyright-version: ~=1.1.367 + - name: Check Output + uses: jannekem/run-python-script-action@bbfca66c612a28f3eeca0ae40e1f810265e2ea68 # v1.7 + env: + TYPE_COMPLETENESS: ${{ steps.pyright-type-completeness.outputs.base-completeness-score }} + with: + script: | + import os + completeness = float(os.getenv("TYPE_COMPLETENESS")) + + if completeness >= 1: + exit(0) + + text = f"Type Completeness Decreased to {completeness}. ❌" + error(text) + set_summary(text) + exit(1) diff --git a/.github/workflows/test.yml b/.github/workflows/unit_tests.yml similarity index 75% rename from .github/workflows/test.yml rename to .github/workflows/unit_tests.yml index a8aa85a8f79..369aef1018a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/unit_tests.yml @@ -1,14 +1,16 @@ -name: GitHub Actions +name: Unit Tests on: pull_request: - branches: - - master + paths: + - src/telegram/** + - tests/** + - .github/workflows/unit_tests.yml + - pyproject.toml push: branches: - master - schedule: - # Run monday and friday morning at 03:07 - odd time to spread load on GitHub Actions - - cron: '7 3 * * 1,5' + +permissions: {} jobs: pytest: @@ -16,24 +18,25 @@ jobs: runs-on: ${{matrix.os}} strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] os: [ubuntu-latest, windows-latest, macos-latest] + include: + - python-version: '3.14t' + os: ubuntu-latest fail-fast: False steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} cache: 'pip' - cache-dependency-path: '**/requirements*.txt' - name: Install dependencies 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 pytest-xdist[psutil] + python -W ignore -m pip install . --group tests - name: Test with pytest # We run 4 different suites here @@ -53,21 +56,17 @@ jobs: # - without socks support # - without http2 support TO_TEST="test_no_passport.py or test_datetime.py or test_defaults.py or test_jobqueue.py or test_applicationbuilder.py or test_ratelimiter.py or test_updater.py or test_callbackdatacache.py or test_request.py" - pytest -v --cov -k "${TO_TEST}" - # Rerun only failed tests (--lf), and don't run any tests if none failed (--lfnf=none) - pytest -v --cov --cov-append -k "${TO_TEST}" --lf --lfnf=none - # No tests were selected, convert returned status code to 0 - opt_dep_status=$(( $? == 5 ? 0 : $? )) - + pytest -v --cov -k "${TO_TEST}" --junit-xml=.test_report_no_optionals_junit.xml + opt_dep_status=$? + # Test the rest export TEST_WITH_OPT_DEPS='true' - pip install -r requirements-opts.txt - # `-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. - pytest -v --cov --cov-append -n auto --dist loadfile - pytest -v --cov --cov-append -n auto --dist loadfile --lf --lfnf=none - main_status=$(( $? == 5 ? 0 : $? )) + pip install .[all] + # `-n auto --dist worksteal` uses pytest-xdist to run tests on multiple CPU + # workers. Increasing number of workers has little effect on test duration, but it seems + # to increase flakyness. + pytest -v --cov --cov-append -n auto --dist worksteal --junit-xml=.test_report_optionals_junit.xml + main_status=$? # exit with non-zero status if any of the two pytest runs failed exit $(( ${opt_dep_status} || ${main_status} )) env: @@ -77,36 +76,25 @@ jobs: TEST_BUILD: "true" shell: bash --noprofile --norc {0} + - name: Test Summary + id: test_summary + uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2.4 + if: always() # always run, even if tests fail + with: + paths: | + .test_report_no_optionals_junit.xml + .test_report_optionals_junit.xml + - name: Submit coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: env_vars: OS,PYTHON name: ${{ matrix.os }}-${{ matrix.python-version }} fail_ci_if_error: true - test_official: - name: test-official - runs-on: ${{matrix.os}} - strategy: - matrix: - python-version: [3.7] - os: [ubuntu-latest] - fail-fast: False - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + token: ${{ secrets.CODECOV_TOKEN }} + - name: Upload test results to Codecov + uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 + if: ${{ !cancelled() }} with: - python-version: ${{ matrix.python-version }} - - 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 - - name: Compare to official api - run: | - pytest -v tests/test_official.py - exit $? - env: - TEST_OFFICIAL: "true" - shell: bash --noprofile --norc {0} + files: .test_report_no_optionals_junit.xml,.test_report_optionals_junit.xml + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index fa3d7aa52c9..01c2cfda73f 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ docs/_build/ # PyBuilder target/ .idea/ +.run/ # Sublime Text 2 *.sublime* @@ -92,3 +93,11 @@ telegram.jpg # virtual env venv* +pyvenv.cfg +Scripts/ + +# environment manager: +.mise.toml + +# Support for uv.lock will come in a future PR. See #4796 +uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 73c5fdff4fa..e8c908293b8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,90 +1,60 @@ -# Make sure that the additional_dependencies here match requirements.txt - ci: autofix_prs: false - autoupdate_schedule: monthly + # We use Renovate to update this file now, but we can't disable automatic pre-commit updates + # when using the `pre-commit` GitHub Action, so we set the schedule to quarterly to avoid + # frequent updates. + autoupdate_schedule: quarterly + autoupdate_commit_msg: 'Bump `pre-commit` Hooks to Latest Versions' repos: -- repo: https://github.com/psf/black - rev: 23.3.0 - hooks: - - id: black - args: - - --diff - - --check -- repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: 'v0.14.14' hooks: - - id: flake8 + # Run the linter: + - id: ruff-check + name: ruff check + # Run the formatter: + - id: ruff-format + name: ruff format - repo: https://github.com/PyCQA/pylint - rev: v3.0.0a6 + rev: v4.0.4 hooks: - id: pylint - files: ^(telegram|examples)/.*\.py$ - args: - - --rcfile=setup.cfg - # run pylint across multiple cpu cores to speed it up- - # https://pylint.pycqa.org/en/latest/user_guide/run.html?#parallel-execution to know more - - --jobs=0 - + files: ^(?!(tests|docs)).*\.py$ + language: python additional_dependencies: - - httpx~=0.24.1 - - tornado~=6.2 - - APScheduler~=3.10.1 - - cachetools~=5.3.1 - - aiolimiter~=1.1.0 + - httpx~=0.27 + - tornado~=6.4 + - APScheduler>=3.10.4,<3.12.0 + - cachetools>=5.3.3,<6.3.0 + - aiolimiter~=1.1,<1.3 - . # this basically does `pip install -e .` - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.3.0 + rev: v1.19.1 hooks: - id: mypy name: mypy-ptb - files: ^telegram/.*\.py$ + files: ^(?!(tests|examples|docs)).*\.py$ + language: python additional_dependencies: - types-pytz - types-cryptography - types-cachetools - - httpx~=0.24.1 - - tornado~=6.2 - - APScheduler~=3.10.1 - - cachetools~=5.3.1 - - aiolimiter~=1.1.0 + - httpx~=0.27 + - tornado~=6.4 + - APScheduler>=3.10.4,<3.12.0 + - cachetools>=5.3.3,<6.3.0 + - aiolimiter~=1.1,<1.3 - . # this basically does `pip install -e .` - id: mypy name: mypy-examples files: ^examples/.*\.py$ + language: python args: - --no-strict-optional - --follow-imports=silent additional_dependencies: - - tornado~=6.2 - - APScheduler~=3.10.1 - - cachetools~=5.3.1 + - tornado~=6.4 + - APScheduler>=3.10.4,<3.12.0 + - cachetools>=5.3.3,<6.3.0 - . # this basically does `pip install -e .` -- repo: https://github.com/asottile/pyupgrade - rev: v3.4.0 - hooks: - - id: pyupgrade - files: ^(telegram|examples|tests|docs)/.*\.py$ - args: - - --py37-plus -- repo: https://github.com/pycqa/isort - rev: 5.12.0 - hooks: - - id: isort - name: isort - args: - - --diff - - --check -- repo: https://github.com/charliermarsh/ruff-pre-commit - rev: 'v0.0.270' - hooks: - - id: ruff - name: ruff - files: ^(telegram|examples|tests)/.*\.py$ - additional_dependencies: - - httpx~=0.24.1 - - tornado~=6.2 - - APScheduler~=3.10.1 - - cachetools~=5.3.1 - - aiolimiter~=1.1.0 diff --git a/.readthedocs.yml b/.readthedocs.yml index bd9f8c51f93..5e14cbfe2a2 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -7,45 +7,59 @@ version: 2 # Build documentation in the docs/ directory with Sphinx sphinx: - configuration: docs/source/conf.py + configuration: docs/source/conf.py # Optionally build your docs in additional formats such as PDF formats: - - pdf + - pdf # Optionally set the version of Python and requirements required to build your docs python: - install: - - method: pip - path: . - - requirements: docs/requirements-docs.txt + install: + - method: pip + path: . build: - os: ubuntu-22.04 - tools: - python: "3" # latest stable cpython version + os: ubuntu-22.04 + tools: + python: "3" # latest stable cpython version + jobs: + install: + - pip install -U pip + - pip install .[all] --group 'docs' --group 'tests' # install most dependency groups + + post_build: + # Based on https://github.com/readthedocs/readthedocs.org/issues/3242#issuecomment-1410321534 + # This provides a HTML zip file for download, with the same structure as the hosted website + - mkdir --parents $READTHEDOCS_OUTPUT/htmlzip + - cp --recursive $READTHEDOCS_OUTPUT/html $READTHEDOCS_OUTPUT/$READTHEDOCS_PROJECT + # Hide the "other versions" dropdown. This is a workaround for those versions being shown, + # but not being accessible, as they are not built. Also, they hide the actual sidebar menu + # that is relevant only on ReadTheDocs. + - echo "#furo-readthedocs-versions{display:none}" >> $READTHEDOCS_OUTPUT/$READTHEDOCS_PROJECT/_static/styles/furo-extensions.css + - cd $READTHEDOCS_OUTPUT ; zip --recurse-path --symlinks htmlzip/$READTHEDOCS_PROJECT.zip $READTHEDOCS_PROJECT search: - ranking: # bump up rank of commonly searched pages: (default: 0, values range from -10 to 10) - telegram.bot.html: 7 - telegram.message.html: 3 - telegram.update.html: 3 - telegram.user.html: 2 - telegram.chat.html: 2 - telegram.ext.application.html: 3 - telegram.ext.filters.html: 3 - telegram.ext.callbackcontext.html: 2 - telegram.ext.inlinekeyboardbutton.html: 1 - - telegram.passport*.html: -7 - - ignore: - - changelog.html - - coc.html - - bot_methods.html# - - bot_methods.html - # Defaults - - search.html - - search/index.html - - 404.html - - 404/index.html' + ranking: # bump up rank of commonly searched pages: (default: 0, values range from -10 to 10) + telegram.bot.html: 7 + telegram.message.html: 3 + telegram.update.html: 3 + telegram.user.html: 2 + telegram.chat.html: 2 + telegram.ext.application.html: 3 + telegram.ext.filters.html: 3 + telegram.ext.callbackcontext.html: 2 + telegram.ext.inlinekeyboardbutton.html: 1 + + telegram.passport*.html: -7 + + ignore: + - changelog.html + - coc.html + - bot_methods.html# + - bot_methods.html + # Defaults + - search.html + - search/index.html + - 404.html + - 404/index.html' diff --git a/AUTHORS.rst b/AUTHORS.rst index 7976f5f7330..9ca986b53e5 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -7,10 +7,8 @@ The current development team includes - `Hinrich Mahler `_ (maintainer) - `Poolitzer `_ (community liaison) -- `Shivam `_ - `Harshil `_ -- `Dmitry Kolomatskiy `_ -- `Aditya `_ +- `Abdelrahman `_ Emeritus maintainers include `Jannes Höke `_ (`@jh0ker `_ on Telegram), @@ -21,12 +19,17 @@ Contributors The following wonderful people contributed directly or indirectly to this project: +- `Aditya `_ - `Abshar `_ +- `Abubakar Alaya `_ - `Alateas `_ - `Ales Dokshanin `_ +- `Alexandre `_ +- `Alizia `_ - `Ambro17 `_ - `Andrej Zhilenkov `_ - `Anton Tagunov `_ +- `Anya Marcano ` - `Avanatiker `_ - `Balduro `_ - `Bibo-Joshi `_ @@ -37,6 +40,7 @@ The following wonderful people contributed directly or indirectly to this projec - `daimajia `_ - `Daniel Reed `_ - `D David Livingston `_ +- `Dmitry Kolomatskiy `_ - `DonalDuck004 `_ - `Eana Hufwe `_ - `Ehsan Online `_ @@ -54,10 +58,14 @@ The following wonderful people contributed directly or indirectly to this projec - `gamgi `_ - `Gauthamram Ravichandran `_ - `Harshil `_ +- `Henry Galue ` - `Hugo Damer `_ - `ihoru `_ +- `Iulian Onofrei `_ +- `Jainam Oswal `_ - `Jasmin Bom `_ - `JASON0916 `_ +- `Jeamhowards Montiel ` - `jeffffc `_ - `Jelle Besseling `_ - `jh0ker `_ @@ -66,20 +74,27 @@ The following wonderful people contributed directly or indirectly to this projec - `Joscha Götzer `_ - `jossalgon `_ - `JRoot3D `_ +- `Juan Cuevas ` +- `kenjitagawa `_ - `kennethcheo `_ - `Kirill Vasin `_ - `Kjwon15 `_ - `Li-aung Yip `_ +- `locobott `_ - `Loo Zheng Yuan `_ - `LRezende `_ - `Luca Bellanti `_ +- `Lucas Molinari `_ +- `Luis Pérez `_ - `macrojames `_ - `Matheus Lemos `_ - `Michael Dix `_ - `Michael Elovskikh `_ - `Miguel C. R. `_ +- `Miguel Salomon ` - `miles `_ - `Mischa Krüger `_ +- `Mohd Yusuf `_ - `naveenvhegde `_ - `neurrone `_ - `NikitaPirate `_ @@ -90,10 +105,12 @@ The following wonderful people contributed directly or indirectly to this projec - `Oleg Sushchenko `_ - `Or Bin `_ - `overquota `_ +- `Pablo Martinez `_ - `Paradox `_ - `Patrick Hofmann `_ - `Paul Larsen `_ - `Pawan `_ +- `Philipp Isachenko `_ - `Pieter Schutz `_ - `Piraty `_ - `Poolitzer `_ @@ -101,11 +118,14 @@ The following wonderful people contributed directly or indirectly to this projec - `Rahiel Kasim `_ - `Riko Naka `_ - `Rizlas `_ +- Snehashish Biswas - `Sahil Sharma `_ - `Sam Mosleh `_ - `Sascha `_ - `Shelomentsev D `_ +- `Shivam `_ - `Shivam Saini `_ +- `Siloé Garcez `_ - `Simon Schürrle `_ - `sooyhwang `_ - `syntx `_ @@ -117,7 +137,9 @@ The following wonderful people contributed directly or indirectly to this projec - `Vorobjev Simon `_ - `Wagner Macedo `_ - `wjt `_ +- `Wonseok Oh `_ - `Yaw Danso `_ +- `Yao Kuan `_ - `zeroone2numeral2 `_ - `zeshuaro `_ - `zpavloudis `_ diff --git a/CHANGES.rst b/CHANGES.rst deleted file mode 100644 index 1c31cf0e093..00000000000 --- a/CHANGES.rst +++ /dev/null @@ -1,2808 +0,0 @@ -.. _ptb-changelog: - -========= -Changelog -========= - -Version 20.3 -============ -*Released 2023-05-07* - -This is the technical changelog for version 20.3. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -Major Changes -------------- - -- Full support for API 6.7 (`#3673`_) -- Add a Stability Policy (`#3622`_) - -New Features ------------- - -- Add ``Application.mark_data_for_update_persistence`` (`#3607`_) -- Make ``Message.link`` Point to Thread View Where Possible (`#3640`_) -- Localize Received ``datetime`` Objects According to ``Defaults.tzinfo`` (`#3632`_) - -Minor Changes, Documentation Improvements and CI ------------------------------------------------- - -- Empower ``ruff`` (`#3594`_) -- Drop Usage of ``sys.maxunicode`` (`#3630`_) -- Add String Representation for ``RequestParameter`` (`#3634`_) -- Stabilize CI by Rerunning Failed Tests (`#3631`_) -- Give Loggers Better Names (`#3623`_) -- Add Logging for Invalid JSON Data in ``BasePersistence.parse_json_payload`` (`#3668`_) -- Improve Warning Categories & Stacklevels (`#3674`_) -- Stabilize ``test_delete_sticker_set`` (`#3685`_) -- Shield Update Fetcher Task in ``Application.start`` (`#3657`_) -- Recover 100% Type Completeness (`#3676`_) -- Documentation Improvements (`#3628`_, `#3636`_, `#3694`_) - -Dependencies ------------- - -- Bump ``actions/stale`` from 7 to 8 (`#3644`_) -- Bump ``furo`` from 2023.3.23 to 2023.3.27 (`#3643`_) -- ``pre-commit`` autoupdate (`#3646`_, `#3688`_) -- Remove Deprecated ``codecov`` Package from CI (`#3664`_) -- Bump ``sphinx-copybutton`` from 0.5.1 to 0.5.2 (`#3662`_) -- Update ``httpx`` requirement from ~=0.23.3 to ~=0.24.0 (`#3660`_) -- Bump ``pytest`` from 7.2.2 to 7.3.1 (`#3661`_) - -.. _`#3673`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3673 -.. _`#3622`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3622 -.. _`#3607`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3607 -.. _`#3640`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3640 -.. _`#3632`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3632 -.. _`#3594`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3594 -.. _`#3630`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3630 -.. _`#3634`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3634 -.. _`#3631`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3631 -.. _`#3623`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3623 -.. _`#3668`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3668 -.. _`#3674`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3674 -.. _`#3685`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3685 -.. _`#3657`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3657 -.. _`#3676`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3676 -.. _`#3628`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3628 -.. _`#3636`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3636 -.. _`#3694`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3694 -.. _`#3644`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3644 -.. _`#3643`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3643 -.. _`#3646`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3646 -.. _`#3688`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3688 -.. _`#3664`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3664 -.. _`#3662`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3662 -.. _`#3660`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3660 -.. _`#3661`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3661 - -Version 20.2 -============ -*Released 2023-03-25* - -This is the technical changelog for version 20.2. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -Major Changes -------------- -- Full Support for API 6.6 (`#3584`_) -- Revert to HTTP/1.1 as Default and make HTTP/2 an Optional Dependency (`#3576`_) - -Minor Changes, Documentation Improvements and CI ------------------------------------------------- -- Documentation Improvements (`#3565`_, `#3600`_) -- Handle Symbolic Links in ``was_called_by`` (`#3552`_) -- Tidy Up Tests Directory (`#3553`_) -- Enhance ``Application.create_task`` (`#3543`_) -- Make Type Completeness Workflow Usable for ``PRs`` from Forks (`#3551`_) -- Refactor and Overhaul the Test Suite (`#3426`_) - -Dependencies ------------- -- Bump ``pytest-asyncio`` from 0.20.3 to 0.21.0 (`#3624`_) -- Bump ``furo`` from 2022.12.7 to 2023.3.23 (`#3625`_) -- Bump ``pytest-xdist`` from 3.2.0 to 3.2.1 (`#3606`_) -- ``pre-commit`` autoupdate (`#3577`_) -- Update ``apscheduler`` requirement from ~=3.10.0 to ~=3.10.1 (`#3572`_) -- Bump ``pytest`` from 7.2.1 to 7.2.2 (`#3573`_) -- Bump ``pytest-xdist`` from 3.1.0 to 3.2.0 (`#3550`_) -- Bump ``sphinxcontrib-mermaid`` from 0.7.1 to 0.8 (`#3549`_) - -.. _`#3584`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3584 -.. _`#3576`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3576 -.. _`#3565`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3565 -.. _`#3600`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3600 -.. _`#3552`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3552 -.. _`#3553`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3553 -.. _`#3543`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3543 -.. _`#3551`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3551 -.. _`#3426`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3426 -.. _`#3624`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3624 -.. _`#3625`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3625 -.. _`#3606`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3606 -.. _`#3577`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3577 -.. _`#3572`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3572 -.. _`#3573`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3573 -.. _`#3550`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3550 -.. _`#3549`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3549 - -Version 20.1 -============ -*Released 2023-02-09* - -This is the technical changelog for version 20.1. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -Major Changes -------------- - -- Full Support for Bot API 6.5 (`#3530`_) - -New Features ------------- - -- Add ``Application(Builder).post_stop`` (`#3466`_) -- Add ``Chat.effective_name`` Convenience Property (`#3485`_) -- Allow to Adjust HTTP Version and Use HTTP/2 by Default (`#3506`_) - -Documentation Improvements --------------------------- - -- Enhance ``chatmemberbot`` Example (`#3500`_) -- Automatically Generate Cross-Reference Links (`#3501`_, `#3529`_, `#3523`_) -- Add Some Graphic Elements to Docs (`#3535`_) -- Various Smaller Improvements (`#3464`_, `#3483`_, `#3484`_, `#3497`_, `#3512`_, `#3515`_, `#3498`_) - -Minor Changes, Documentation Improvements and CI ------------------------------------------------- - -- Update Copyright to 2023 (`#3459`_) -- Stabilize Tests on Closing and Hiding the General Forum Topic (`#3460`_) -- Fix Dependency Warning Typo (`#3474`_) -- Cache Dependencies on ``GitHub`` Actions (`#3469`_) -- Store Documentation Builts as ``GitHub`` Actions Artifacts (`#3468`_) -- Add ``ruff`` to ``pre-commit`` Hooks (`#3488`_) -- Improve Warning for ``days`` Parameter of ``JobQueue.run_daily`` (`#3503`_) -- Improve Error Message for ``NetworkError`` (`#3505`_) -- Lock Inactive Threads Only Once Each Day (`#3510`_) -- Bump ``pytest`` from 7.2.0 to 7.2.1 (`#3513`_) -- Check for 3D Arrays in ``check_keyboard_type`` (`#3514`_) -- Explicit Type Annotations (`#3508`_) -- Increase Verbosity of Type Completeness CI Job (`#3531`_) -- Fix CI on Python 3.11 + Windows (`#3547`_) - -Dependencies ------------- - -- Bump ``actions/stale`` from 6 to 7 (`#3461`_) -- Bump ``dessant/lock-threads`` from 3.0.0 to 4.0.0 (`#3462`_) -- ``pre-commit`` autoupdate (`#3470`_) -- Update ``httpx`` requirement from ~=0.23.1 to ~=0.23.3 (`#3489`_) -- Update ``cachetools`` requirement from ~=5.2.0 to ~=5.2.1 (`#3502`_) -- Improve Config for ``ruff`` and Bump to ``v0.0.222`` (`#3507`_) -- Update ``cachetools`` requirement from ~=5.2.1 to ~=5.3.0 (`#3520`_) -- Bump ``isort`` to 5.12.0 (`#3525`_) -- Update ``apscheduler`` requirement from ~=3.9.1 to ~=3.10.0 (`#3532`_) -- ``pre-commit`` autoupdate (`#3537`_) -- Update ``cryptography`` requirement to >=39.0.1 to address Vulnerability (`#3539`_) - - - -.. _`#3530`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3530 -.. _`#3466`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3466 -.. _`#3485`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3485 -.. _`#3506`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3506 -.. _`#3500`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3500 -.. _`#3501`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3501 -.. _`#3529`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3529 -.. _`#3523`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3523 -.. _`#3535`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3535 -.. _`#3464`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3464 -.. _`#3483`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3483 -.. _`#3484`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3484 -.. _`#3497`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3497 -.. _`#3512`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3512 -.. _`#3515`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3515 -.. _`#3498`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3498 -.. _`#3459`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3459 -.. _`#3460`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3460 -.. _`#3474`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3474 -.. _`#3469`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3469 -.. _`#3468`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3468 -.. _`#3488`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3488 -.. _`#3503`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3503 -.. _`#3505`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3505 -.. _`#3510`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3510 -.. _`#3513`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3513 -.. _`#3514`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3514 -.. _`#3508`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3508 -.. _`#3531`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3531 -.. _`#3547`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3547 -.. _`#3461`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3461 -.. _`#3462`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3462 -.. _`#3470`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3470 -.. _`#3489`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3489 -.. _`#3502`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3502 -.. _`#3507`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3507 -.. _`#3520`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3520 -.. _`#3525`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3525 -.. _`#3532`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3532 -.. _`#3537`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3537 -.. _`#3539`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3539 - -Version 20.0 -============ -*Released 2023-01-01* - -This is the technical changelog for version 20.0. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -Major Changes -------------- - -- Full Support For Bot API 6.4 (`#3449`_) - -Minor Changes, Documentation Improvements and CI ------------------------------------------------- - -- Documentation Improvements (`#3428`_, `#3423`_, `#3429`_, `#3441`_, `#3404`_, `#3443`_) -- Allow ``Sequence`` Input for Bot Methods (`#3412`_) -- Update Link-Check CI and Replace a Dead Link (`#3456`_) -- Freeze Classes Without Arguments (`#3453`_) -- Add New Constants (`#3444`_) -- Override ``Bot.__deepcopy__`` to Raise ``TypeError`` (`#3446`_) -- Add Log Decorator to ``Bot.get_webhook_info`` (`#3442`_) -- Add Documentation On Verifying Releases (`#3436`_) -- Drop Undocumented ``Job.__lt__`` (`#3432`_) - -Dependencies ------------- - -- Downgrade ``sphinx`` to 5.3.0 to Fix Search (`#3457`_) -- Bump ``sphinx`` from 5.3.0 to 6.0.0 (`#3450`_) - -.. _`#3449`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3449 -.. _`#3428`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3428 -.. _`#3423`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3423 -.. _`#3429`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3429 -.. _`#3441`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3441 -.. _`#3404`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3404 -.. _`#3443`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3443 -.. _`#3412`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3412 -.. _`#3456`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3456 -.. _`#3453`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3453 -.. _`#3444`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3444 -.. _`#3446`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3446 -.. _`#3442`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3442 -.. _`#3436`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3436 -.. _`#3432`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3432 -.. _`#3457`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3457 -.. _`#3450`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3450 - -Version 20.0b0 -============== -*Released 2022-12-15* - -This is the technical changelog for version 20.0b0. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -Major Changes -------------- - -- Make ``TelegramObject`` Immutable (`#3249`_) - -Minor Changes, Documentation Improvements and CI ------------------------------------------------- - -- Reduce Code Duplication in Testing ``Defaults`` (`#3419`_) -- Add Notes and Warnings About Optional Dependencies (`#3393`_) -- Simplify Internals of ``Bot`` Methods (`#3396`_) -- Reduce Code Duplication in Several ``Bot`` Methods (`#3385`_) -- Documentation Improvements (`#3386`_, `#3395`_, `#3398`_, `#3403`_) - -Dependencies ------------- - -- Bump ``pytest-xdist`` from 3.0.2 to 3.1.0 (`#3415`_) -- Bump ``pytest-asyncio`` from 0.20.2 to 0.20.3 (`#3417`_) -- ``pre-commit`` autoupdate (`#3409`_) - -.. _`#3249`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3249 -.. _`#3419`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3419 -.. _`#3393`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3393 -.. _`#3396`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3396 -.. _`#3385`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3385 -.. _`#3386`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3386 -.. _`#3395`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3395 -.. _`#3398`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3398 -.. _`#3403`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3403 -.. _`#3415`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3415 -.. _`#3417`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3417 -.. _`#3409`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3409 - -Version 20.0a6 -============== -*Released 2022-11-24* - -This is the technical changelog for version 20.0a6. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -Bug Fixes ---------- - -- Only Persist Arbitrary ``callback_data`` if ``ExtBot.callback_data_cache`` is Present (`#3384`_) -- Improve Backwards Compatibility of ``TelegramObjects`` Pickle Behavior (`#3382`_) -- Fix Naming and Keyword Arguments of ``File.download_*`` Methods (`#3380`_) -- Fix Return Value Annotation of ``Chat.create_forum_topic`` (`#3381`_) - -.. _`#3384`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3384 -.. _`#3382`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3382 -.. _`#3380`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3380 -.. _`#3381`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3381 - -Version 20.0a5 -============== -*Released 2022-11-22* - -This is the technical changelog for version 20.0a5. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -Major Changes -------------- - -- API 6.3 (`#3346`_, `#3343`_, `#3342`_, `#3360`_) -- Explicit ``local_mode`` Setting (`#3154`_) -- Make Almost All 3rd Party Dependencies Optional (`#3267`_) -- Split ``File.download`` Into ``File.download_to_drive`` And ``File.download_to_memory`` (`#3223`_) - -New Features ------------- - -- Add Properties for API Settings of ``Bot`` (`#3247`_) -- Add ``chat_id`` and ``username`` Parameters to ``ChatJoinRequestHandler`` (`#3261`_) -- Introduce ``TelegramObject.api_kwargs`` (`#3233`_) -- Add Two Constants Related to Local Bot API Servers (`#3296`_) -- Add ``recursive`` Parameter to ``TelegramObject.to_dict()`` (`#3276`_) -- Overhaul String Representation of ``TelegramObject`` (`#3234`_) -- Add Methods ``Chat.mention_{html, markdown, markdown_v2}`` (`#3308`_) -- Add ``constants.MessageLimit.DEEP_LINK_LENGTH`` (`#3315`_) -- Add Shortcut Parameters ``caption``, ``parse_mode`` and ``caption_entities`` to ``Bot.send_media_group`` (`#3295`_) -- Add Several New Enums To Constants (`#3351`_) - -Bug Fixes ---------- - -- Fix ``CallbackQueryHandler`` Not Handling Non-String Data Correctly With Regex Patterns (`#3252`_) -- Fix Defaults Handling in ``Bot.answer_web_app_query`` (`#3362`_) - -Documentation Improvements --------------------------- - -- Update PR Template (`#3361`_) -- Document Dunder Methods of ``TelegramObject`` (`#3319`_) -- Add Several References to Wiki pages (`#3306`_) -- Overhaul Search bar (`#3218`_) -- Unify Documentation of Arguments and Attributes of Telegram Classes (`#3217`_, `#3292`_, `#3303`_, `#3312`_, `#3314`_) -- Several Smaller Improvements (`#3214`_, `#3271`_, `#3289`_, `#3326`_, `#3370`_, `#3376`_, `#3366`_) - -Minor Changes, Documentation Improvements and CI ------------------------------------------------- - -- Improve Warning About Unknown ``ConversationHandler`` States (`#3242`_) -- Switch from Stale Bot to ``GitHub`` Actions (`#3243`_) -- Bump Python 3.11 to RC2 in Test Matrix (`#3246`_) -- Make ``Job.job`` a Property and Make ``Jobs`` Hashable (`#3250`_) -- Skip ``JobQueue`` Tests on Windows Again (`#3280`_) -- Read-Only ``CallbackDataCache`` (`#3266`_) -- Type Hinting Fix for ``Message.effective_attachment`` (`#3294`_) -- Run Unit Tests in Parallel (`#3283`_) -- Update Test Matrix to Use Stable Python 3.11 (`#3313`_) -- Don't Edit Objects In-Place When Inserting ``ext.Defaults`` (`#3311`_) -- Add a Test for ``MessageAttachmentType`` (`#3335`_) -- Add Three New Test Bots (`#3347`_) -- Improve Unit Tests Regarding ``ChatMemberUpdated.difference`` (`#3352`_) -- Flaky Unit Tests: Use ``pytest`` Marker (`#3354`_) -- Fix ``DeepSource`` Issues (`#3357`_) -- Handle Lists and Tuples and Datetimes Directly in ``TelegramObject.to_dict`` (`#3353`_) -- Update Meta Config (`#3365`_) -- Merge ``ChatDescriptionLimit`` Enum Into ``ChatLimit`` (`#3377`_) - -Dependencies ------------- - -- Bump ``pytest`` from 7.1.2 to 7.1.3 (`#3228`_) -- ``pre-commit`` Updates (`#3221`_) -- Bump ``sphinx`` from 5.1.1 to 5.2.3 (`#3269`_) -- Bump ``furo`` from 2022.6.21 to 2022.9.29 (`#3268`_) -- Bump ``actions/stale`` from 5 to 6 (`#3277`_) -- ``pre-commit`` autoupdate (`#3282`_) -- Bump ``sphinx`` from 5.2.3 to 5.3.0 (`#3300`_) -- Bump ``pytest-asyncio`` from 0.19.0 to 0.20.1 (`#3299`_) -- Bump ``pytest`` from 7.1.3 to 7.2.0 (`#3318`_) -- Bump ``pytest-xdist`` from 2.5.0 to 3.0.2 (`#3317`_) -- ``pre-commit`` autoupdate (`#3325`_) -- Bump ``pytest-asyncio`` from 0.20.1 to 0.20.2 (`#3359`_) -- Update ``httpx`` requirement from ~=0.23.0 to ~=0.23.1 (`#3373`_) - -.. _`#3346`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3346 -.. _`#3343`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3343 -.. _`#3342`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3342 -.. _`#3360`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3360 -.. _`#3154`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3154 -.. _`#3267`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3267 -.. _`#3223`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3223 -.. _`#3247`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3247 -.. _`#3261`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3261 -.. _`#3233`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3233 -.. _`#3296`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3296 -.. _`#3276`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3276 -.. _`#3234`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3234 -.. _`#3308`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3308 -.. _`#3315`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3315 -.. _`#3295`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3295 -.. _`#3351`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3351 -.. _`#3252`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3252 -.. _`#3362`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3362 -.. _`#3361`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3361 -.. _`#3319`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3319 -.. _`#3306`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3306 -.. _`#3218`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3218 -.. _`#3217`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3217 -.. _`#3292`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3292 -.. _`#3303`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3303 -.. _`#3312`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3312 -.. _`#3314`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3314 -.. _`#3214`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3214 -.. _`#3271`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3271 -.. _`#3289`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3289 -.. _`#3326`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3326 -.. _`#3370`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3370 -.. _`#3376`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3376 -.. _`#3366`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3366 -.. _`#3242`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3242 -.. _`#3243`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3243 -.. _`#3246`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3246 -.. _`#3250`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3250 -.. _`#3280`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3280 -.. _`#3266`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3266 -.. _`#3294`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3294 -.. _`#3283`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3283 -.. _`#3313`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3313 -.. _`#3311`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3311 -.. _`#3335`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3335 -.. _`#3347`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3347 -.. _`#3352`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3352 -.. _`#3354`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3354 -.. _`#3357`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3357 -.. _`#3353`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3353 -.. _`#3365`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3365 -.. _`#3377`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3377 -.. _`#3228`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3228 -.. _`#3221`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3221 -.. _`#3269`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3269 -.. _`#3268`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3268 -.. _`#3277`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3277 -.. _`#3282`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3282 -.. _`#3300`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3300 -.. _`#3299`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3299 -.. _`#3318`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3318 -.. _`#3317`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3317 -.. _`#3325`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3325 -.. _`#3359`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3359 -.. _`#3373`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3373 - -Version 20.0a4 -============== -*Released 2022-08-27* - -This is the technical changelog for version 20.0a4. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -Hot Fixes ---------- - -* Fix a Bug in ``setup.py`` Regarding Optional Dependencies (`#3209`_) - -.. _`#3209`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3209 - -Version 20.0a3 -============== -*Released 2022-08-27* - -This is the technical changelog for version 20.0a3. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -Major Changes -------------- - -- Full Support for API 6.2 (`#3195`_) - -New Features ------------- - -- New Rate Limiting Mechanism (`#3148`_) -- Make ``chat/user_data`` Available in Error Handler for Errors in Jobs (`#3152`_) -- Add ``Application.post_shutdown`` (`#3126`_) - -Bug Fixes ---------- - -- Fix ``helpers.mention_markdown`` for Markdown V1 and Improve Related Unit Tests (`#3155`_) -- Add ``api_kwargs`` Parameter to ``Bot.log_out`` and Improve Related Unit Tests (`#3147`_) -- Make ``Bot.delete_my_commands`` a Coroutine Function (`#3136`_) -- Fix ``ConversationHandler.check_update`` not respecting ``per_user`` (`#3128`_) - -Minor Changes, Documentation Improvements and CI ------------------------------------------------- - -- Add Python 3.11 to Test Suite & Adapt Enum Behaviour (`#3168`_) -- Drop Manual Token Validation (`#3167`_) -- Simplify Unit Tests for ``Bot.send_chat_action`` (`#3151`_) -- Drop ``pre-commit`` Dependencies from ``requirements-dev.txt`` (`#3120`_) -- Change Default Values for ``concurrent_updates`` and ``connection_pool_size`` (`#3127`_) -- Documentation Improvements (`#3139`_, `#3153`_, `#3135`_) -- Type Hinting Fixes (`#3202`_) - -Dependencies ------------- - -- Bump ``sphinx`` from 5.0.2 to 5.1.1 (`#3177`_) -- Update ``pre-commit`` Dependencies (`#3085`_) -- Bump ``pytest-asyncio`` from 0.18.3 to 0.19.0 (`#3158`_) -- Update ``tornado`` requirement from ~=6.1 to ~=6.2 (`#3149`_) -- Bump ``black`` from 22.3.0 to 22.6.0 (`#3132`_) -- Bump ``actions/setup-python`` from 3 to 4 (`#3131`_) - -.. _`#3195`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3195 -.. _`#3148`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3148 -.. _`#3152`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3152 -.. _`#3126`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3126 -.. _`#3155`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3155 -.. _`#3147`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3147 -.. _`#3136`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3136 -.. _`#3128`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3128 -.. _`#3168`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3168 -.. _`#3167`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3167 -.. _`#3151`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3151 -.. _`#3120`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3120 -.. _`#3127`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3127 -.. _`#3139`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3139 -.. _`#3153`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3153 -.. _`#3135`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3135 -.. _`#3202`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3202 -.. _`#3177`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3177 -.. _`#3085`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3085 -.. _`#3158`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3158 -.. _`#3149`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3149 -.. _`#3132`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3132 -.. _`#3131`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3131 - -Version 20.0a2 -============== -*Released 2022-06-27* - -This is the technical changelog for version 20.0a2. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -Major Changes -------------- - -- Full Support for API 6.1 (`#3112`_) - -New Features ------------- - -- Add Additional Shortcut Methods to ``Chat`` (`#3115`_) -- Mermaid-based Example State Diagrams (`#3090`_) - -Minor Changes, Documentation Improvements and CI ------------------------------------------------- - -- Documentation Improvements (`#3103`_, `#3121`_, `#3098`_) -- Stabilize CI (`#3119`_) -- Bump ``pyupgrade`` from 2.32.1 to 2.34.0 (`#3096`_) -- Bump ``furo`` from 2022.6.4 to 2022.6.4.1 (`#3095`_) -- Bump ``mypy`` from 0.960 to 0.961 (`#3093`_) - -.. _`#3112`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3112 -.. _`#3115`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3115 -.. _`#3090`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3090 -.. _`#3103`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3103 -.. _`#3121`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3121 -.. _`#3098`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3098 -.. _`#3119`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3119 -.. _`#3096`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3096 -.. _`#3095`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3095 -.. _`#3093`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3093 - -Version 20.0a1 -============== -*Released 2022-06-09* - -This is the technical changelog for version 20.0a1. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -Major Changes: --------------- - -- Drop Support for ``ujson`` and instead ``BaseRequest.parse_json_payload`` (`#3037`_, `#3072`_) -- Drop ``InputFile.is_image`` (`#3053`_) -- Drop Explicit Type conversions in ``__init__`` s (`#3056`_) -- Handle List-Valued Attributes More Consistently (`#3057`_) -- Split ``{Command, Prefix}Handler`` And Make Attributes Immutable (`#3045`_) -- Align Behavior Of ``JobQueue.run_daily`` With ``cron`` (`#3046`_) -- Make PTB Specific Keyword-Only Arguments for PTB Specific in Bot methods (`#3035`_) -- Adjust Equality Comparisons to Fit Bot API 6.0 (`#3033`_) -- Add Tuple Based Version Info (`#3030`_)- Improve Type Annotations for ``CallbackContext`` and Move Default Type Alias to ``ContextTypes.DEFAULT_TYPE`` (`#3017`_, `#3023`_) -- Rename ``Job.context`` to ``Job.data`` (`#3028`_) -- Rename ``Handler`` to ``BaseHandler`` (`#3019`_) - -New Features: -------------- - -- Add ``Application.post_init`` (`#3078`_) -- Add Arguments ``chat/user_id`` to ``CallbackContext`` And Example On Custom Webhook Setups (`#3059`_) -- Add Convenience Property ``Message.id`` (`#3077`_) -- Add Example for ``WebApp`` (`#3052`_) -- Rename ``telegram.bot_api_version`` to ``telegram.__bot_api_version__`` (`#3030`_) - -Bug Fixes: ----------- - -- Fix Non-Blocking Entry Point in ``ConversationHandler`` (`#3068`_) -- Escape Backslashes in ``escape_markdown`` (`#3055`_) - -Dependencies: -------------- - -- Update ``httpx`` requirement from ~=0.22.0 to ~=0.23.0 (`#3069`_) -- Update ``cachetools`` requirement from ~=5.0.0 to ~=5.2.0 (`#3058`_, `#3080`_) - -Minor Changes, Documentation Improvements and CI: -------------------------------------------------- - -- Move Examples To Documentation (`#3089`_) -- Documentation Improvements and Update Dependencies (`#3010`_, `#3007`_, `#3012`_, `#3067`_, `#3081`_, `#3082`_) -- Improve Some Unit Tests (`#3026`_) -- Update Code Quality dependencies (`#3070`_, `#3032`_,`#2998`_, `#2999`_) -- Don't Set Signal Handlers On Windows By Default (`#3065`_) -- Split ``{Command, Prefix}Handler`` And Make Attributes Immutable (`#3045`_) -- Apply ``isort`` and Update ``pre-commit.ci`` Configuration (`#3049`_) -- Adjust ``pre-commit`` Settings for ``isort`` (`#3043`_) -- Add Version Check to Examples (`#3036`_) -- Use ``Collection`` Instead of ``List`` and ``Tuple`` (`#3025`_) -- Remove Client-Side Parameter Validation (`#3024`_) -- Don't Pass Default Values of Optional Parameters to Telegram (`#2978`_) -- Stabilize ``Application.run_*`` on Python 3.7 (`#3009`_) -- Ignore Code Style Commits in ``git blame`` (`#3003`_) -- Adjust Tests to Changed API Behavior (`#3002`_) - -.. _`#2978`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2978 -.. _`#2998`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2998 -.. _`#2999`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2999 -.. _`#3002`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3002 -.. _`#3003`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3003 -.. _`#3007`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3007 -.. _`#3009`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3009 -.. _`#3010`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3010 -.. _`#3012`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3012 -.. _`#3017`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3017 -.. _`#3019`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3019 -.. _`#3023`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3023 -.. _`#3024`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3024 -.. _`#3025`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3025 -.. _`#3026`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3026 -.. _`#3028`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3028 -.. _`#3030`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3030 -.. _`#3032`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3032 -.. _`#3033`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3033 -.. _`#3035`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3035 -.. _`#3036`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3036 -.. _`#3037`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3037 -.. _`#3043`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3043 -.. _`#3045`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3045 -.. _`#3046`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3046 -.. _`#3049`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3049 -.. _`#3052`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3052 -.. _`#3053`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3053 -.. _`#3055`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3055 -.. _`#3056`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3056 -.. _`#3057`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3057 -.. _`#3058`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3058 -.. _`#3059`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3059 -.. _`#3065`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3065 -.. _`#3067`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3067 -.. _`#3068`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3068 -.. _`#3069`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3069 -.. _`#3070`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3070 -.. _`#3072`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3072 -.. _`#3077`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3077 -.. _`#3078`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3078 -.. _`#3080`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3080 -.. _`#3081`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3081 -.. _`#3082`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3082 -.. _`#3089`: https://github.com/python-telegram-bot/python-telegram-bot/pull/3089 - -Version 20.0a0 -============== -*Released 2022-05-06* - -This is the technical changelog for version 20.0a0. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -Major Changes: --------------- - -- Refactor Initialization of Persistence Classes - (`#2604 `__) -- Drop Non-``CallbackContext`` API - (`#2617 `__) -- Remove ``__dict__`` from ``__slots__`` and drop Python 3.6 - (`#2619 `__, - `#2636 `__) -- Move and Rename ``TelegramDecryptionError`` to - ``telegram.error.PassportDecryptionError`` - (`#2621 `__) -- Make ``BasePersistence`` Methods Abstract - (`#2624 `__) -- Remove ``day_is_strict`` argument of ``JobQueue.run_monthly`` - (`#2634 `__ - by `iota-008 `__) -- Move ``Defaults`` to ``telegram.ext`` - (`#2648 `__) -- Remove Deprecated Functionality - (`#2644 `__, - `#2740 `__, - `#2745 `__) -- Overhaul of Filters - (`#2759 `__, - `#2922 `__) -- Switch to ``asyncio`` and Refactor PTBs Architecture - (`#2731 `__) -- Improve ``Job.__getattr__`` - (`#2832 `__) -- Remove ``telegram.ReplyMarkup`` - (`#2870 `__) -- Persistence of ``Bots``: Refactor Automatic Replacement and - Integration with ``TelegramObject`` - (`#2893 `__) - -New Features: -------------- - -- Introduce Builder Pattern - (`#2646 `__) -- Add ``Filters.update.edited`` - (`#2705 `__ - by `PhilippFr `__) -- Introduce ``Enums`` for ``telegram.constants`` - (`#2708 `__) -- Accept File Paths for ``private_key`` - (`#2724 `__) -- Associate ``Jobs`` with ``chat/user_id`` - (`#2731 `__) -- Convenience Functionality for ``ChatInviteLinks`` - (`#2782 `__) -- Add ``Dispatcher.add_handlers`` - (`#2823 `__) -- Improve Error Messages in ``CommandHandler.__init__`` - (`#2837 `__) -- ``Defaults.protect_content`` - (`#2840 `__) -- Add ``Dispatcher.migrate_chat_data`` - (`#2848 `__ - by `DonalDuck004 `__) -- Add Method ``drop_chat/user_data`` to ``Dispatcher`` and Persistence - (`#2852 `__) -- Add methods ``ChatPermissions.{all, no}_permissions`` (`#2948 `__) -- Full Support for API 6.0 - (`#2956 `__) -- Add Python 3.10 to Test Suite - (`#2968 `__) - -Bug Fixes & Minor Changes: --------------------------- - -- Improve Type Hinting for ``CallbackContext`` - (`#2587 `__ - by `revolter `__) -- Fix Signatures and Improve ``test_official`` - (`#2643 `__) -- Refine ``Dispatcher.dispatch_error`` - (`#2660 `__) -- Make ``InlineQuery.answer`` Raise ``ValueError`` - (`#2675 `__) -- Improve Signature Inspection for Bot Methods - (`#2686 `__) -- Introduce ``TelegramObject.set/get_bot`` - (`#2712 `__ - by `zpavloudis `__) -- Improve Subscription of ``TelegramObject`` - (`#2719 `__ - by `SimonDamberg `__) -- Use Enums for Dynamic Types & Rename Two Attributes in ``ChatMember`` - (`#2817 `__) -- Return Plain Dicts from ``BasePersistence.get_*_data`` - (`#2873 `__) -- Fix a Bug in ``ChatMemberUpdated.difference`` - (`#2947 `__) -- Update Dependency Policy - (`#2958 `__) - -Internal Restructurings & Improvements: ---------------------------------------- - -- Add User Friendly Type Check For Init Of - ``{Inline, Reply}KeyboardMarkup`` - (`#2657 `__) -- Warnings Overhaul - (`#2662 `__) -- Clear Up Import Policy - (`#2671 `__) -- Mark Internal Modules As Private - (`#2687 `__ - by `kencx `__) -- Handle Filepaths via the ``pathlib`` Module - (`#2688 `__ - by `eldbud `__) -- Refactor MRO of ``InputMedia*`` and Some File-Like Classes - (`#2717 `__ - by `eldbud `__) -- Update Exceptions for Immutable Attributes - (`#2749 `__) -- Refactor Warnings in ``ConversationHandler`` - (`#2755 `__, - `#2784 `__) -- Use ``__all__`` Consistently - (`#2805 `__) - -CI, Code Quality & Test Suite Improvements: -------------------------------------------- - -- Add Custom ``pytest`` Marker to Ease Development - (`#2628 `__) -- Pass Failing Jobs to Error Handlers - (`#2692 `__) -- Update Notification Workflows - (`#2695 `__) -- Use Error Messages for ``pylint`` Instead of Codes - (`#2700 `__ - by `Piraty `__) -- Make Tests Agnostic of the CWD - (`#2727 `__ - by `eldbud `__) -- Update Code Quality Dependencies - (`#2748 `__) -- Improve Code Quality - (`#2783 `__) -- Update ``pre-commit`` Settings & Improve a Test - (`#2796 `__) -- Improve Code Quality & Test Suite - (`#2843 `__) -- Fix failing animation tests - (`#2865 `__) -- Update and Expand Tests & pre-commit Settings and Improve Code - Quality - (`#2925 `__) -- Extend Code Formatting With Black - (`#2972 `__) -- Update Workflow Permissions - (`#2984 `__) -- Adapt Tests to Changed ``Bot.get_file`` Behavior - (`#2995 `__) - -Documentation Improvements: ---------------------------- - -- Doc Fixes - (`#2597 `__) -- Add Code Comment Guidelines to Contribution Guide - (`#2612 `__) -- Add Cross-References to External Libraries & Other Documentation - Improvements - (`#2693 `__, - `#2691 `__ - by `joesinghh `__, - `#2739 `__ - by `eldbud `__) -- Use Furo Theme, Make Parameters Referenceable, Add Documentation - Building to CI, Improve Links to Source Code & Other Improvements - (`#2856 `__, - `#2798 `__, - `#2854 `__, - `#2841 `__) -- Documentation Fixes & Improvements - (`#2822 `__) -- Replace ``git.io`` Links - (`#2872 `__ - by `murugu-21 `__) -- Overhaul Readmes, Update RTD Startpage & Other Improvements - (`#2969 `__) - -Version 13.11 -============= -*Released 2022-02-02* - -This is the technical changelog for version 13.11. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -**Major Changes:** - -- Full Support for Bot API 5.7 (`#2881`_) - -.. _`#2881`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2881 - -Version 13.10 -============= -*Released 2022-01-03* - -This is the technical changelog for version 13.10. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -**Major Changes:** - -- Full Support for API 5.6 (`#2835`_) - -**Minor Changes & Doc fixes:** - -- Update Copyright to 2022 (`#2836`_) -- Update Documentation of ``BotCommand`` (`#2820`_) - -.. _`#2835`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2835 -.. _`#2836`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2836 -.. _`#2820`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2820 - -Version 13.9 -============ -*Released 2021-12-11* - -This is the technical changelog for version 13.9. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -**Major Changes:** - -- Full Support for Api 5.5 (`#2809`_) - -**Minor Changes** - -- Adjust Automated Locking of Inactive Issues (`#2775`_) - -.. _`#2809`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2809 -.. _`#2775`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2775 - -Version 13.8.1 -============== -*Released 2021-11-08* - -This is the technical changelog for version 13.8.1. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -**Doc fixes:** - -- Add ``ChatJoinRequest(Handler)`` to Docs (`#2771`_) - -.. _`#2771`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2771 - -Version 13.8 -============ -*Released 2021-11-08* - -This is the technical changelog for version 13.8. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -**Major Changes:** - -- Full support for API 5.4 (`#2767`_) - -**Minor changes, CI improvements, Doc fixes and Type hinting:** - -- Create Issue Template Forms (`#2689`_) -- Fix ``camelCase`` Functions in ``ExtBot`` (`#2659`_) -- Fix Empty Captions not Being Passed by ``Bot.copy_message`` (`#2651`_) -- Fix Setting Thumbs When Uploading A Single File (`#2583`_) -- Fix Bug in ``BasePersistence.insert``/``replace_bot`` for Objects with ``__dict__`` not in ``__slots__`` (`#2603`_) - -.. _`#2767`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2767 -.. _`#2689`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2689 -.. _`#2659`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2659 -.. _`#2651`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2651 -.. _`#2583`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2583 -.. _`#2603`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2603 - -Version 13.7 -============ -*Released 2021-07-01* - -This is the technical changelog for version 13.7. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. - -**Major Changes:** - -- Full support for Bot API 5.3 (`#2572`_) - -**Bug Fixes:** - -- Fix Bug in ``BasePersistence.insert/replace_bot`` for Objects with ``__dict__`` in their slots (`#2561`_) -- Remove Incorrect Warning About ``Defaults`` and ``ExtBot`` (`#2553`_) - -**Minor changes, CI improvements, Doc fixes and Type hinting:** - -- Type Hinting Fixes (`#2552`_) -- Doc Fixes (`#2551`_) -- Improve Deprecation Warning for ``__slots__`` (`#2574`_) -- Stabilize CI (`#2575`_) -- Fix Coverage Configuration (`#2571`_) -- Better Exception-Handling for ``BasePersistence.replace/insert_bot`` (`#2564`_) -- Remove Deprecated ``pass_args`` from Deeplinking Example (`#2550`_) - -.. _`#2572`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2572 -.. _`#2561`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2561 -.. _`#2553`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2553 -.. _`#2552`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2552 -.. _`#2551`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2551 -.. _`#2574`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2574 -.. _`#2575`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2575 -.. _`#2571`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2571 -.. _`#2564`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2564 -.. _`#2550`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2550 - -Version 13.6 -============ -*Released 2021-06-06* - -New Features: - -- Arbitrary ``callback_data`` (`#1844`_) -- Add ``ContextTypes`` & ``BasePersistence.refresh_user/chat/bot_data`` (`#2262`_) -- Add ``Filters.attachment`` (`#2528`_) -- Add ``pattern`` Argument to ``ChosenInlineResultHandler`` (`#2517`_) - -Major Changes: - -- Add ``slots`` (`#2345`_) - -Minor changes, CI improvements, Doc fixes and Type hinting: - -- Doc Fixes (`#2495`_, `#2510`_) -- Add ``max_connections`` Parameter to ``Updater.start_webhook`` (`#2547`_) -- Fix for ``Promise.done_callback`` (`#2544`_) -- Improve Code Quality (`#2536`_, `#2454`_) -- Increase Test Coverage of ``CallbackQueryHandler`` (`#2520`_) -- Stabilize CI (`#2522`_, `#2537`_, `#2541`_) -- Fix ``send_phone_number_to_provider`` argument for ``Bot.send_invoice`` (`#2527`_) -- Handle Classes as Input for ``BasePersistence.replace/insert_bot`` (`#2523`_) -- Bump Tornado Version and Remove Workaround from `#2067`_ (`#2494`_) - -.. _`#1844`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1844 -.. _`#2262`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2262 -.. _`#2528`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2528 -.. _`#2517`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2517 -.. _`#2345`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2345 -.. _`#2495`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2495 -.. _`#2547`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2547 -.. _`#2544`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2544 -.. _`#2536`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2536 -.. _`#2454`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2454 -.. _`#2520`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2520 -.. _`#2522`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2522 -.. _`#2537`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2537 -.. _`#2541`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2541 -.. _`#2527`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2527 -.. _`#2523`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2523 -.. _`#2067`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2067 -.. _`#2494`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2494 -.. _`#2510`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2510 - -Version 13.5 -============ -*Released 2021-04-30* - -**Major Changes:** - -- Full support of Bot API 5.2 (`#2489`_). - - .. note:: - The ``start_parameter`` argument of ``Bot.send_invoice`` and the corresponding shortcuts is now optional, so the order of - parameters had to be changed. Make sure to update your method calls accordingly. - -- Update ``ChatActions``, Deprecating ``ChatAction.RECORD_AUDIO`` and ``ChatAction.UPLOAD_AUDIO`` (`#2460`_) - -**New Features:** - -- Convenience Utilities & Example for Handling ``ChatMemberUpdated`` (`#2490`_) -- ``Filters.forwarded_from`` (`#2446`_) - -**Minor changes, CI improvements, Doc fixes and Type hinting:** - -- Improve Timeouts in ``ConversationHandler`` (`#2417`_) -- Stabilize CI (`#2480`_) -- Doc Fixes (`#2437`_) -- Improve Type Hints of Data Filters (`#2456`_) -- Add Two ``UserWarnings`` (`#2464`_) -- Improve Code Quality (`#2450`_) -- Update Fallback Test-Bots (`#2451`_) -- Improve Examples (`#2441`_, `#2448`_) - -.. _`#2489`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2489 -.. _`#2460`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2460 -.. _`#2490`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2490 -.. _`#2446`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2446 -.. _`#2417`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2417 -.. _`#2480`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2480 -.. _`#2437`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2437 -.. _`#2456`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2456 -.. _`#2464`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2464 -.. _`#2450`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2450 -.. _`#2451`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2451 -.. _`#2441`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2441 -.. _`#2448`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2448 - -Version 13.4.1 -============== -*Released 2021-03-14* - -**Hot fix release:** - -- Fixed a bug in ``setup.py`` (`#2431`_) - -.. _`#2431`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2431 - -Version 13.4 -============ -*Released 2021-03-14* - -**Major Changes:** - -- Full support of Bot API 5.1 (`#2424`_) - -**Minor changes, CI improvements, doc fixes and type hinting:** - -- Improve ``Updater.set_webhook`` (`#2419`_) -- Doc Fixes (`#2404`_) -- Type Hinting Fixes (`#2425`_) -- Update ``pre-commit`` Settings (`#2415`_) -- Fix Logging for Vendored ``urllib3`` (`#2427`_) -- Stabilize Tests (`#2409`_) - -.. _`#2424`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2424 -.. _`#2419`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2419 -.. _`#2404`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2404 -.. _`#2425`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2425 -.. _`#2415`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2415 -.. _`#2427`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2427 -.. _`#2409`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2409 - -Version 13.3 -============ -*Released 2021-02-19* - -**Major Changes:** - -- Make ``cryptography`` Dependency Optional & Refactor Some Tests (`#2386`_, `#2370`_) -- Deprecate ``MessageQueue`` (`#2393`_) - -**Bug Fixes:** - -- Refactor ``Defaults`` Integration (`#2363`_) -- Add Missing ``telegram.SecureValue`` to init and Docs (`#2398`_) - -**Minor changes:** - -- Doc Fixes (`#2359`_) - -.. _`#2386`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2386 -.. _`#2370`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2370 -.. _`#2393`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2393 -.. _`#2363`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2363 -.. _`#2398`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2398 -.. _`#2359`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2359 - -Version 13.2 -============ -*Released 2021-02-02* - -**Major Changes:** - -- Introduce ``python-telegram-bot-raw`` (`#2324`_) -- Explicit Signatures for Shortcuts (`#2240`_) - -**New Features:** - -- Add Missing Shortcuts to ``Message`` (`#2330`_) -- Rich Comparison for ``Bot`` (`#2320`_) -- Add ``run_async`` Parameter to ``ConversationHandler`` (`#2292`_) -- Add New Shortcuts to ``Chat`` (`#2291`_) -- Add New Constant ``MAX_ANSWER_CALLBACK_QUERY_TEXT_LENGTH`` (`#2282`_) -- Allow Passing Custom Filename For All Media (`#2249`_) -- Handle Bytes as File Input (`#2233`_) - -**Bug Fixes:** - -- Fix Escaping in Nested Entities in ``Message`` Properties (`#2312`_) -- Adjust Calling of ``Dispatcher.update_persistence`` (`#2285`_) -- Add ``quote`` kwarg to ``Message.reply_copy`` (`#2232`_) -- ``ConversationHandler``: Docs & ``edited_channel_post`` behavior (`#2339`_) - -**Minor changes, CI improvements, doc fixes and type hinting:** - -- Doc Fixes (`#2253`_, `#2225`_) -- Reduce Usage of ``typing.Any`` (`#2321`_) -- Extend Deeplinking Example (`#2335`_) -- Add pyupgrade to pre-commit Hooks (`#2301`_) -- Add PR Template (`#2299`_) -- Drop Nightly Tests & Update Badges (`#2323`_) -- Update Copyright (`#2289`_, `#2287`_) -- Change Order of Class DocStrings (`#2256`_) -- Add macOS to Test Matrix (`#2266`_) -- Start Using Versioning Directives in Docs (`#2252`_) -- Improve Annotations & Docs of Handlers (`#2243`_) - -.. _`#2324`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2324 -.. _`#2240`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2240 -.. _`#2330`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2330 -.. _`#2320`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2320 -.. _`#2292`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2292 -.. _`#2291`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2291 -.. _`#2282`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2282 -.. _`#2249`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2249 -.. _`#2233`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2233 -.. _`#2312`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2312 -.. _`#2285`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2285 -.. _`#2232`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2232 -.. _`#2339`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2339 -.. _`#2253`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2253 -.. _`#2225`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2225 -.. _`#2321`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2321 -.. _`#2335`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2335 -.. _`#2301`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2301 -.. _`#2299`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2299 -.. _`#2323`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2323 -.. _`#2289`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2289 -.. _`#2287`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2287 -.. _`#2256`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2256 -.. _`#2266`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2266 -.. _`#2252`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2252 -.. _`#2243`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2243 - -Version 13.1 -============ -*Released 2020-11-29* - -**Major Changes:** - -- Full support of Bot API 5.0 (`#2181`_, `#2186`_, `#2190`_, `#2189`_, `#2183`_, `#2184`_, `#2188`_, `#2185`_, `#2192`_, `#2196`_, `#2193`_, `#2223`_, `#2199`_, `#2187`_, `#2147`_, `#2205`_) - -**New Features:** - -- Add ``Defaults.run_async`` (`#2210`_) -- Improve and Expand ``CallbackQuery`` Shortcuts (`#2172`_) -- Add XOR Filters and make ``Filters.name`` a Property (`#2179`_) -- Add ``Filters.document.file_extension`` (`#2169`_) -- Add ``Filters.caption_regex`` (`#2163`_) -- Add ``Filters.chat_type`` (`#2128`_) -- Handle Non-Binary File Input (`#2202`_) - -**Bug Fixes:** - -- Improve Handling of Custom Objects in ``BasePersistence.insert``/``replace_bot`` (`#2151`_) -- Fix bugs in ``replace/insert_bot`` (`#2218`_) - -**Minor changes, CI improvements, doc fixes and type hinting:** - -- Improve Type hinting (`#2204`_, `#2118`_, `#2167`_, `#2136`_) -- Doc Fixes & Extensions (`#2201`_, `#2161`_) -- Use F-Strings Where Possible (`#2222`_) -- Rename kwargs to _kwargs where possible (`#2182`_) -- Comply with PEP561 (`#2168`_) -- Improve Code Quality (`#2131`_) -- Switch Code Formatting to Black (`#2122`_, `#2159`_, `#2158`_) -- Update Wheel Settings (`#2142`_) -- Update ``timerbot.py`` to ``v13.0`` (`#2149`_) -- Overhaul Constants (`#2137`_) -- Add Python 3.9 to Test Matrix (`#2132`_) -- Switch Codecov to ``GitHub`` Action (`#2127`_) -- Specify Required pytz Version (`#2121`_) - - -.. _`#2181`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2181 -.. _`#2186`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2186 -.. _`#2190`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2190 -.. _`#2189`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2189 -.. _`#2183`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2183 -.. _`#2184`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2184 -.. _`#2188`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2188 -.. _`#2185`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2185 -.. _`#2192`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2192 -.. _`#2196`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2196 -.. _`#2193`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2193 -.. _`#2223`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2223 -.. _`#2199`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2199 -.. _`#2187`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2187 -.. _`#2147`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2147 -.. _`#2205`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2205 -.. _`#2210`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2210 -.. _`#2172`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2172 -.. _`#2179`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2179 -.. _`#2169`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2169 -.. _`#2163`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2163 -.. _`#2128`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2128 -.. _`#2202`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2202 -.. _`#2151`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2151 -.. _`#2218`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2218 -.. _`#2204`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2204 -.. _`#2118`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2118 -.. _`#2167`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2167 -.. _`#2136`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2136 -.. _`#2201`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2201 -.. _`#2161`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2161 -.. _`#2222`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2222 -.. _`#2182`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2182 -.. _`#2168`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2168 -.. _`#2131`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2131 -.. _`#2122`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2122 -.. _`#2159`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2159 -.. _`#2158`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2158 -.. _`#2142`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2142 -.. _`#2149`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2149 -.. _`#2137`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2137 -.. _`#2132`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2132 -.. _`#2127`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2127 -.. _`#2121`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2121 - -Version 13.0 -============ -*Released 2020-10-07* - -**For a detailed guide on how to migrate from v12 to v13, see this** `wiki page `_. - -**Major Changes:** - -- Deprecate old-style callbacks, i.e. set ``use_context=True`` by default (`#2050`_) -- Refactor Handling of Message VS Update Filters (`#2032`_) -- Deprecate ``Message.default_quote`` (`#1965`_) -- Refactor persistence of Bot instances (`#1994`_) -- Refactor ``JobQueue`` (`#1981`_) -- Refactor handling of kwargs in Bot methods (`#1924`_) -- Refactor ``Dispatcher.run_async``, deprecating the ``@run_async`` decorator (`#2051`_) - -**New Features:** - -- Type Hinting (`#1920`_) -- Automatic Pagination for ``answer_inline_query`` (`#2072`_) -- ``Defaults.tzinfo`` (`#2042`_) -- Extend rich comparison of objects (`#1724`_) -- Add ``Filters.via_bot`` (`#2009`_) -- Add missing shortcuts (`#2043`_) -- Allow ``DispatcherHandlerStop`` in ``ConversationHandler`` (`#2059`_) -- Make Errors picklable (`#2106`_) - -**Minor changes, CI improvements, doc fixes or bug fixes:** - -- Fix Webhook not working on Windows with Python 3.8+ (`#2067`_) -- Fix setting thumbs with ``send_media_group`` (`#2093`_) -- Make ``MessageHandler`` filter for ``Filters.update`` first (`#2085`_) -- Fix ``PicklePersistence.flush()`` with only ``bot_data`` (`#2017`_) -- Add test for clean argument of ``Updater.start_polling/webhook`` (`#2002`_) -- Doc fixes, refinements and additions (`#2005`_, `#2008`_, `#2089`_, `#2094`_, `#2090`_) -- CI fixes (`#2018`_, `#2061`_) -- Refine ``pollbot.py`` example (`#2047`_) -- Refine Filters in examples (`#2027`_) -- Rename ``echobot`` examples (`#2025`_) -- Use Lock-Bot to lock old threads (`#2048`_, `#2052`_, `#2049`_, `#2053`_) - -.. _`#2050`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2050 -.. _`#2032`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2032 -.. _`#1965`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1965 -.. _`#1994`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1994 -.. _`#1981`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1981 -.. _`#1924`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1924 -.. _`#2051`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2051 -.. _`#1920`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1920 -.. _`#2072`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2072 -.. _`#2042`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2042 -.. _`#1724`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1724 -.. _`#2009`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2009 -.. _`#2043`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2043 -.. _`#2059`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2059 -.. _`#2106`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2106 -.. _`#2067`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2067 -.. _`#2093`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2093 -.. _`#2085`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2085 -.. _`#2017`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2017 -.. _`#2002`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2002 -.. _`#2005`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2005 -.. _`#2008`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2008 -.. _`#2089`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2089 -.. _`#2094`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2094 -.. _`#2090`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2090 -.. _`#2018`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2018 -.. _`#2061`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2061 -.. _`#2047`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2047 -.. _`#2027`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2027 -.. _`#2025`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2025 -.. _`#2048`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2048 -.. _`#2052`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2052 -.. _`#2049`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2049 -.. _`#2053`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2053 - -Version 12.8 -============ -*Released 2020-06-22* - -**Major Changes:** - -- Remove Python 2 support (`#1715`_) -- Bot API 4.9 support (`#1980`_) -- IDs/Usernames of ``Filters.user`` and ``Filters.chat`` can now be updated (`#1757`_) - -**Minor changes, CI improvements, doc fixes or bug fixes:** - -- Update contribution guide and stale bot (`#1937`_) -- Remove ``NullHandlers`` (`#1913`_) -- Improve and expand examples (`#1943`_, `#1995`_, `#1983`_, `#1997`_) -- Doc fixes (`#1940`_, `#1962`_) -- Add ``User.send_poll()`` shortcut (`#1968`_) -- Ignore private attributes en ``TelegramObject.to_dict()`` (`#1989`_) -- Stabilize CI (`#2000`_) - -.. _`#1937`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1937 -.. _`#1913`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1913 -.. _`#1943`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1943 -.. _`#1757`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1757 -.. _`#1940`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1940 -.. _`#1962`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1962 -.. _`#1968`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1968 -.. _`#1989`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1989 -.. _`#1995`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1995 -.. _`#1983`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1983 -.. _`#1715`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1715 -.. _`#2000`: https://github.com/python-telegram-bot/python-telegram-bot/pull/2000 -.. _`#1997`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1997 -.. _`#1980`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1980 - -Version 12.7 -============ -*Released 2020-05-02* - -**Major Changes:** - -- Bot API 4.8 support. **Note:** The ``Dice`` object now has a second positional argument ``emoji``. This is relevant, if you instantiate ``Dice`` objects manually. (`#1917`_) -- Added ``tzinfo`` argument to ``helpers.from_timestamp``. It now returns an timezone aware object. This is relevant for ``Message.{date,forward_date,edit_date}``, ``Poll.close_date`` and ``ChatMember.until_date`` (`#1621`_) - -**New Features:** - -- New method ``run_monthly`` for the ``JobQueue`` (`#1705`_) -- ``Job.next_t`` now gives the datetime of the jobs next execution (`#1685`_) - -**Minor changes, CI improvements, doc fixes or bug fixes:** - -- Stabalize CI (`#1919`_, `#1931`_) -- Use ABCs ``@abstractmethod`` instead of raising ``NotImplementedError`` for ``Handler``, ``BasePersistence`` and ``BaseFilter`` (`#1905`_) -- Doc fixes (`#1914`_, `#1902`_, `#1910`_) - -.. _`#1902`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1902 -.. _`#1685`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1685 -.. _`#1910`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1910 -.. _`#1914`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1914 -.. _`#1931`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1931 -.. _`#1905`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1905 -.. _`#1919`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1919 -.. _`#1621`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1621 -.. _`#1705`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1705 -.. _`#1917`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1917 - -Version 12.6.1 -============== -*Released 2020-04-11* - -**Bug fixes:** - -- Fix serialization of ``reply_markup`` in media messages (`#1889`_) - -.. _`#1889`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1889 - -Version 12.6 -============ -*Released 2020-04-10* - -**Major Changes:** - -- Bot API 4.7 support. **Note:** In ``Bot.create_new_sticker_set`` and ``Bot.add_sticker_to_set``, the order of the parameters had be changed, as the ``png_sticker`` parameter is now optional. (`#1858`_) - -**Minor changes, CI improvements or bug fixes:** - -- Add tests for ``swtich_inline_query(_current_chat)`` with empty string (`#1635`_) -- Doc fixes (`#1854`_, `#1874`_, `#1884`_) -- Update issue templates (`#1880`_) -- Favor concrete types over "Iterable" (`#1882`_) -- Pass last valid ``CallbackContext`` to ``TIMEOUT`` handlers of ``ConversationHandler`` (`#1826`_) -- Tweak handling of persistence and update persistence after job calls (`#1827`_) -- Use checkout@v2 for GitHub actions (`#1887`_) - -.. _`#1858`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1858 -.. _`#1635`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1635 -.. _`#1854`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1854 -.. _`#1874`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1874 -.. _`#1884`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1884 -.. _`#1880`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1880 -.. _`#1882`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1882 -.. _`#1826`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1826 -.. _`#1827`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1827 -.. _`#1887`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1887 - -Version 12.5.1 -============== -*Released 2020-03-30* - -**Minor changes, doc fixes or bug fixes:** - -- Add missing docs for `PollHandler` and `PollAnswerHandler` (`#1853`_) -- Fix wording in `Filters` docs (`#1855`_) -- Reorder tests to make them more stable (`#1835`_) -- Make `ConversationHandler` attributes immutable (`#1756`_) -- Make `PrefixHandler` attributes `command` and `prefix` editable (`#1636`_) -- Fix UTC as default `tzinfo` for `Job` (`#1696`_) - -.. _`#1853`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1853 -.. _`#1855`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1855 -.. _`#1835`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1835 -.. _`#1756`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1756 -.. _`#1636`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1636 -.. _`#1696`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1696 - -Version 12.5 -============ -*Released 2020-03-29* - -**New Features:** - -- `Bot.link` gives the `t.me` link of the bot (`#1770`_) - -**Major Changes:** - -- Bot API 4.5 and 4.6 support. (`#1508`_, `#1723`_) - -**Minor changes, CI improvements or bug fixes:** - -- Remove legacy CI files (`#1783`_, `#1791`_) -- Update pre-commit config file (`#1787`_) -- Remove builtin names (`#1792`_) -- CI improvements (`#1808`_, `#1848`_) -- Support Python 3.8 (`#1614`_, `#1824`_) -- Use stale bot for auto closing stale issues (`#1820`_, `#1829`_, `#1840`_) -- Doc fixes (`#1778`_, `#1818`_) -- Fix typo in `edit_message_media` (`#1779`_) -- In examples, answer CallbackQueries and use `edit_message_text` shortcut (`#1721`_) -- Revert accidental change in vendored urllib3 (`#1775`_) - -.. _`#1783`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1783 -.. _`#1787`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1787 -.. _`#1792`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1792 -.. _`#1791`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1791 -.. _`#1808`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1808 -.. _`#1614`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1614 -.. _`#1770`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1770 -.. _`#1824`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1824 -.. _`#1820`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1820 -.. _`#1829`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1829 -.. _`#1840`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1840 -.. _`#1778`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1778 -.. _`#1779`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1779 -.. _`#1721`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1721 -.. _`#1775`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1775 -.. _`#1848`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1848 -.. _`#1818`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1818 -.. _`#1508`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1508 -.. _`#1723`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1723 - -Version 12.4.2 -============== -*Released 2020-02-10* - -**Bug Fixes** - -- Pass correct parse_mode to InlineResults if bot.defaults is None (`#1763`_) -- Make sure PP can read files that dont have bot_data (`#1760`_) - -.. _`#1763`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1763 -.. _`#1760`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1760 - -Version 12.4.1 -============== -*Released 2020-02-08* - -This is a quick release for `#1744`_ which was accidently left out of v12.4.0 though mentioned in the -release notes. - - -Version 12.4.0 -============== -*Released 2020-02-08* - -**New features:** - -- Set default values for arguments appearing repeatedly. We also have a `wiki page for the new defaults`_. (`#1490`_) -- Store data in ``CallbackContext.bot_data`` to access it in every callback. Also persists. (`#1325`_) -- ``Filters.poll`` allows only messages containing a poll (`#1673`_) - -**Major changes:** - -- ``Filters.text`` now accepts messages that start with a slash, because ``CommandHandler`` checks for ``MessageEntity.BOT_COMMAND`` since v12. This might lead to your MessageHandlers receiving more updates than before (`#1680`_). -- ``Filters.command`` new checks for ``MessageEntity.BOT_COMMAND`` instead of just a leading slash. Also by ``Filters.command(False)`` you can now filters for messages containing a command `anywhere` in the text (`#1744`_). - -**Minor changes, CI improvements or bug fixes:** - -- Add ``disptacher`` argument to ``Updater`` to allow passing a customized ``Dispatcher`` (`#1484`_) -- Add missing names for ``Filters`` (`#1632`_) -- Documentation fixes (`#1624`_, `#1647`_, `#1669`_, `#1703`_, `#1718`_, `#1734`_, `#1740`_, `#1642`_, `#1739`_, `#1746`_) -- CI improvements (`#1716`_, `#1731`_, `#1738`_, `#1748`_, `#1749`_, `#1750`_, `#1752`_) -- Fix spelling issue for ``encode_conversations_to_json`` (`#1661`_) -- Remove double assignement of ``Dispatcher.job_queue`` (`#1698`_) -- Expose dispatcher as property for ``CallbackContext`` (`#1684`_) -- Fix ``None`` check in ``JobQueue._put()`` (`#1707`_) -- Log datetimes correctly in ``JobQueue`` (`#1714`_) -- Fix false ``Message.link`` creation for private groups (`#1741`_) -- Add option ``--with-upstream-urllib3`` to `setup.py` to allow using non-vendored version (`#1725`_) -- Fix persistence for nested ``ConversationHandlers`` (`#1679`_) -- Improve handling of non-decodable server responses (`#1623`_) -- Fix download for files without ``file_path`` (`#1591`_) -- test_webhook_invalid_posts is now considered flaky and retried on failure (`#1758`_) - -.. _`wiki page for the new defaults`: https://github.com/python-telegram-bot/python-telegram-bot/wiki/Adding-defaults-to-your-bot -.. _`#1744`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1744 -.. _`#1752`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1752 -.. _`#1750`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1750 -.. _`#1591`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1591 -.. _`#1490`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1490 -.. _`#1749`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1749 -.. _`#1623`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1623 -.. _`#1748`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1748 -.. _`#1679`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1679 -.. _`#1711`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1711 -.. _`#1325`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1325 -.. _`#1746`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1746 -.. _`#1725`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1725 -.. _`#1739`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1739 -.. _`#1741`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1741 -.. _`#1642`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1642 -.. _`#1738`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1738 -.. _`#1740`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1740 -.. _`#1734`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1734 -.. _`#1680`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1680 -.. _`#1718`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1718 -.. _`#1714`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1714 -.. _`#1707`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1707 -.. _`#1731`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1731 -.. _`#1673`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1673 -.. _`#1684`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1684 -.. _`#1703`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1703 -.. _`#1698`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1698 -.. _`#1669`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1669 -.. _`#1661`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1661 -.. _`#1647`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1647 -.. _`#1632`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1632 -.. _`#1624`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1624 -.. _`#1716`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1716 -.. _`#1484`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1484 -.. _`#1758`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1484 - -Version 12.3.0 -============== -*Released 2020-01-11* - -**New features:** - -- `Filters.caption` allows only messages with caption (`#1631`_). -- Filter for exact messages/captions with new capability of `Filters.text` and `Filters.caption`. Especially useful in combination with ReplyKeyboardMarkup. (`#1631`_). - -**Major changes:** - -- Fix inconsistent handling of naive datetimes (`#1506`_). - -**Minor changes, CI improvements or bug fixes:** - -- Documentation fixes (`#1558`_, `#1569`_, `#1579`_, `#1572`_, `#1566`_, `#1577`_, `#1656`_). -- Add mutex protection on `ConversationHandler` (`#1533`_). -- Add `MAX_PHOTOSIZE_UPLOAD` constant (`#1560`_). -- Add args and kwargs to `Message.forward()` (`#1574`_). -- Transfer to GitHub Actions CI (`#1555`_, `#1556`_, `#1605`_, `#1606`_, `#1607`_, `#1612`_, `#1615`_, `#1645`_). -- Fix deprecation warning with Py3.8 by vendored urllib3 (`#1618`_). -- Simplify assignements for optional arguments (`#1600`_) -- Allow private groups for `Message.link` (`#1619`_). -- Fix wrong signature call for `ConversationHandler.TIMEOUT` handlers (`#1653`_). - -.. _`#1631`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1631 -.. _`#1506`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1506 -.. _`#1558`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1558 -.. _`#1569`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1569 -.. _`#1579`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1579 -.. _`#1572`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1572 -.. _`#1566`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1566 -.. _`#1577`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1577 -.. _`#1533`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1533 -.. _`#1560`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1560 -.. _`#1574`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1574 -.. _`#1555`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1555 -.. _`#1556`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1556 -.. _`#1605`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1605 -.. _`#1606`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1606 -.. _`#1607`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1607 -.. _`#1612`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1612 -.. _`#1615`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1615 -.. _`#1618`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1618 -.. _`#1600`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1600 -.. _`#1619`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1619 -.. _`#1653`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1653 -.. _`#1656`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1656 -.. _`#1645`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1645 - -Version 12.2.0 -============== -*Released 2019-10-14* - -**New features:** - -- Nested ConversationHandlers (`#1512`_). - -**Minor changes, CI improvments or bug fixes:** - -- Fix CI failures due to non-backward compat attrs depndency (`#1540`_). -- travis.yaml: TEST_OFFICIAL removed from allowed_failures. -- Fix typos in examples (`#1537`_). -- Fix Bot.to_dict to use proper first_name (`#1525`_). -- Refactor ``test_commandhandler.py`` (`#1408`_). -- Add Python 3.8 (RC version) to Travis testing matrix (`#1543`_). -- test_bot.py: Add to_dict test (`#1544`_). -- Flake config moved into setup.cfg (`#1546`_). - -.. _`#1512`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1512 -.. _`#1540`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1540 -.. _`#1537`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1537 -.. _`#1525`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1525 -.. _`#1408`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1408 -.. _`#1543`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1543 -.. _`#1544`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1544 -.. _`#1546`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1546 - -Version 12.1.1 -============== -*Released 2019-09-18* - -**Hot fix release** - -Fixed regression in the vendored urllib3 (`#1517`_). - -.. _`#1517`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1517 - -Version 12.1.0 -================ -*Released 2019-09-13* - -**Major changes:** - -- Bot API 4.4 support (`#1464`_, `#1510`_) -- Add `get_file` method to `Animation` & `ChatPhoto`. Add, `get_small_file` & `get_big_file` - methods to `ChatPhoto` (`#1489`_) -- Tools for deep linking (`#1049`_) - -**Minor changes and/or bug fixes:** - -- Documentation fixes (`#1500`_, `#1499`_) -- Improved examples (`#1502`_) - -.. _`#1464`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1464 -.. _`#1502`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1502 -.. _`#1499`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1499 -.. _`#1500`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1500 -.. _`#1049`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1049 -.. _`#1489`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1489 -.. _`#1510`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1510 - -Version 12.0.0 -================ -*Released 2019-08-29* - -Well... This felt like decades. But here we are with a new release. - -Expect minor releases soon (mainly complete Bot API 4.4 support) - -**Major and/or breaking changes:** - -- Context based callbacks -- Persistence -- PrefixHandler added (Handler overhaul) -- Deprecation of RegexHandler and edited_messages, channel_post, etc. arguments (Filter overhaul) -- Various ConversationHandler changes and fixes -- Bot API 4.1, 4.2, 4.3 support -- Python 3.4 is no longer supported -- Error Handler now handles all types of exceptions (`#1485`_) -- Return UTC from from_timestamp() (`#1485`_) - -**See the wiki page at https://github.com/python-telegram-bot/python-telegram-bot/wiki/Transition-guide-to-Version-12.0 for a detailed guide on how to migrate from version 11 to version 12.** - -Context based callbacks (`#1100`_) ----------------------------------- - -- Use of ``pass_`` in handlers is deprecated. -- Instead use ``use_context=True`` on ``Updater`` or ``Dispatcher`` and change callback from (bot, update, others...) to (update, context). -- This also applies to error handlers ``Dispatcher.add_error_handler`` and JobQueue jobs (change (bot, job) to (context) here). -- For users with custom handlers subclassing Handler, this is mostly backwards compatible, but to use the new context based callbacks you need to implement the new collect_additional_context method. -- Passing bot to ``JobQueue.__init__`` is deprecated. Use JobQueue.set_dispatcher with a dispatcher instead. -- Dispatcher makes sure to use a single `CallbackContext` for a entire update. This means that if an update is handled by multiple handlers (by using the group argument), you can add custom arguments to the `CallbackContext` in a lower group handler and use it in higher group handler. NOTE: Never use with @run_async, see docs for more info. (`#1283`_) -- If you have custom handlers they will need to be updated to support the changes in this release. -- Update all examples to use context based callbacks. - -Persistence (`#1017`_) ----------------------- - -- Added PicklePersistence and DictPersistence for adding persistence to your bots. -- BasePersistence can be subclassed for all your persistence needs. -- Add a new example that shows a persistent ConversationHandler bot - -Handler overhaul (`#1114`_) ---------------------------- - -- CommandHandler now only triggers on actual commands as defined by telegram servers (everything that the clients mark as a tabable link). -- PrefixHandler can be used if you need to trigger on prefixes (like all messages starting with a "/" (old CommandHandler behaviour) or even custom prefixes like "#" or "!"). - -Filter overhaul (`#1221`_) --------------------------- - -- RegexHandler is deprecated and should be replaced with a MessageHandler with a regex filter. -- Use update filters to filter update types instead of arguments (message_updates, channel_post_updates and edited_updates) on the handlers. -- Completely remove allow_edited argument - it has been deprecated for a while. -- data_filters now exist which allows filters that return data into the callback function. This is how the regex filter is implemented. -- All this means that it no longer possible to use a list of filters in a handler. Use bitwise operators instead! - -ConversationHandler -------------------- - -- Remove ``run_async_timeout`` and ``timed_out_behavior`` arguments (`#1344`_) -- Replace with ``WAITING`` constant and behavior from states (`#1344`_) -- Only emit one warning for multiple CallbackQueryHandlers in a ConversationHandler (`#1319`_) -- Use warnings.warn for ConversationHandler warnings (`#1343`_) -- Fix unresolvable promises (`#1270`_) - - -Bug fixes & improvements ------------------------- - -- Handlers should be faster due to deduped logic. -- Avoid compiling compiled regex in regex filter. (`#1314`_) -- Add missing ``left_chat_member`` to Message.MESSAGE_TYPES (`#1336`_) -- Make custom timeouts actually work properly (`#1330`_) -- Add convenience classmethods (from_button, from_row and from_column) to InlineKeyboardMarkup -- Small typo fix in setup.py (`#1306`_) -- Add Conflict error (HTTP error code 409) (`#1154`_) -- Change MAX_CAPTION_LENGTH to 1024 (`#1262`_) -- Remove some unnecessary clauses (`#1247`_, `#1239`_) -- Allow filenames without dots in them when sending files (`#1228`_) -- Fix uploading files with unicode filenames (`#1214`_) -- Replace http.server with Tornado (`#1191`_) -- Allow SOCKSConnection to parse username and password from URL (`#1211`_) -- Fix for arguments in passport/data.py (`#1213`_) -- Improve message entity parsing by adding text_mention (`#1206`_) -- Documentation fixes (`#1348`_, `#1397`_, `#1436`_) -- Merged filters short-circuit (`#1350`_) -- Fix webhook listen with tornado (`#1383`_) -- Call task_done() on update queue after update processing finished (`#1428`_) -- Fix send_location() - latitude may be 0 (`#1437`_) -- Make MessageEntity objects comparable (`#1465`_) -- Add prefix to thread names (`#1358`_) - -Buf fixes since v12.0.0b1 -------------------------- - -- Fix setting bot on ShippingQuery (`#1355`_) -- Fix _trigger_timeout() missing 1 required positional argument: 'job' (`#1367`_) -- Add missing message.text check in PrefixHandler check_update (`#1375`_) -- Make updates persist even on DispatcherHandlerStop (`#1463`_) -- Dispatcher force updating persistence object's chat data attribute(`#1462`_) - -.. _`#1100`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1100 -.. _`#1283`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1283 -.. _`#1017`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1017 -.. _`#1325`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1325 -.. _`#1301`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1301 -.. _`#1312`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1312 -.. _`#1324`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1324 -.. _`#1114`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1114 -.. _`#1221`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1221 -.. _`#1314`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1314 -.. _`#1336`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1336 -.. _`#1330`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1330 -.. _`#1306`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1306 -.. _`#1154`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1154 -.. _`#1262`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1262 -.. _`#1247`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1247 -.. _`#1239`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1239 -.. _`#1228`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1228 -.. _`#1214`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1214 -.. _`#1191`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1191 -.. _`#1211`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1211 -.. _`#1213`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1213 -.. _`#1206`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1206 -.. _`#1344`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1344 -.. _`#1319`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1319 -.. _`#1343`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1343 -.. _`#1270`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1270 -.. _`#1348`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1348 -.. _`#1350`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1350 -.. _`#1383`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1383 -.. _`#1397`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1397 -.. _`#1428`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1428 -.. _`#1436`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1436 -.. _`#1437`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1437 -.. _`#1465`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1465 -.. _`#1358`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1358 -.. _`#1355`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1355 -.. _`#1367`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1367 -.. _`#1375`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1375 -.. _`#1463`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1463 -.. _`#1462`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1462 -.. _`#1483`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1483 -.. _`#1485`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1485 - -Internal improvements ---------------------- - -- Finally fix our CI builds mostly (too many commits and PRs to list) -- Use multiple bots for CI to improve testing times significantly. -- Allow pypy to fail in CI. -- Remove the last CamelCase CheckUpdate methods from the handlers we missed earlier. -- test_official is now executed in a different job - -Version 11.1.0 -============== -*Released 2018-09-01* - -Fixes and updates for Telegram Passport: (`#1198`_) - -- Fix passport decryption failing at random times -- Added support for middle names. -- Added support for translations for documents -- Add errors for translations for documents -- Added support for requesting names in the language of the user's country of residence -- Replaced the payload parameter with the new parameter nonce -- Add hash to EncryptedPassportElement - -.. _`#1198`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1198 - -Version 11.0.0 -============== -*Released 2018-08-29* - -Fully support Bot API version 4.0! -(also some bugfixes :)) - -Telegram Passport (`#1174`_): - -- Add full support for telegram passport. - - New types: PassportData, PassportFile, EncryptedPassportElement, EncryptedCredentials, PassportElementError, PassportElementErrorDataField, PassportElementErrorFrontSide, PassportElementErrorReverseSide, PassportElementErrorSelfie, PassportElementErrorFile and PassportElementErrorFiles. - - New bot method: set_passport_data_errors - - New filter: Filters.passport_data - - Field passport_data field on Message - - PassportData can be easily decrypted. - - PassportFiles are automatically decrypted if originating from decrypted PassportData. -- See new passportbot.py example for details on how to use, or go to `our telegram passport wiki page`_ for more info -- NOTE: Passport decryption requires new dependency `cryptography`. - -Inputfile rework (`#1184`_): - -- Change how Inputfile is handled internally -- This allows support for specifying the thumbnails of photos and videos using the thumb= argument in the different send\_ methods. -- Also allows Bot.send_media_group to actually finally send more than one media. -- Add thumb to Audio, Video and Videonote -- Add Bot.edit_message_media together with InputMediaAnimation, InputMediaAudio, and inputMediaDocument. - -Other Bot API 4.0 changes: - -- Add forusquare_type to Venue, InlineQueryResultVenue, InputVenueMessageContent, and Bot.send_venue. (`#1170`_) -- Add vCard support by adding vcard field to Contact, InlineQueryResultContact, InputContactMessageContent, and Bot.send_contact. (`#1166`_) -- Support new message entities: CASHTAG and PHONE_NUMBER. (`#1179`_) - - Cashtag seems to be things like `$USD` and `$GBP`, but it seems telegram doesn't currently send them to bots. - - Phone number also seems to have limited support for now -- Add Bot.send_animation, add width, height, and duration to Animation, and add Filters.animation. (`#1172`_) - -Non Bot API 4.0 changes: - -- Minor integer comparison fix (`#1147`_) -- Fix Filters.regex failing on non-text message (`#1158`_) -- Fix ProcessLookupError if process finishes before we kill it (`#1126`_) -- Add t.me links for User, Chat and Message if available and update User.mention_* (`#1092`_) -- Fix mention_markdown/html on py2 (`#1112`_) - -.. _`#1092`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1092 -.. _`#1112`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1112 -.. _`#1126`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1126 -.. _`#1147`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1147 -.. _`#1158`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1158 -.. _`#1166`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1166 -.. _`#1170`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1170 -.. _`#1174`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1174 -.. _`#1172`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1172 -.. _`#1179`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1179 -.. _`#1184`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1184 -.. _`our telegram passport wiki page`: https://github.com/python-telegram-bot/python-telegram-bot/wiki/Telegram-Passport - -Version 10.1.0 -============== -*Released 2018-05-02* - -Fixes changing previous behaviour: - -- Add urllib3 fix for socks5h support (`#1085`_) -- Fix send_sticker() timeout=20 (`#1088`_) - -Fixes: - -- Add a caption_entity filter for filtering caption entities (`#1068`_) -- Inputfile encode filenames (`#1086`_) -- InputFile: Fix proper naming of file when reading from subprocess.PIPE (`#1079`_) -- Remove pytest-catchlog from requirements (`#1099`_) -- Documentation fixes (`#1061`_, `#1078`_, `#1081`_, `#1096`_) - -.. _`#1061`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1061 -.. _`#1068`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1068 -.. _`#1078`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1078 -.. _`#1079`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1079 -.. _`#1081`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1081 -.. _`#1085`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1085 -.. _`#1086`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1086 -.. _`#1088`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1088 -.. _`#1096`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1096 -.. _`#1099`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1099 - -Version 10.0.2 -============== -*Released 2018-04-17* - -Important fix: - -- Handle utf8 decoding errors (`#1076`_) - -New features: - -- Added Filter.regex (`#1028`_) -- Filters for Category and file types (`#1046`_) -- Added video note filter (`#1067`_) - -Fixes: - -- Fix in telegram.Message (`#1042`_) -- Make chat_id a positional argument inside shortcut methods of Chat and User classes (`#1050`_) -- Make Bot.full_name return a unicode object. (`#1063`_) -- CommandHandler faster check (`#1074`_) -- Correct documentation of Dispatcher.add_handler (`#1071`_) -- Various small fixes to documentation. - -.. _`#1028`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1028 -.. _`#1042`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1042 -.. _`#1046`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1046 -.. _`#1050`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1050 -.. _`#1067`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1067 -.. _`#1063`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1063 -.. _`#1074`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1074 -.. _`#1076`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1076 -.. _`#1071`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1071 - -Version 10.0.1 -============== -*Released 2018-03-05* - -Fixes: - -- Fix conversationhandler timeout (PR `#1032`_) -- Add missing docs utils (PR `#912`_) - -.. _`#1032`: https://github.com/python-telegram-bot/python-telegram-bot/pull/826 -.. _`#912`: https://github.com/python-telegram-bot/python-telegram-bot/pull/826 - -Version 10.0.0 -============== -*Released 2018-03-02* - -Non backward compatabile changes and changed defaults - -- JobQueue: Remove deprecated prevent_autostart & put() (PR `#1012`_) -- Bot, Updater: Remove deprecated network_delay (PR `#1012`_) -- Remove deprecated Message.new_chat_member (PR `#1012`_) -- Retry bootstrap phase indefinitely (by default) on network errors (PR `#1018`_) - -New Features - -- Support v3.6 API (PR `#1006`_) -- User.full_name convinience property (PR `#949`_) -- Add `send_phone_number_to_provider` and `send_email_to_provider` arguments to send_invoice (PR `#986`_) -- Bot: Add shortcut methods reply_{markdown,html} (PR `#827`_) -- Bot: Add shortcut method reply_media_group (PR `#994`_) -- Added utils.helpers.effective_message_type (PR `#826`_) -- Bot.get_file now allows passing a file in addition to file_id (PR `#963`_) -- Add .get_file() to Audio, Document, PhotoSize, Sticker, Video, VideoNote and Voice (PR `#963`_) -- Add .send_*() methods to User and Chat (PR `#963`_) -- Get jobs by name (PR `#1011`_) -- Add Message caption html/markdown methods (PR `#1013`_) -- File.download_as_bytearray - new method to get a d/led file as bytearray (PR `#1019`_) -- File.download(): Now returns a meaningful return value (PR `#1019`_) -- Added conversation timeout in ConversationHandler (PR `#895`_) - -Changes - -- Store bot in PreCheckoutQuery (PR `#953`_) -- Updater: Issue INFO log upon received signal (PR `#951`_) -- JobQueue: Thread safety fixes (PR `#977`_) -- WebhookHandler: Fix exception thrown during error handling (PR `#985`_) -- Explicitly check update.effective_chat in ConversationHandler.check_update (PR `#959`_) -- Updater: Better handling of timeouts during get_updates (PR `#1007`_) -- Remove unnecessary to_dict() (PR `#834`_) -- CommandHandler - ignore strings in entities and "/" followed by whitespace (PR `#1020`_) -- Documentation & style fixes (PR `#942`_, PR `#956`_, PR `#962`_, PR `#980`_, PR `#983`_) - -.. _`#826`: https://github.com/python-telegram-bot/python-telegram-bot/pull/826 -.. _`#827`: https://github.com/python-telegram-bot/python-telegram-bot/pull/827 -.. _`#834`: https://github.com/python-telegram-bot/python-telegram-bot/pull/834 -.. _`#895`: https://github.com/python-telegram-bot/python-telegram-bot/pull/895 -.. _`#942`: https://github.com/python-telegram-bot/python-telegram-bot/pull/942 -.. _`#949`: https://github.com/python-telegram-bot/python-telegram-bot/pull/949 -.. _`#951`: https://github.com/python-telegram-bot/python-telegram-bot/pull/951 -.. _`#956`: https://github.com/python-telegram-bot/python-telegram-bot/pull/956 -.. _`#953`: https://github.com/python-telegram-bot/python-telegram-bot/pull/953 -.. _`#962`: https://github.com/python-telegram-bot/python-telegram-bot/pull/962 -.. _`#959`: https://github.com/python-telegram-bot/python-telegram-bot/pull/959 -.. _`#963`: https://github.com/python-telegram-bot/python-telegram-bot/pull/963 -.. _`#977`: https://github.com/python-telegram-bot/python-telegram-bot/pull/977 -.. _`#980`: https://github.com/python-telegram-bot/python-telegram-bot/pull/980 -.. _`#983`: https://github.com/python-telegram-bot/python-telegram-bot/pull/983 -.. _`#985`: https://github.com/python-telegram-bot/python-telegram-bot/pull/985 -.. _`#986`: https://github.com/python-telegram-bot/python-telegram-bot/pull/986 -.. _`#994`: https://github.com/python-telegram-bot/python-telegram-bot/pull/994 -.. _`#1006`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1006 -.. _`#1007`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1007 -.. _`#1011`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1011 -.. _`#1012`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1012 -.. _`#1013`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1013 -.. _`#1018`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1018 -.. _`#1019`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1019 -.. _`#1020`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1020 - -Version 9.0.0 -============= -*Released 2017-12-08* - -Breaking changes (possibly) - -- Drop support for python 3.3 (PR `#930`_) - - -New Features - -- Support Bot API 3.5 (PR `#920`_) - - -Changes - -- Fix race condition in dispatcher start/stop (`#887`_) -- Log error trace if there is no error handler registered (`#694`_) -- Update examples with consistent string formatting (`#870`_) -- Various changes and improvements to the docs. - -.. _`#920`: https://github.com/python-telegram-bot/python-telegram-bot/pull/920 -.. _`#930`: https://github.com/python-telegram-bot/python-telegram-bot/pull/930 -.. _`#887`: https://github.com/python-telegram-bot/python-telegram-bot/pull/887 -.. _`#694`: https://github.com/python-telegram-bot/python-telegram-bot/pull/694 -.. _`#870`: https://github.com/python-telegram-bot/python-telegram-bot/pull/870 - -Version 8.1.1 -============= -*Released 2017-10-15* - -- Fix Commandhandler crashing on single character messages (PR `#873`_). - -.. _`#873`: https://github.com/python-telegram-bot/python-telegram-bot/pull/871 - -Version 8.1.0 -============= -*Released 2017-10-14* - -New features -- Support Bot API 3.4 (PR `#865`_). - -Changes -- MessageHandler & RegexHandler now consider channel_updates. -- Fix command not recognized if it is directly followed by a newline (PR `#869`_). -- Removed Bot._message_wrapper (PR `#822`_). -- Unitests are now also running on AppVeyor (Windows VM). -- Various unitest improvements. -- Documentation fixes. - -.. _`#822`: https://github.com/python-telegram-bot/python-telegram-bot/pull/822 -.. _`#865`: https://github.com/python-telegram-bot/python-telegram-bot/pull/865 -.. _`#869`: https://github.com/python-telegram-bot/python-telegram-bot/pull/869 - -Version 8.0.0 -============= -*Released 2017-09-01* - -New features - -- Fully support Bot Api 3.3 (PR `#806`_). -- DispatcherHandlerStop (`see docs`_). -- Regression fix for text_html & text_markdown (PR `#777`_). -- Added effective_attachment to message (PR `#766`_). - -Non backward compatible changes - -- Removed Botan support from the library (PR `#776`_). -- Fully support Bot Api 3.3 (PR `#806`_). -- Remove de_json() (PR `#789`_). - -Changes - -- Sane defaults for tcp socket options on linux (PR `#754`_). -- Add RESTRICTED as constant to ChatMember (PR `#761`_). -- Add rich comparison to CallbackQuery (PR `#764`_). -- Fix get_game_high_scores (PR `#771`_). -- Warn on small con_pool_size during custom initalization of Updater (PR `#793`_). -- Catch exceptions in error handlerfor errors that happen during polling (PR `#810`_). -- For testing we switched to pytest (PR `#788`_). -- Lots of small improvements to our tests and documentation. - - -.. _`see docs`: https://docs.python-telegram-bot.org/en/v13.11/telegram.ext.dispatcher.html?highlight=Dispatcher.add_handler#telegram.ext.Dispatcher.add_handler -.. _`#777`: https://github.com/python-telegram-bot/python-telegram-bot/pull/777 -.. _`#806`: https://github.com/python-telegram-bot/python-telegram-bot/pull/806 -.. _`#766`: https://github.com/python-telegram-bot/python-telegram-bot/pull/766 -.. _`#776`: https://github.com/python-telegram-bot/python-telegram-bot/pull/776 -.. _`#789`: https://github.com/python-telegram-bot/python-telegram-bot/pull/789 -.. _`#754`: https://github.com/python-telegram-bot/python-telegram-bot/pull/754 -.. _`#761`: https://github.com/python-telegram-bot/python-telegram-bot/pull/761 -.. _`#764`: https://github.com/python-telegram-bot/python-telegram-bot/pull/764 -.. _`#771`: https://github.com/python-telegram-bot/python-telegram-bot/pull/771 -.. _`#788`: https://github.com/python-telegram-bot/python-telegram-bot/pull/788 -.. _`#793`: https://github.com/python-telegram-bot/python-telegram-bot/pull/793 -.. _`#810`: https://github.com/python-telegram-bot/python-telegram-bot/pull/810 - -Version 7.0.1 -=============== -*Released 2017-07-28* - -- Fix TypeError exception in RegexHandler (PR #751). -- Small documentation fix (PR #749). - -Version 7.0.0 -============= -*Released 2017-07-25* - -- Fully support Bot API 3.2. -- New filters for handling messages from specific chat/user id (PR #677). -- Add the possibility to add objects as arguments to send_* methods (PR #742). -- Fixed download of URLs with UTF-8 chars in path (PR #688). -- Fixed URL parsing for ``Message`` text properties (PR #689). -- Fixed args dispatching in ``MessageQueue``'s decorator (PR #705). -- Fixed regression preventing IPv6 only hosts from connnecting to Telegram servers (Issue #720). -- ConvesationHandler - check if a user exist before using it (PR #699). -- Removed deprecated ``telegram.Emoji``. -- Removed deprecated ``Botan`` import from ``utils`` (``Botan`` is still available through ``contrib``). -- Removed deprecated ``ReplyKeyboardHide``. -- Removed deprecated ``edit_message`` argument of ``bot.set_game_score``. -- Internal restructure of files. -- Improved documentation. -- Improved unitests. - -Pre-version 7.0 -=============== - -**2017-06-18** - -*Released 6.1.0* - -- Fully support Bot API 3.0 -- Add more fine-grained filters for status updates -- Bug fixes and other improvements - -**2017-05-29** - -*Released 6.0.3* - -- Faulty PyPI release - -**2017-05-29** - -*Released 6.0.2* - -- Avoid confusion with user's ``urllib3`` by renaming vendored ``urllib3`` to ``ptb_urllib3`` - -**2017-05-19** - -*Released 6.0.1* - -- Add support for ``User.language_code`` -- Fix ``Message.text_html`` and ``Message.text_markdown`` for messages with emoji - -**2017-05-19** - -*Released 6.0.0* - -- Add support for Bot API 2.3.1 -- Add support for ``deleteMessage`` API method -- New, simpler API for ``JobQueue`` - https://github.com/python-telegram-bot/python-telegram-bot/pull/484 -- Download files into file-like objects - https://github.com/python-telegram-bot/python-telegram-bot/pull/459 -- Use vendor ``urllib3`` to address issues with timeouts - - The default timeout for messages is now 5 seconds. For sending media, the default timeout is now 20 seconds. -- String attributes that are not set are now ``None`` by default, instead of empty strings -- Add ``text_markdown`` and ``text_html`` properties to ``Message`` - https://github.com/python-telegram-bot/python-telegram-bot/pull/507 -- Add support for Socks5 proxy - https://github.com/python-telegram-bot/python-telegram-bot/pull/518 -- Add support for filters in ``CommandHandler`` - https://github.com/python-telegram-bot/python-telegram-bot/pull/536 -- Add the ability to invert (not) filters - https://github.com/python-telegram-bot/python-telegram-bot/pull/552 -- Add ``Filters.group`` and ``Filters.private`` -- Compatibility with GAE via ``urllib3.contrib`` package - https://github.com/python-telegram-bot/python-telegram-bot/pull/583 -- Add equality rich comparision operators to telegram objects - https://github.com/python-telegram-bot/python-telegram-bot/pull/604 -- Several bugfixes and other improvements -- Remove some deprecated code - -**2017-04-17** - -*Released 5.3.1* - -- Hotfix release due to bug introduced by urllib3 version 1.21 - -**2016-12-11** - -*Released 5.3* - -- Implement API changes of November 21st (Bot API 2.3) -- ``JobQueue`` now supports ``datetime.timedelta`` in addition to seconds -- ``JobQueue`` now supports running jobs only on certain days -- New ``Filters.reply`` filter -- Bugfix for ``Message.edit_reply_markup`` -- Other bugfixes - -**2016-10-25** - -*Released 5.2* - -- Implement API changes of October 3rd (games update) -- Add ``Message.edit_*`` methods -- Filters for the ``MessageHandler`` can now be combined using bitwise operators (``& and |``) -- Add a way to save user- and chat-related data temporarily -- Other bugfixes and improvements - -**2016-09-24** - -*Released 5.1* - -- Drop Python 2.6 support -- Deprecate ``telegram.Emoji`` - -- Use ``ujson`` if available -- Add instance methods to ``Message``, ``Chat``, ``User``, ``InlineQuery`` and ``CallbackQuery`` -- RegEx filtering for ``CallbackQueryHandler`` and ``InlineQueryHandler`` -- New ``MessageHandler`` filters: ``forwarded`` and ``entity`` -- Add ``Message.get_entity`` to correctly handle UTF-16 codepoints and ``MessageEntity`` offsets -- Fix bug in ``ConversationHandler`` when first handler ends the conversation -- Allow multiple ``Dispatcher`` instances -- Add ``ChatMigrated`` Exception -- Properly split and handle arguments in ``CommandHandler`` - -**2016-07-15** - -*Released 5.0* - -- Rework ``JobQueue`` -- Introduce ``ConversationHandler`` -- Introduce ``telegram.constants`` - https://github.com/python-telegram-bot/python-telegram-bot/pull/342 - -**2016-07-12** - -*Released 4.3.4* - -- Fix proxy support with ``urllib3`` when proxy requires auth - -**2016-07-08** - -*Released 4.3.3* - -- Fix proxy support with ``urllib3`` - -**2016-07-04** - -*Released 4.3.2* - -- Fix: Use ``timeout`` parameter in all API methods - -**2016-06-29** - -*Released 4.3.1* - -- Update wrong requirement: ``urllib3>=1.10`` - -**2016-06-28** - -*Released 4.3* - -- Use ``urllib3.PoolManager`` for connection re-use -- Rewrite ``run_async`` decorator to re-use threads -- New requirements: ``urllib3`` and ``certifi`` - -**2016-06-10** - -*Released 4.2.1* - -- Fix ``CallbackQuery.to_dict()`` bug (thanks to @jlmadurga) -- Fix ``editMessageText`` exception when receiving a ``CallbackQuery`` - -**2016-05-28** - -*Released 4.2* - -- Implement Bot API 2.1 -- Move ``botan`` module to ``telegram.contrib`` -- New exception type: ``BadRequest`` - -**2016-05-22** - -*Released 4.1.2* - -- Fix ``MessageEntity`` decoding with Bot API 2.1 changes - -**2016-05-16** - -*Released 4.1.1* - -- Fix deprecation warning in ``Dispatcher`` - -**2016-05-15** - -*Released 4.1* - -- Implement API changes from May 6, 2016 -- Fix bug when ``start_polling`` with ``clean=True`` -- Methods now have snake_case equivalent, for example ``telegram.Bot.send_message`` is the same as ``telegram.Bot.sendMessage`` - -**2016-05-01** - -*Released 4.0.3* - -- Add missing attribute ``location`` to ``InlineQuery`` - -**2016-04-29** - -*Released 4.0.2* - -- Bugfixes -- ``KeyboardReplyMarkup`` now accepts ``str`` again - -**2016-04-27** - -*Released 4.0.1* - -- Implement Bot API 2.0 -- Almost complete recode of ``Dispatcher`` -- Please read the `Transition Guide to 4.0 `_ -- **Changes from 4.0rc1** - - The syntax of filters for ``MessageHandler`` (upper/lower cases) - - Handler groups are now identified by ``int`` only, and ordered -- **Note:** v4.0 has been skipped due to a PyPI accident - -**2016-04-22** - -*Released 4.0rc1* - -- Implement Bot API 2.0 -- Almost complete recode of ``Dispatcher`` -- Please read the `Transistion Guide to 4.0 `_ - -**2016-03-22** - -*Released 3.4* - -- Move ``Updater``, ``Dispatcher`` and ``JobQueue`` to new ``telegram.ext`` submodule (thanks to @rahiel) -- Add ``disable_notification`` parameter (thanks to @aidarbiktimirov) -- Fix bug where commands sent by Telegram Web would not be recognized (thanks to @shelomentsevd) -- Add option to skip old updates on bot startup -- Send files from ``BufferedReader`` - -**2016-02-28** - -*Released 3.3* - -- Inline bots -- Send any file by URL -- Specialized exceptions: ``Unauthorized``, ``InvalidToken``, ``NetworkError`` and ``TimedOut`` -- Integration for botan.io (thanks to @ollmer) -- HTML Parsemode (thanks to @jlmadurga) -- Bugfixes and under-the-hood improvements - -**Very special thanks to Noam Meltzer (@tsnoam) for all of his work!** - -**2016-01-09** - -*Released 3.3b1* - -- Implement inline bots (beta) - -**2016-01-05** - -*Released 3.2.0* - -- Introducing ``JobQueue`` (original author: @franciscod) -- Streamlining all exceptions to ``TelegramError`` (Special thanks to @tsnoam) -- Proper locking of ``Updater`` and ``Dispatcher`` ``start`` and ``stop`` methods -- Small bugfixes - -**2015-12-29** - -*Released 3.1.2* - -- Fix custom path for file downloads -- Don't stop the dispatcher thread on uncaught errors in handlers - -**2015-12-21** - -*Released 3.1.1* - -- Fix a bug where asynchronous handlers could not have additional arguments -- Add ``groups`` and ``groupdict`` as additional arguments for regex-based handlers - -**2015-12-16** - -*Released 3.1.0* - -- The ``chat``-field in ``Message`` is now of type ``Chat``. (API update Oct 8 2015) -- ``Message`` now contains the optional fields ``supergroup_chat_created``, ``migrate_to_chat_id``, ``migrate_from_chat_id`` and ``channel_chat_created``. (API update Nov 2015) - -**2015-12-08** - -*Released 3.0.0* - -- Introducing the ``Updater`` and ``Dispatcher`` classes - -**2015-11-11** - -*Released 2.9.2* - -- Error handling on request timeouts has been improved - -**2015-11-10** - -*Released 2.9.1* - -- Add parameter ``network_delay`` to Bot.getUpdates for slow connections - -**2015-11-10** - -*Released 2.9* - -- Emoji class now uses ``bytes_to_native_str`` from ``future`` 3rd party lib -- Make ``user_from`` optional to work with channels -- Raise exception if Telegram times out on long-polling - -*Special thanks to @jh0ker for all hard work* - - -**2015-10-08** - -*Released 2.8.7* - -- Type as optional for ``GroupChat`` class - - -**2015-10-08** - -*Released 2.8.6* - -- Adds type to ``User`` and ``GroupChat`` classes (pre-release Telegram feature) - - -**2015-09-24** - -*Released 2.8.5* - -- Handles HTTP Bad Gateway (503) errors on request -- Fixes regression on ``Audio`` and ``Document`` for unicode fields - - -**2015-09-20** - -*Released 2.8.4* - -- ``getFile`` and ``File.download`` is now fully supported - - -**2015-09-10** - -*Released 2.8.3* - -- Moved ``Bot._requestURL`` to its own class (``telegram.utils.request``) -- Much better, such wow, Telegram Objects tests -- Add consistency for ``str`` properties on Telegram Objects -- Better design to test if ``chat_id`` is invalid -- Add ability to set custom filename on ``Bot.sendDocument(..,filename='')`` -- Fix Sticker as ``InputFile`` -- Send JSON requests over urlencoded post data -- Markdown support for ``Bot.sendMessage(..., parse_mode=ParseMode.MARKDOWN)`` -- Refactor of ``TelegramError`` class (no more handling ``IOError`` or ``URLError``) - - -**2015-09-05** - -*Released 2.8.2* - -- Fix regression on Telegram ReplyMarkup -- Add certificate to ``is_inputfile`` method - - -**2015-09-05** - -*Released 2.8.1* - -- Fix regression on Telegram objects with thumb properties - - -**2015-09-04** - -*Released 2.8* - -- TelegramError when ``chat_id`` is empty for send* methods -- ``setWebhook`` now supports sending self-signed certificate -- Huge redesign of existing Telegram classes -- Added support for PyPy -- Added docstring for existing classes - - -**2015-08-19** - -*Released 2.7.1* - -- Fixed JSON serialization for ``message`` - - -**2015-08-17** - -*Released 2.7* - -- Added support for ``Voice`` object and ``sendVoice`` method -- Due backward compatibility performer or/and title will be required for ``sendAudio`` -- Fixed JSON serialization when forwarded message - - -**2015-08-15** - -*Released 2.6.1* - -- Fixed parsing image header issue on < Python 2.7.3 - - -**2015-08-14** - -*Released 2.6.0* - -- Depreciation of ``require_authentication`` and ``clearCredentials`` methods -- Giving ``AUTHORS`` the proper credits for their contribution for this project -- ``Message.date`` and ``Message.forward_date`` are now ``datetime`` objects - - -**2015-08-12** - -*Released 2.5.3* - -- ``telegram.Bot`` now supports to be unpickled - - -**2015-08-11** - -*Released 2.5.2* - -- New changes from Telegram Bot API have been applied -- ``telegram.Bot`` now supports to be pickled -- Return empty ``str`` instead ``None`` when ``message.text`` is empty - - -**2015-08-10** - -*Released 2.5.1* - -- Moved from GPLv2 to LGPLv3 - - -**2015-08-09** - -*Released 2.5* - -- Fixes logging calls in API - - -**2015-08-08** - -*Released 2.4* - -- Fixes ``Emoji`` class for Python 3 -- ``PEP8`` improvements - - -**2015-08-08** - -*Released 2.3* - -- Fixes ``ForceReply`` class -- Remove ``logging.basicConfig`` from library - - -**2015-07-25** - -*Released 2.2* - -- Allows ``debug=True`` when initializing ``telegram.Bot`` - - -**2015-07-20** - -*Released 2.1* - -- Fix ``to_dict`` for ``Document`` and ``Video`` - - -**2015-07-19** - -*Released 2.0* - -- Fixes bugs -- Improves ``__str__`` over ``to_json()`` -- Creates abstract class ``TelegramObject`` - - -**2015-07-15** - -*Released 1.9* - -- Python 3 officially supported -- ``PEP8`` improvements - - -**2015-07-12** - -*Released 1.8* - -- Fixes crash when replying an unicode text message (special thanks to JRoot3D) - - -**2015-07-11** - -*Released 1.7* - -- Fixes crash when ``username`` is not defined on ``chat`` (special thanks to JRoot3D) - - -**2015-07-10** - -*Released 1.6* - -- Improvements for GAE support - - -**2015-07-10** - -*Released 1.5* - -- Fixes randomly unicode issues when using ``InputFile`` - - -**2015-07-10** - -*Released 1.4* - -- ``requests`` lib is no longer required -- Google App Engine (GAE) is supported - - -**2015-07-10** - -*Released 1.3* - -- Added support to ``setWebhook`` (special thanks to macrojames) - - -**2015-07-09** - -*Released 1.2* - -- ``CustomKeyboard`` classes now available -- Emojis available -- ``PEP8`` improvements - - -**2015-07-08** - -*Released 1.1* - -- PyPi package now available - - -**2015-07-08** - -*Released 1.0* - -- Initial checkin of python-telegram-bot 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 0f5365d9cc1..2a96b7112b9 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,3 @@ -.. - Make sure to apply any changes to this file to README_RAW.rst as well! - .. image:: https://raw.githubusercontent.com/python-telegram-bot/logos/master/logo-text/png/ptb-logo-text_768.png :align: center :target: https://python-telegram-bot.org @@ -14,15 +11,15 @@ :target: https://pypi.org/project/python-telegram-bot/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-6.7-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-9.3-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog - :alt: Supported Bot API versions + :alt: Supported Bot API version .. image:: https://img.shields.io/pypi/dm/python-telegram-bot :target: https://pypistats.org/packages/python-telegram-bot :alt: PyPi Package Monthly Download -.. image:: https://readthedocs.org/projects/python-telegram-bot/badge/?version=stable +.. image:: https://app.readthedocs.org/projects/python-telegram-bot/badge/?version=stable :target: https://docs.python-telegram-bot.org/en/stable/ :alt: Documentation Status @@ -30,7 +27,7 @@ :target: https://www.gnu.org/licenses/lgpl-3.0.html :alt: LGPLv3 License -.. image:: https://github.com/python-telegram-bot/python-telegram-bot/workflows/GitHub%20Actions/badge.svg +.. 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 @@ -46,10 +43,6 @@ :target: https://app.codacy.com/gh/python-telegram-bot/python-telegram-bot/dashboard :alt: Code quality: Codacy -.. image:: https://app.deepsource.com/gh/python-telegram-bot/python-telegram-bot.svg/?label=active+issues - :target: https://app.deepsource.com/gh/python-telegram-bot/python-telegram-bot/?ref=repository-badge - :alt: Code quality: DeepSource - .. 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 @@ -73,30 +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.7+**. +It's compatible with Python versions **3.10+**. -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. -A pure API implementation *without* ``telegram.ext`` is available as the standalone package ``python-telegram-bot-raw``. `See here for details. `_ +After installing_ the library, be sure to check out the section on `working with PTB`_. -Note ----- +Telegram API support +~~~~~~~~~~~~~~~~~~~~ -Installing both ``python-telegram-bot`` and ``python-telegram-bot-raw`` in conjunction will result in undesired side-effects, so only install *one* of both. +All types and methods of the Telegram Bot API **9.3** are natively supported by this library. +In addition, Bot API functionality not yet natively included can still be used as described `in our wiki `_. -Telegram API support -==================== +Notable Features +~~~~~~~~~~~~~~~~ -All types and methods of the Telegram Bot API **6.7** 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 @@ -112,22 +111,29 @@ 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 + +You can also use your favored package manager (such as ``uv``, ``hatch``, ``poetry``, etc.) instead of ``pip``. 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. @@ -135,7 +141,7 @@ As these features are *optional*, the corresponding 3rd party dependencies are n 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.24.1 `_ for +The only required dependency is `httpx >=0.27,<0.29 `_ for ``telegram.request.HTTPXRequest``, the default networking backend. ``python-telegram-bot`` is most useful when used along with additional libraries. @@ -148,29 +154,34 @@ Optional Dependencies PTB can be installed with optional dependencies: -* ``pip install python-telegram-bot[passport]`` installs the `cryptography>=39.0.1 `_ library. Use this, if you want to use Telegram Passport related functionality. -* ``pip install python-telegram-bot[socks]`` installs `httpx[socks] `_. Use this, if you want to work behind a Socks5 server. -* ``pip install python-telegram-bot[http2]`` installs `httpx[http2] `_. Use this, if you want to use HTTP/2. -* ``pip install python-telegram-bot[rate-limiter]`` installs `aiolimiter~=1.1.0 `_. Use this, if you want to use ``telegram.ext.AIORateLimiter``. -* ``pip install python-telegram-bot[webhooks]`` installs the `tornado~=6.2 `_ library. Use this, if you want to use ``telegram.ext.Updater.start_webhook``/``telegram.ext.Application.run_webhook``. -* ``pip install python-telegram-bot[callback-data]`` installs the `cachetools~=5.3.1 `_ library. Use this, if you want to use `arbitrary callback_data `_. -* ``pip install python-telegram-bot[job-queue]`` installs the `APScheduler~=3.10.1 `_ library and enforces `pytz>=2018.6 `_, where ``pytz`` is a dependency of ``APScheduler``. Use this, if you want to use the ``telegram.ext.JobQueue``. +* ``pip install "python-telegram-bot[passport]"`` installs the `cryptography>=39.0.1 `_ library. Use this, if you want to use Telegram Passport related functionality. +* ``pip install "python-telegram-bot[socks]"`` installs `httpx[socks] `_. Use this, if you want to work behind a Socks5 server. +* ``pip install "python-telegram-bot[http2]"`` installs `httpx[http2] `_. Use this, if you want to use HTTP/2. +* ``pip install "python-telegram-bot[rate-limiter]"`` installs `aiolimiter~=1.1,<1.3 `_. Use this, if you want to use ``telegram.ext.AIORateLimiter``. +* ``pip install "python-telegram-bot[webhooks]"`` installs the `tornado~=6.4 `_ library. Use this, if you want to use ``telegram.ext.Updater.start_webhook``/``telegram.ext.Application.run_webhook``. +* ``pip install "python-telegram-bot[callback-data]"`` installs the `cachetools>=5.3.3,<6.3.0 `_ library. Use this, if you want to use `arbitrary callback_data `_. +* ``pip install "python-telegram-bot[job-queue]"`` installs the `APScheduler>=3.10.4,<3.12.0 `_ library. Use this, if you want to use the ``telegram.ext.JobQueue``. -To install multiple optional dependencies, separate them by commas, e.g. ``pip install python-telegram-bot[socks,webhooks]``. +To install multiple optional dependencies, separate them by commas, e.g. ``pip install "python-telegram-bot[socks,webhooks]"``. 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]``. +* ``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. +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 `_. @@ -181,7 +192,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. @@ -192,10 +203,10 @@ 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. +Because ``asyncio`` is in general single-threaded, ``python-telegram-bot`` currently does not aim to be thread-safe. Noteworthy parts of ``python-telegram-bots`` API that are likely to cause issues (e.g. race conditions) when used in a multi-threaded setting include: * ``telegram.ext.Application/Updater.update_queue`` @@ -204,21 +215,29 @@ Noteworthy parts of ``python-telegram-bots`` API that are likely to cause issues * ``telegram.ext.BasePersistence`` * all classes in the ``telegram.ext.filters`` module that allow to add/remove allowed users/chats at runtime +Free threading +~~~~~~~~~~~~~~ + +While ``python-telegram-bot`` is tested to work with Python 3.14 free threading, we do not guarantee that +PTB is thread-safe for all use cases. Please see issue `#4873 `_ for more information. + 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 `_. +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. +Derivative 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 diff --git a/README_RAW.rst b/README_RAW.rst deleted file mode 100644 index 0e7ca847f79..00000000000 --- a/README_RAW.rst +++ /dev/null @@ -1,210 +0,0 @@ -.. - Make sure to apply any changes to this file to README.rst as well! - -.. 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-6.7-blue?logo=telegram - :target: https://core.telegram.org/bots/api-changelog - :alt: Supported Bot API versions - -.. 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/workflows/GitHub%20Actions/badge.svg - :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://app.deepsource.com/gh/python-telegram-bot/python-telegram-bot.svg/?label=active+issues - :target: https://app.deepsource.com/gh/python-telegram-bot/python-telegram-bot/?ref=repository-badge - :alt: Code quality: DeepSource - -.. 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 - -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.7+**. - -``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 **6.7** 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.24.1 `_ 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[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/changes/22.0_2025-03-15/4671.7B3boYRvHdGzsrZgkpKp7B.toml b/changes/22.0_2025-03-15/4671.7B3boYRvHdGzsrZgkpKp7B.toml new file mode 100644 index 00000000000..002b70d4ad5 --- /dev/null +++ b/changes/22.0_2025-03-15/4671.7B3boYRvHdGzsrZgkpKp7B.toml @@ -0,0 +1,19 @@ +breaking = """This release removes all functionality that was deprecated in v20.x. This is in line with our :ref:`stability policy `. +This includes the following changes: + +- Removed ``filters.CHAT`` (all messages have an associated chat) and ``filters.StatusUpdate.USER_SHARED`` (use ``filters.StatusUpdate.USERS_SHARED`` instead). +- Removed ``Defaults.disable_web_page_preview`` and ``Defaults.quote``. Use ``Defaults.link_preview_options`` and ``Defaults.do_quote`` instead. +- Removed ``ApplicationBuilder.(get_updates_)proxy_url`` and ``HTTPXRequest.proxy_url``. Use ``ApplicationBuilder.(get_updates_)proxy`` and ``HTTPXRequest.proxy`` instead. +- Removed the ``*_timeout`` arguments of ``Application.run_polling`` and ``Updater.start_webhook``. Instead, specify the values via ``ApplicationBuilder.get_updates_*_timeout``. +- Removed ``constants.InlineQueryLimit.MIN_SWITCH_PM_TEXT_LENGTH``. Use ``constants.InlineQueryResultsButtonLimit.MAX_START_PARAMETER_LENGTH`` instead. +- Removed the argument ``quote`` of ``Message.reply_*``. Use ``do_quote`` instead. +- Removed the superfluous ``EncryptedPassportElement.credentials`` without replacement. +- Changed attribute value of ``PassportFile.file_date`` from :obj:`int` to :class:`datetime.datetime`. Make sure to adjust your code accordingly. +- Changed the attribute value of ``PassportElementErrors.file_hashes`` from :obj:`list` to :obj:`tuple`. Make sure to adjust your code accordingly. +- Make ``BaseRequest.read_timeout`` an abstract property. If you subclass ``BaseRequest``, you need to implement this property. +- The default value for ``write_timeout`` now defaults to ``DEFAULT_NONE`` also for bot methods that send media. Previously, it was ``20``. If you subclass ``BaseRequest``, make sure to use your desired write timeout if ``RequestData.multipart_data`` is set. +""" +[[pull_requests]] +uid = "4671" +author_uid = "Bibo-Joshi" +closes_threads = ["4659"] diff --git a/changes/22.0_2025-03-15/4672.G9szuJ7pRafycByfem2DrT.toml b/changes/22.0_2025-03-15/4672.G9szuJ7pRafycByfem2DrT.toml new file mode 100644 index 00000000000..c13a2d145df --- /dev/null +++ b/changes/22.0_2025-03-15/4672.G9szuJ7pRafycByfem2DrT.toml @@ -0,0 +1,5 @@ +documentation = "Add `chango `_ As Changelog Management Tool" +[[pull_requests]] +uid = "4672" +author_uid = "Bibo-Joshi" +closes_threads = ["4321"] diff --git a/changes/22.0_2025-03-15/4697.GzyGCEgj74G6bTEDbuFjUU.toml b/changes/22.0_2025-03-15/4697.GzyGCEgj74G6bTEDbuFjUU.toml new file mode 100644 index 00000000000..abdb8f95575 --- /dev/null +++ b/changes/22.0_2025-03-15/4697.GzyGCEgj74G6bTEDbuFjUU.toml @@ -0,0 +1,5 @@ +internal = "Bump github/codeql-action from 3.28.8 to 3.28.10" +[[pull_requests]] +uid = "4697" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.0_2025-03-15/4698.Fkzr3oU2qcFmFX28xfoja5.toml b/changes/22.0_2025-03-15/4698.Fkzr3oU2qcFmFX28xfoja5.toml new file mode 100644 index 00000000000..93eb25aa5b5 --- /dev/null +++ b/changes/22.0_2025-03-15/4698.Fkzr3oU2qcFmFX28xfoja5.toml @@ -0,0 +1,5 @@ +internal = "Bump srvaroa/labeler from 1.12.0 to 1.13.0" +[[pull_requests]] +uid = "4698" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.0_2025-03-15/4699.LYAYfKXX7C7wqT54kRPnVy.toml b/changes/22.0_2025-03-15/4699.LYAYfKXX7C7wqT54kRPnVy.toml new file mode 100644 index 00000000000..6d028a80509 --- /dev/null +++ b/changes/22.0_2025-03-15/4699.LYAYfKXX7C7wqT54kRPnVy.toml @@ -0,0 +1,5 @@ +internal = "Bump astral-sh/setup-uv from 5.2.2 to 5.3.1" +[[pull_requests]] +uid = "4699" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.0_2025-03-15/4700.nm6R6YTnkmCo5evbykz4kz.toml b/changes/22.0_2025-03-15/4700.nm6R6YTnkmCo5evbykz4kz.toml new file mode 100644 index 00000000000..5f5355f6d2e --- /dev/null +++ b/changes/22.0_2025-03-15/4700.nm6R6YTnkmCo5evbykz4kz.toml @@ -0,0 +1,5 @@ +internal = "Bump Bibo-Joshi/chango from 0.3.1 to 0.3.2" +[[pull_requests]] +uid = "4700" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.0_2025-03-15/4701.ah8Wi4SWc22EbgBc4KQeqH.toml b/changes/22.0_2025-03-15/4701.ah8Wi4SWc22EbgBc4KQeqH.toml new file mode 100644 index 00000000000..ac941f29246 --- /dev/null +++ b/changes/22.0_2025-03-15/4701.ah8Wi4SWc22EbgBc4KQeqH.toml @@ -0,0 +1,5 @@ +internal = "Bump pypa/gh-action-pypi-publish from 1.12.3 to 1.12.4" +[[pull_requests]] +uid = "4701" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.0_2025-03-15/4709.dbwPVaU8vSacVkMLhiMjyJ.toml b/changes/22.0_2025-03-15/4709.dbwPVaU8vSacVkMLhiMjyJ.toml new file mode 100644 index 00000000000..5cc432cd401 --- /dev/null +++ b/changes/22.0_2025-03-15/4709.dbwPVaU8vSacVkMLhiMjyJ.toml @@ -0,0 +1,5 @@ +internal = "Bump pytest from 8.3.4 to 8.3.5" +[[pull_requests]] +uid = "4709" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.0_2025-03-15/4710.CSNixpvxJdLFaM6xSQ39Zf.toml b/changes/22.0_2025-03-15/4710.CSNixpvxJdLFaM6xSQ39Zf.toml new file mode 100644 index 00000000000..4219b05acba --- /dev/null +++ b/changes/22.0_2025-03-15/4710.CSNixpvxJdLFaM6xSQ39Zf.toml @@ -0,0 +1,5 @@ +internal = "Bump sphinx from 8.1.3 to 8.2.3" +[[pull_requests]] +uid = "4710" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.0_2025-03-15/4712.8ckkAPAXGzedityWEyv363.toml b/changes/22.0_2025-03-15/4712.8ckkAPAXGzedityWEyv363.toml new file mode 100644 index 00000000000..63f0ad0743e --- /dev/null +++ b/changes/22.0_2025-03-15/4712.8ckkAPAXGzedityWEyv363.toml @@ -0,0 +1,5 @@ +internal = "Bump Bibo-Joshi/chango from 0.3.2 to 0.4.0" +[[pull_requests]] +uid = "4712" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.0_2025-03-15/4719.d4xMztC8JjrbVgEscxvMWX.toml b/changes/22.0_2025-03-15/4719.d4xMztC8JjrbVgEscxvMWX.toml new file mode 100644 index 00000000000..c220a3eb6f3 --- /dev/null +++ b/changes/22.0_2025-03-15/4719.d4xMztC8JjrbVgEscxvMWX.toml @@ -0,0 +1,5 @@ +internal = "Bump Version to v22.0" +[[pull_requests]] +uid = "4719" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4692.dVZs28GuwTFnNJdWkvPbNv.toml b/changes/22.1_2025-05-15/4692.dVZs28GuwTFnNJdWkvPbNv.toml new file mode 100644 index 00000000000..a70d3418d7c --- /dev/null +++ b/changes/22.1_2025-05-15/4692.dVZs28GuwTFnNJdWkvPbNv.toml @@ -0,0 +1,5 @@ +breaking = "Drop backward compatibility for ``user_id`` in ``send_gift`` by updating the order of parameters. Please adapt your code accordingly or use keyword arguments." +[[pull_requests]] +uid = "4692" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4730.GsSUQSXzV8TF2Wav4CXRdd.toml b/changes/22.1_2025-05-15/4730.GsSUQSXzV8TF2Wav4CXRdd.toml new file mode 100644 index 00000000000..9a4d9369e2f --- /dev/null +++ b/changes/22.1_2025-05-15/4730.GsSUQSXzV8TF2Wav4CXRdd.toml @@ -0,0 +1,9 @@ +documentation = "Documentation Improvements. Among others, add missing ``Returns`` field in ``User.get_profile_photos``" +[[pull_requests]] +uid = "4730" +author_uid = "Bibo-Joshi" +closes_threads = [] +[[pull_requests]] +uid = "4740" +author_uid = "aelkheir" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4733.BRLwsEuh76974FPJRuiBjf.toml b/changes/22.1_2025-05-15/4733.BRLwsEuh76974FPJRuiBjf.toml new file mode 100644 index 00000000000..ebc3a8519f8 --- /dev/null +++ b/changes/22.1_2025-05-15/4733.BRLwsEuh76974FPJRuiBjf.toml @@ -0,0 +1,5 @@ +bugfixes = "Ensure execution of ``Bot.shutdown()`` even if ``Bot.get_me()`` fails in ``Bot.initialize()``" +[[pull_requests]] +uid = "4733" +author_uid = "Poolitzer" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4741.nVLBrFX4p8jTCBjMRqaYoQ.toml b/changes/22.1_2025-05-15/4741.nVLBrFX4p8jTCBjMRqaYoQ.toml new file mode 100644 index 00000000000..aacb5f2d501 --- /dev/null +++ b/changes/22.1_2025-05-15/4741.nVLBrFX4p8jTCBjMRqaYoQ.toml @@ -0,0 +1,5 @@ +internal = "Bump codecov/test-results-action from 1.0.2 to 1.1.0" +[[pull_requests]] +uid = "4741" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4742.oEA6MjYXMafdbu2akWT5tC.toml b/changes/22.1_2025-05-15/4742.oEA6MjYXMafdbu2akWT5tC.toml new file mode 100644 index 00000000000..97463ed483f --- /dev/null +++ b/changes/22.1_2025-05-15/4742.oEA6MjYXMafdbu2akWT5tC.toml @@ -0,0 +1,5 @@ +internal = "Bump actions/setup-python from 5.4.0 to 5.5.0" +[[pull_requests]] +uid = "4742" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4743.SpMm4vvAMjEreykTcGwzcF.toml b/changes/22.1_2025-05-15/4743.SpMm4vvAMjEreykTcGwzcF.toml new file mode 100644 index 00000000000..b6724ab2917 --- /dev/null +++ b/changes/22.1_2025-05-15/4743.SpMm4vvAMjEreykTcGwzcF.toml @@ -0,0 +1,5 @@ +internal = "Bump github/codeql-action from 3.28.10 to 3.28.13" +[[pull_requests]] +uid = "4743" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4744.a4tsF64kZPA2noP7HtTzTX.toml b/changes/22.1_2025-05-15/4744.a4tsF64kZPA2noP7HtTzTX.toml new file mode 100644 index 00000000000..cb5f24ea554 --- /dev/null +++ b/changes/22.1_2025-05-15/4744.a4tsF64kZPA2noP7HtTzTX.toml @@ -0,0 +1,5 @@ +internal = "Bump astral-sh/setup-uv from 5.3.1 to 5.4.1" +[[pull_requests]] +uid = "4744" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4745.emNmhxtvtTP9uLNQxpcVSj.toml b/changes/22.1_2025-05-15/4745.emNmhxtvtTP9uLNQxpcVSj.toml new file mode 100644 index 00000000000..cae16287a79 --- /dev/null +++ b/changes/22.1_2025-05-15/4745.emNmhxtvtTP9uLNQxpcVSj.toml @@ -0,0 +1,5 @@ +internal = "Bump actions/download-artifact from 4.1.8 to 4.2.1" +[[pull_requests]] +uid = "4745" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4746.gWnX3BCxbvujQ8B2QejtTK.toml b/changes/22.1_2025-05-15/4746.gWnX3BCxbvujQ8B2QejtTK.toml new file mode 100644 index 00000000000..c24204d45c4 --- /dev/null +++ b/changes/22.1_2025-05-15/4746.gWnX3BCxbvujQ8B2QejtTK.toml @@ -0,0 +1,5 @@ +internal = "Reenable ``test_official`` Blocked by Debug Remnant" +[[pull_requests]] +uid = "4746" +author_uid = "aelkheir" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4747.MLmApvpGdwN7J24j7fXsDU.toml b/changes/22.1_2025-05-15/4747.MLmApvpGdwN7J24j7fXsDU.toml new file mode 100644 index 00000000000..e6bb47332f9 --- /dev/null +++ b/changes/22.1_2025-05-15/4747.MLmApvpGdwN7J24j7fXsDU.toml @@ -0,0 +1,5 @@ +documentation = "Update ``AUTHORS.rst``, Adding `@aelkheir `_ to Active Development Team" +[[pull_requests]] +uid = "4747" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4748.j3cKusZZKqTLbc542K4sqJ.toml b/changes/22.1_2025-05-15/4748.j3cKusZZKqTLbc542K4sqJ.toml new file mode 100644 index 00000000000..a719b9ecd07 --- /dev/null +++ b/changes/22.1_2025-05-15/4748.j3cKusZZKqTLbc542K4sqJ.toml @@ -0,0 +1,5 @@ +internal = "Bump `pre-commit` Hooks to Latest Versions" +[[pull_requests]] +uid = "4748" +author_uid = "pre-commit-ci" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4756.JT5nmUmGRG6qDEh5ScMn5f.toml b/changes/22.1_2025-05-15/4756.JT5nmUmGRG6qDEh5ScMn5f.toml new file mode 100644 index 00000000000..a7a6b76c9a7 --- /dev/null +++ b/changes/22.1_2025-05-15/4756.JT5nmUmGRG6qDEh5ScMn5f.toml @@ -0,0 +1,51 @@ +features = "Full Support for Bot API 9.0" +deprecations = """This release comes with several deprecations, in line with our :ref:`stability policy `. +This includes the following: + +- Deprecated ``telegram.constants.StarTransactionsLimit.NANOSTAR_MIN_AMOUNT`` and ``telegram.constants.StarTransactionsLimit.NANOSTAR_MAX_AMOUNT``. These members will be replaced by ``telegram.constants.NanostarLimit.MIN_AMOUNT`` and ``telegram.constants.NanostarLimit.MAX_AMOUNT``. +- Deprecated the class ``telegram.constants.StarTransactions``. Its only member ``telegram.constants.StarTransactions.NANOSTAR_VALUE`` will be replaced by ``telegram.constants.Nanostar.VALUE``. +- Bot API 9.0 deprecated ``BusinessConnection.can_reply`` in favor of ``BusinessConnection.rights`` +- Bot API 9.0 deprecated ``ChatFullInfo.can_send_gift`` in favor of ``ChatFullInfo.accepted_gift_types``. +- Bot API 9.0 introduced these new required fields to existing classes: + - ``TransactionPartnerUser.transaction_type`` + - ``ChatFullInfo.accepted_gift_types`` + + Passing these values as positional arguments is deprecated. We encourage you to use keyword arguments instead, as the the signature will be updated in a future release. + +These deprecations are backward compatible, but we strongly recommend to update your code to use the new members. +""" +[[pull_requests]] +uid = "4756" +author_uid = "Bibo-Joshi" +closes_threads = ["4754"] +[[pull_requests]] +uid = "4757" +author_uid = "Bibo-Joshi" +closes_threads = [] +[[pull_requests]] +uid = "4759" +author_uid = "Bibo-Joshi" +closes_threads = [] +[[pull_requests]] +uid = "4763" +author_uid = "aelkheir" +closes_threads = [] +[[pull_requests]] +uid = "4766" +author_uid = "Bibo-Joshi" +[[pull_requests]] +uid = "4769" +author_uid = "aelkheir" +closes_threads = [] +[[pull_requests]] +uid = "4773" +author_uid = "aelkheir" +closes_threads = [] +[[pull_requests]] +uid = "4781" +author_uid = "aelkheir" +closes_threads = [] +[[pull_requests]] +uid = "4782" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4758.dSyCdBJWEJroH2GynR2VaJ.toml b/changes/22.1_2025-05-15/4758.dSyCdBJWEJroH2GynR2VaJ.toml new file mode 100644 index 00000000000..c602a07af7c --- /dev/null +++ b/changes/22.1_2025-05-15/4758.dSyCdBJWEJroH2GynR2VaJ.toml @@ -0,0 +1,5 @@ +internal = "Fine-tune ``chango`` and release workflows" +[[pull_requests]] +uid = "4758" +author_uid = "Bibo-Joshi" +closes_threads = ["4720"] diff --git a/changes/22.1_2025-05-15/4761.mmsngFA6b4ccdEzEpFTZS3.toml b/changes/22.1_2025-05-15/4761.mmsngFA6b4ccdEzEpFTZS3.toml new file mode 100644 index 00000000000..a47dcdcc3b1 --- /dev/null +++ b/changes/22.1_2025-05-15/4761.mmsngFA6b4ccdEzEpFTZS3.toml @@ -0,0 +1,6 @@ +bugfixes = "Fix Handling of ``Defaults`` for ``InputPaidMedia``" + +[[pull_requests]] +uid = "4761" +author_uid = "ngrogolev" +closes_threads = ["4753"] diff --git a/changes/22.1_2025-05-15/4762.PbcJGM8KPBMbKri3fdHKjh.toml b/changes/22.1_2025-05-15/4762.PbcJGM8KPBMbKri3fdHKjh.toml new file mode 100644 index 00000000000..0aeebe750b6 --- /dev/null +++ b/changes/22.1_2025-05-15/4762.PbcJGM8KPBMbKri3fdHKjh.toml @@ -0,0 +1,5 @@ +documentation = "Clarify Documentation and Type Hints of ``InputMedia`` and ``InputPaidMedia``. Note that the ``media`` parameter accepts only objects of type ``str`` and ``InputFile``. The respective subclasses of ``Input(Paid)Media`` each accept a broader range of input type for the ``media`` parameter." +[[pull_requests]] +uid = "4762" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4775.kkLon84t7Vy5REKRe9LwPH.toml b/changes/22.1_2025-05-15/4775.kkLon84t7Vy5REKRe9LwPH.toml new file mode 100644 index 00000000000..b01e19eb5ec --- /dev/null +++ b/changes/22.1_2025-05-15/4775.kkLon84t7Vy5REKRe9LwPH.toml @@ -0,0 +1,5 @@ +internal = "Bump codecov/codecov-action from 5.1.2 to 5.4.2" +[[pull_requests]] +uid = "4775" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4776.g83DxRk4WVWCC8rCd6ocFC.toml b/changes/22.1_2025-05-15/4776.g83DxRk4WVWCC8rCd6ocFC.toml new file mode 100644 index 00000000000..2af8ebcd2d6 --- /dev/null +++ b/changes/22.1_2025-05-15/4776.g83DxRk4WVWCC8rCd6ocFC.toml @@ -0,0 +1,5 @@ +internal = "Bump actions/upload-artifact from 4.5.0 to 4.6.2" +[[pull_requests]] +uid = "4776" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4777.Lhgz8xFtQjgrKM2KvNfxwD.toml b/changes/22.1_2025-05-15/4777.Lhgz8xFtQjgrKM2KvNfxwD.toml new file mode 100644 index 00000000000..4b5e40bad26 --- /dev/null +++ b/changes/22.1_2025-05-15/4777.Lhgz8xFtQjgrKM2KvNfxwD.toml @@ -0,0 +1,5 @@ +internal = "Bump stefanzweifel/git-auto-commit-action from 5.1.0 to 5.2.0" +[[pull_requests]] +uid = "4777" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4778.CeUSPNLbGGsqP2Vo4xKkdp.toml b/changes/22.1_2025-05-15/4778.CeUSPNLbGGsqP2Vo4xKkdp.toml new file mode 100644 index 00000000000..c14276f7821 --- /dev/null +++ b/changes/22.1_2025-05-15/4778.CeUSPNLbGGsqP2Vo4xKkdp.toml @@ -0,0 +1,5 @@ +internal = "Bump github/codeql-action from 3.28.13 to 3.28.16" +[[pull_requests]] +uid = "4778" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4779.UqcbJVKYxwTtrBEGDgb3VS.toml b/changes/22.1_2025-05-15/4779.UqcbJVKYxwTtrBEGDgb3VS.toml new file mode 100644 index 00000000000..b6917ffef1b --- /dev/null +++ b/changes/22.1_2025-05-15/4779.UqcbJVKYxwTtrBEGDgb3VS.toml @@ -0,0 +1,5 @@ +internal = "Bump actions/download-artifact from 4.2.1 to 4.3.0" +[[pull_requests]] +uid = "4779" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.1_2025-05-15/4791.QycEvQbiaiJKraKDhAfFWM.toml b/changes/22.1_2025-05-15/4791.QycEvQbiaiJKraKDhAfFWM.toml new file mode 100644 index 00000000000..c5db706c800 --- /dev/null +++ b/changes/22.1_2025-05-15/4791.QycEvQbiaiJKraKDhAfFWM.toml @@ -0,0 +1,5 @@ +other = "Bump Version to v22.1" +[[pull_requests]] +uid = "4791" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4750.jJBu7iAgZa96hdqcpHK96W.toml b/changes/22.2_2025-06-29/4750.jJBu7iAgZa96hdqcpHK96W.toml new file mode 100644 index 00000000000..5d9d75d7ca9 --- /dev/null +++ b/changes/22.2_2025-06-29/4750.jJBu7iAgZa96hdqcpHK96W.toml @@ -0,0 +1,36 @@ +features = "Use `timedelta` to represent time periods in class arguments and attributes" +deprecations = """In this release, we're migrating attributes of Telegram objects that represent durations/time periods from having :obj:`int` type to Python's native :class:`datetime.timedelta`. This change is opt-in for now to allow for a smooth transition phase. It will become opt-out in future releases. + +Set ``PTB_TIMEDELTA=true`` or ``PTB_TIMEDELTA=1`` as an environment variable to make these attributes return :obj:`datetime.timedelta` objects instead of integers. Support for :obj:`int` values is deprecated and will be removed in a future major version. + +Affected Attributes: +- :attr:`telegram.ChatFullInfo.slow_mode_delay` and :attr:`telegram.ChatFullInfo.message_auto_delete_time` +- :attr:`telegram.Animation.duration` +- :attr:`telegram.Audio.duration` +- :attr:`telegram.Video.duration` and :attr:`telegram.Video.start_timestamp` +- :attr:`telegram.VideoNote.duration` +- :attr:`telegram.Voice.duration` +- :attr:`telegram.PaidMediaPreview.duration` +- :attr:`telegram.VideoChatEnded.duration` +- :attr:`telegram.InputMediaVideo.duration` +- :attr:`telegram.InputMediaAnimation.duration` +- :attr:`telegram.InputMediaAudio.duration` +- :attr:`telegram.InputPaidMediaVideo.duration` +- :attr:`telegram.InlineQueryResultGif.gif_duration` +- :attr:`telegram.InlineQueryResultMpeg4Gif.mpeg4_duration` +- :attr:`telegram.InlineQueryResultVideo.video_duration` +- :attr:`telegram.InlineQueryResultAudio.audio_duration` +- :attr:`telegram.InlineQueryResultVoice.voice_duration` +- :attr:`telegram.InlineQueryResultLocation.live_period` +- :attr:`telegram.Poll.open_period` +- :attr:`telegram.Location.live_period` +- :attr:`telegram.MessageAutoDeleteTimerChanged.message_auto_delete_time` +- :attr:`telegram.ChatInviteLink.subscription_period` +- :attr:`telegram.InputLocationMessageContent.live_period` +- :attr:`telegram.error.RetryAfter.retry_after` +""" +internal = "Modify `test_official` to handle time periods as timedelta automatically." +[[pull_requests]] +uid = "4750" +author_uid = "aelkheir" +closes_threads = ["4575"] diff --git a/changes/22.2_2025-06-29/4792.YsK6LmbEhZv6y3dvhHbXD7.toml b/changes/22.2_2025-06-29/4792.YsK6LmbEhZv6y3dvhHbXD7.toml new file mode 100644 index 00000000000..675c2904b4d --- /dev/null +++ b/changes/22.2_2025-06-29/4792.YsK6LmbEhZv6y3dvhHbXD7.toml @@ -0,0 +1,5 @@ +internal = "Fix Bug in Automated Channel Announcement" +[[pull_requests]] +uid = "4792" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4793.mDR5p3mrSmPFQkvWFGWBmD.toml b/changes/22.2_2025-06-29/4793.mDR5p3mrSmPFQkvWFGWBmD.toml new file mode 100644 index 00000000000..7a6ca4c3e95 --- /dev/null +++ b/changes/22.2_2025-06-29/4793.mDR5p3mrSmPFQkvWFGWBmD.toml @@ -0,0 +1,5 @@ +internal = "Fix a Failing Test Case" +[[pull_requests]] +uid = "4793" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4798.g7G3jRf2ns4ath9LRFEcit.toml b/changes/22.2_2025-06-29/4798.g7G3jRf2ns4ath9LRFEcit.toml new file mode 100644 index 00000000000..c238ebdfc67 --- /dev/null +++ b/changes/22.2_2025-06-29/4798.g7G3jRf2ns4ath9LRFEcit.toml @@ -0,0 +1,5 @@ +internal = "Rework Repository to `src` Layout" +[[pull_requests]] +uid = "4798" +author_uid = "Bibo-Joshi" +closes_threads = ["4797"] diff --git a/changes/22.2_2025-06-29/4800.2Z9Q8uvzdU2TqMJ9biBLam.toml b/changes/22.2_2025-06-29/4800.2Z9Q8uvzdU2TqMJ9biBLam.toml new file mode 100644 index 00000000000..4f670da8bd0 --- /dev/null +++ b/changes/22.2_2025-06-29/4800.2Z9Q8uvzdU2TqMJ9biBLam.toml @@ -0,0 +1,5 @@ +dependencies = "Implement PEP 735 Dependency Groups for Development Dependencies" +[[pull_requests]] +uid = "4800" +author_uid = "harshil21" +closes_threads = ["4795"] diff --git a/changes/22.2_2025-06-29/4801.feKaYKKZTZq2KBjhyxVVAM.toml b/changes/22.2_2025-06-29/4801.feKaYKKZTZq2KBjhyxVVAM.toml new file mode 100644 index 00000000000..3531270fc8d --- /dev/null +++ b/changes/22.2_2025-06-29/4801.feKaYKKZTZq2KBjhyxVVAM.toml @@ -0,0 +1,5 @@ +dependencies = "Update cachetools requirement from <5.6.0,>=5.3.3 to >=5.3.3,<6.1.0" +[[pull_requests]] +uid = "4801" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4802.RMLufX4UazobYg5aZojyoD.toml b/changes/22.2_2025-06-29/4802.RMLufX4UazobYg5aZojyoD.toml new file mode 100644 index 00000000000..8c558c7c1c8 --- /dev/null +++ b/changes/22.2_2025-06-29/4802.RMLufX4UazobYg5aZojyoD.toml @@ -0,0 +1,14 @@ +bugfixes = """ +Fixed a bug where calling ``Application.remove/add_handler`` during update handling can cause a ``RuntimeError`` in ``Application.process_update``. + +.. hint:: + Calling ``Application.add/remove_handler`` now has no influence on calls to ``process_update`` that are + already in progress. The same holds for ``Application.add/remove_error_handler`` and ``Application.process_error``, respectively. + + .. warning:: + This behavior should currently be considered an implementation detail and not as guaranteed behavior. +""" +[[pull_requests]] +uid = "4802" +author_uid = "Bibo-Joshi" +closes_threads = ["4803"] diff --git a/changes/22.2_2025-06-29/4810.KyRnffWk3ARyQFNcF88Uh3.toml b/changes/22.2_2025-06-29/4810.KyRnffWk3ARyQFNcF88Uh3.toml new file mode 100644 index 00000000000..dcb64b0d66b --- /dev/null +++ b/changes/22.2_2025-06-29/4810.KyRnffWk3ARyQFNcF88Uh3.toml @@ -0,0 +1,20 @@ +documentation = """Documentation Improvements. Among other things + +* mention alternative package managers in README and contribution guide +* remove ``furo-sphinx-search`` +""" + +[[pull_requests]] +uid = "4810" +author_uid = "Bibo-Joshi" +closes_threads = [] + +[[pull_requests]] +uid = "4824" +author_uid = "Aweryc" +closes_threads = ["4823"] + +[[pull_requests]] +uid = "4826" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4811.TQgVr5Sa6uDFtWXdTS5wfw.toml b/changes/22.2_2025-06-29/4811.TQgVr5Sa6uDFtWXdTS5wfw.toml new file mode 100644 index 00000000000..cefe0bb045c --- /dev/null +++ b/changes/22.2_2025-06-29/4811.TQgVr5Sa6uDFtWXdTS5wfw.toml @@ -0,0 +1,5 @@ +internal = "Bump github/codeql-action from 3.28.16 to 3.28.18" +[[pull_requests]] +uid = "4811" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4812.i4aqsTfCkdEs8uYNYS59si.toml b/changes/22.2_2025-06-29/4812.i4aqsTfCkdEs8uYNYS59si.toml new file mode 100644 index 00000000000..0382ab61f34 --- /dev/null +++ b/changes/22.2_2025-06-29/4812.i4aqsTfCkdEs8uYNYS59si.toml @@ -0,0 +1,5 @@ +internal = "Bump actions/setup-python from 5.5.0 to 5.6.0" +[[pull_requests]] +uid = "4812" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4813.cnbzL2eSRzj3i9NcUMFyFo.toml b/changes/22.2_2025-06-29/4813.cnbzL2eSRzj3i9NcUMFyFo.toml new file mode 100644 index 00000000000..afd93290d34 --- /dev/null +++ b/changes/22.2_2025-06-29/4813.cnbzL2eSRzj3i9NcUMFyFo.toml @@ -0,0 +1,5 @@ +internal = "Bump dependabot/fetch-metadata from 2.3.0 to 2.4.0" +[[pull_requests]] +uid = "4813" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4814.RMQVXTNywcpBQ3HkX2TuyA.toml b/changes/22.2_2025-06-29/4814.RMQVXTNywcpBQ3HkX2TuyA.toml new file mode 100644 index 00000000000..789f6ebdc68 --- /dev/null +++ b/changes/22.2_2025-06-29/4814.RMQVXTNywcpBQ3HkX2TuyA.toml @@ -0,0 +1,5 @@ +internal = "Bump codecov/codecov-action from 5.4.2 to 5.4.3" +[[pull_requests]] +uid = "4814" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4815.9dLSFHFozQiAM7oCpX4NyL.toml b/changes/22.2_2025-06-29/4815.9dLSFHFozQiAM7oCpX4NyL.toml new file mode 100644 index 00000000000..e2559792f7b --- /dev/null +++ b/changes/22.2_2025-06-29/4815.9dLSFHFozQiAM7oCpX4NyL.toml @@ -0,0 +1,5 @@ +internal = "Bump codecov/test-results-action from 1.1.0 to 1.1.1" +[[pull_requests]] +uid = "4815" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4816.hhYVDfdzUgoQoMNRKkCDjb.toml b/changes/22.2_2025-06-29/4816.hhYVDfdzUgoQoMNRKkCDjb.toml new file mode 100644 index 00000000000..ade061585de --- /dev/null +++ b/changes/22.2_2025-06-29/4816.hhYVDfdzUgoQoMNRKkCDjb.toml @@ -0,0 +1,5 @@ +internal = "Fix Typo in `TelegramObject._get_attrs`" +[[pull_requests]] +uid = "4816" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4817.ZHx38jvj9zKRNZfQh9Xrpb.toml b/changes/22.2_2025-06-29/4817.ZHx38jvj9zKRNZfQh9Xrpb.toml new file mode 100644 index 00000000000..31e63a6a7ef --- /dev/null +++ b/changes/22.2_2025-06-29/4817.ZHx38jvj9zKRNZfQh9Xrpb.toml @@ -0,0 +1,5 @@ +bugfixes = "Allow for pattern matching empty inline queries" +[[pull_requests]] +uid = "4817" +author_uid = "locobott" +closes_threads = [] \ No newline at end of file diff --git a/changes/22.2_2025-06-29/4818.3VPDqqEWEhDCrTipFY8KKU.toml b/changes/22.2_2025-06-29/4818.3VPDqqEWEhDCrTipFY8KKU.toml new file mode 100644 index 00000000000..503e69ae82e --- /dev/null +++ b/changes/22.2_2025-06-29/4818.3VPDqqEWEhDCrTipFY8KKU.toml @@ -0,0 +1,12 @@ +bugfixes = """ +Correctly parse parameter ``allow_sending_without_reply`` in ``Message.reply_*`` when used in combination with ``do_quote=True``. + +.. hint:: + + Using ``dict`` valued input for ``do_quote`` along with passing ``allow_sending_without_reply`` is not supported and will raise an error. +""" + +[[pull_requests]] +uid = "4818" +author_uid = "Bibo-Joshi" +closes_threads = ["4807"] diff --git a/changes/22.2_2025-06-29/4820.7bFkjLSeWKdNVhThPpVMAT.toml b/changes/22.2_2025-06-29/4820.7bFkjLSeWKdNVhThPpVMAT.toml new file mode 100644 index 00000000000..f0b2f0f9ff0 --- /dev/null +++ b/changes/22.2_2025-06-29/4820.7bFkjLSeWKdNVhThPpVMAT.toml @@ -0,0 +1,5 @@ +dependencies = "Bump ``httpx`` from ~=0.27 to >=0.27,<0.29" +[[pull_requests]] +uid = "4820" +author_uid = "Bibo-Joshi" +closes_threads = ["4819"] diff --git a/changes/22.2_2025-06-29/4822.DrW3tJ3KoB8kTmHtNnNEpQ.toml b/changes/22.2_2025-06-29/4822.DrW3tJ3KoB8kTmHtNnNEpQ.toml new file mode 100644 index 00000000000..060021dc6af --- /dev/null +++ b/changes/22.2_2025-06-29/4822.DrW3tJ3KoB8kTmHtNnNEpQ.toml @@ -0,0 +1,5 @@ +other = "Improve Informativeness of Network Errors Raised by ``BaseRequest.post/retrieve``" + +[[pull_requests]] +uid = "4822" +author_uid = "Bibo-Joshi" diff --git a/changes/22.2_2025-06-29/4825.R7wiTzvN37KAV656s9kfnC.toml b/changes/22.2_2025-06-29/4825.R7wiTzvN37KAV656s9kfnC.toml new file mode 100644 index 00000000000..5f932e8254d --- /dev/null +++ b/changes/22.2_2025-06-29/4825.R7wiTzvN37KAV656s9kfnC.toml @@ -0,0 +1,5 @@ +other = "Add Python 3.14 Beta To Test Matrix. *Python 3.14 is not officially supported by PTB yet!*" +[[pull_requests]] +uid = "4825" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.2_2025-06-29/4830.EZzGEAk7DiFuedKPoQMMvd.toml b/changes/22.2_2025-06-29/4830.EZzGEAk7DiFuedKPoQMMvd.toml new file mode 100644 index 00000000000..40e5f0fb805 --- /dev/null +++ b/changes/22.2_2025-06-29/4830.EZzGEAk7DiFuedKPoQMMvd.toml @@ -0,0 +1,5 @@ +dependencies = "Update ``cachetools`` requirement from <6.1.0,>=5.3.3 to >=5.3.3,<6.2.0" + +[[pull_requests]] +uid = "4830" +author_uid = "dependabot" diff --git a/changes/22.2_2025-06-29/4834.oBWsiGWNMuoSXvJNom6N6A.toml b/changes/22.2_2025-06-29/4834.oBWsiGWNMuoSXvJNom6N6A.toml new file mode 100644 index 00000000000..db25128ceaa --- /dev/null +++ b/changes/22.2_2025-06-29/4834.oBWsiGWNMuoSXvJNom6N6A.toml @@ -0,0 +1,5 @@ +other = "Bump Version to v22.2" +[[pull_requests]] +uid = "4834" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.3_2025-07-20/4837.cVTsY7pxfQqgVXfFW9hgZK.toml b/changes/22.3_2025-07-20/4837.cVTsY7pxfQqgVXfFW9hgZK.toml new file mode 100644 index 00000000000..799b32d75fe --- /dev/null +++ b/changes/22.3_2025-07-20/4837.cVTsY7pxfQqgVXfFW9hgZK.toml @@ -0,0 +1,6 @@ +internal = "Update API Token for Local Testing Bot" + +[[pull_requests]] +uid = "4837" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.3_2025-07-20/4839.gExNhF7C6kR5VrBg6cBaar.toml b/changes/22.3_2025-07-20/4839.gExNhF7C6kR5VrBg6cBaar.toml new file mode 100644 index 00000000000..30824ba789f --- /dev/null +++ b/changes/22.3_2025-07-20/4839.gExNhF7C6kR5VrBg6cBaar.toml @@ -0,0 +1,5 @@ +documentation = "Documentation Improvements. Among others, fix links to source code." +[[pull_requests]] +uid = "4839" +author_uid = "aelkheir" +closes_threads = ["4838"] diff --git a/changes/22.3_2025-07-20/4840.jz9uGugc5DUd8x8pQHPyzg.toml b/changes/22.3_2025-07-20/4840.jz9uGugc5DUd8x8pQHPyzg.toml new file mode 100644 index 00000000000..bf51748b985 --- /dev/null +++ b/changes/22.3_2025-07-20/4840.jz9uGugc5DUd8x8pQHPyzg.toml @@ -0,0 +1,5 @@ +internal = "Bump stefanzweifel/git-auto-commit-action from 5.2.0 to 6.0.1" +[[pull_requests]] +uid = "4840" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.3_2025-07-20/4841.HHVCCXXZbYAaaQVmKHCJm2.toml b/changes/22.3_2025-07-20/4841.HHVCCXXZbYAaaQVmKHCJm2.toml new file mode 100644 index 00000000000..5959a63505f --- /dev/null +++ b/changes/22.3_2025-07-20/4841.HHVCCXXZbYAaaQVmKHCJm2.toml @@ -0,0 +1,5 @@ +internal = "Bump github/codeql-action from 3.28.18 to 3.29.2" +[[pull_requests]] +uid = "4841" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.3_2025-07-20/4842.PSW9ZbxENhwfRhbSfemCwE.toml b/changes/22.3_2025-07-20/4842.PSW9ZbxENhwfRhbSfemCwE.toml new file mode 100644 index 00000000000..25aab233487 --- /dev/null +++ b/changes/22.3_2025-07-20/4842.PSW9ZbxENhwfRhbSfemCwE.toml @@ -0,0 +1,5 @@ +internal = "Bump astral-sh/setup-uv from 5.4.1 to 6.3.1" +[[pull_requests]] +uid = "4842" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.3_2025-07-20/4843.f5aZ5Vpxevw8fEEudawU5u.toml b/changes/22.3_2025-07-20/4843.f5aZ5Vpxevw8fEEudawU5u.toml new file mode 100644 index 00000000000..6a2593d4fc3 --- /dev/null +++ b/changes/22.3_2025-07-20/4843.f5aZ5Vpxevw8fEEudawU5u.toml @@ -0,0 +1,5 @@ +internal = "Bump sigstore/gh-action-sigstore-python from 3.0.0 to 3.0.1" +[[pull_requests]] +uid = "4843" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.3_2025-07-20/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml b/changes/22.3_2025-07-20/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml new file mode 100644 index 00000000000..82d84835c8e --- /dev/null +++ b/changes/22.3_2025-07-20/4847.8ujbbBbaZ2VTEdRLeqirSZ.toml @@ -0,0 +1,18 @@ +highlights = "Full Support for Bot API 9.1" + +features = """ +New filters based on Bot API 9.1: + +* ``filters.StatusUpdate.DIRECT_MESSAGE_PRICE_CHANGED`` for ``Message.direct_message_price_changed`` +* ``filters.StatusUpdate.CHECKLIST_TASKS_ADDED`` for ``Message.checklist_tasks_added`` +* ``filters.StatusUpdate.CHECKLIST_TASKS_DONE`` for ``Message.checklist_tasks_done`` +* ``filters.CHECKLIST`` for ``Message.checklist`` +""" + +pull_requests = [ + { uid = "4847", author_uid = "Bibo-Joshi", closes_threads = ["4845"] }, + { uid = "4848", author_uid = "Bibo-Joshi" }, + { uid = "4849", author_uid = "harshil21" }, + { uid = "4851", author_uid = "harshil21" }, + { uid = "4857", author_uid = "aelkheir" }, +] diff --git a/changes/22.3_2025-07-20/4852.mz3RDjX636ZdGoR456C6v9.toml b/changes/22.3_2025-07-20/4852.mz3RDjX636ZdGoR456C6v9.toml new file mode 100644 index 00000000000..73640caded1 --- /dev/null +++ b/changes/22.3_2025-07-20/4852.mz3RDjX636ZdGoR456C6v9.toml @@ -0,0 +1,11 @@ +breaking = """Remove Functionality Deprecated in API 9.0 + +* Remove deprecated argument and attribute ``BusinessConnection.can_reply``. +* Remove deprecated argument and attribute ``ChatFullInfo.can_send_gift`` +* Remove deprecated class ``constants.StarTransactions``. Please instead use :attr:`telegram.constants.Nanostar.VALUE`. +* Remove deprecated attributes ``constants.StarTransactionsLimit.NANOSTAR_MIN_AMOUNT`` and ``constants.StarTransactionsLimit.NANOSTAR_MAX_AMOUNT``. Please instead use :attr:`telegram.constants.NanostarLimit.MIN_AMOUNT` and :attr:`telegram.constants.NanostarLimit.MAX_AMOUNT`. +""" +[[pull_requests]] +uid = "4852" +author_uid = "aelkheir" +closes_threads = [] diff --git a/changes/22.3_2025-07-20/4855.8hCFRFMeMaRWpBEYaxrTMq.toml b/changes/22.3_2025-07-20/4855.8hCFRFMeMaRWpBEYaxrTMq.toml new file mode 100644 index 00000000000..cbbda59a270 --- /dev/null +++ b/changes/22.3_2025-07-20/4855.8hCFRFMeMaRWpBEYaxrTMq.toml @@ -0,0 +1,5 @@ +other= "Make Gender Input Case-Insensitive in ``conversationbot.py``" +[[pull_requests]] +uid = "4855" +author_uid = "fengxiaohu" +closes_threads = ["4846"] diff --git a/changes/22.3_2025-07-20/4858.ajt46xDsbfzFqcghJ2rP6g.toml b/changes/22.3_2025-07-20/4858.ajt46xDsbfzFqcghJ2rP6g.toml new file mode 100644 index 00000000000..54620eebed2 --- /dev/null +++ b/changes/22.3_2025-07-20/4858.ajt46xDsbfzFqcghJ2rP6g.toml @@ -0,0 +1,5 @@ +internal = "Bump `pre-commit` Hooks to Latest Versions" +[[pull_requests]] +uid = "4858" +author_uid = "pre-commit-ci" +closes_threads = [] diff --git a/changes/22.3_2025-07-20/4870.QeVo4BE5TNouLRJjCFbRMo.toml b/changes/22.3_2025-07-20/4870.QeVo4BE5TNouLRJjCFbRMo.toml new file mode 100644 index 00000000000..fe24e83ca2d --- /dev/null +++ b/changes/22.3_2025-07-20/4870.QeVo4BE5TNouLRJjCFbRMo.toml @@ -0,0 +1,5 @@ +other = "Bump Version to v22.3" +[[pull_requests]] +uid = "4870" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4869.7GK56NC89XswBFUCiWXtqc.toml b/changes/22.4_2025-09-13/4869.7GK56NC89XswBFUCiWXtqc.toml new file mode 100644 index 00000000000..c02dd235421 --- /dev/null +++ b/changes/22.4_2025-09-13/4869.7GK56NC89XswBFUCiWXtqc.toml @@ -0,0 +1,5 @@ +features = "Extend :meth:`telegram.Message.delete` shortcut to support business message deletion" +[[pull_requests]] +uid = "4869" +author_uid = "jainamoswal" +closes_threads = ["4867"] diff --git a/changes/22.4_2025-09-13/4875.29srP6fENz7FrGGoWSkPNa.toml b/changes/22.4_2025-09-13/4875.29srP6fENz7FrGGoWSkPNa.toml new file mode 100644 index 00000000000..ed9df36be08 --- /dev/null +++ b/changes/22.4_2025-09-13/4875.29srP6fENz7FrGGoWSkPNa.toml @@ -0,0 +1,5 @@ +bugfixes = "Adapt logic on getting the event loop in ``Application.run_polling/webhook`` to Python 3.14" +[[pull_requests]] +uid = "4875" +author_uid = "harshil21" +closes_threads = ["4874"] diff --git a/changes/22.4_2025-09-13/4878.AbGo6aozTWquUjnzzpSdyX.toml b/changes/22.4_2025-09-13/4878.AbGo6aozTWquUjnzzpSdyX.toml new file mode 100644 index 00000000000..81e41dc7b98 --- /dev/null +++ b/changes/22.4_2025-09-13/4878.AbGo6aozTWquUjnzzpSdyX.toml @@ -0,0 +1,9 @@ +documentation = "Documentation Improvements" + +[[pull_requests]] +uid = "4878" +author_uid = "Bibo-Joshi" + +[[pull_requests]] +uid = "4872" +author_uid = "Ca5parAD" diff --git a/changes/22.4_2025-09-13/4879.kABAi45KpR2H6jqJu6NtDS.toml b/changes/22.4_2025-09-13/4879.kABAi45KpR2H6jqJu6NtDS.toml new file mode 100644 index 00000000000..f1fcabc5453 --- /dev/null +++ b/changes/22.4_2025-09-13/4879.kABAi45KpR2H6jqJu6NtDS.toml @@ -0,0 +1,5 @@ +internal = "Address Failing Unit Test for ``send_paid_media``" + +[[pull_requests]] +uid = "4879" +author_uid = "Bibo-Joshi" diff --git a/changes/22.4_2025-09-13/4880.42PW26hGpLypMJMxTeiQZ6.toml b/changes/22.4_2025-09-13/4880.42PW26hGpLypMJMxTeiQZ6.toml new file mode 100644 index 00000000000..fdcc0952a37 --- /dev/null +++ b/changes/22.4_2025-09-13/4880.42PW26hGpLypMJMxTeiQZ6.toml @@ -0,0 +1,6 @@ +internal = "Improve Internal Logic for Network Retries" + +[[pull_requests]] +uid = "4880" +author_uid = "Bibo-Joshi" +closes_threads = ["4871"] diff --git a/changes/22.4_2025-09-13/4881.dJNT656MVxJk9DgcoRePip.toml b/changes/22.4_2025-09-13/4881.dJNT656MVxJk9DgcoRePip.toml new file mode 100644 index 00000000000..6f331b1e4fe --- /dev/null +++ b/changes/22.4_2025-09-13/4881.dJNT656MVxJk9DgcoRePip.toml @@ -0,0 +1,6 @@ +features = "Add convenience properties for ``firstname``, ``lastname``, and ``username`` to ``SharedUser`` and ``ChatShared``" +internal = "Introduce utility module ``_utils.usernames`` refactoring convenience properties around Telegram Objects' ``firstname``, ``lastname``, and ``username``" +pull_requests = [ + { uid = "4881", author_uid = "aelkheir" }, + { uid = "4713", author_uid = "david-shiko" }, +] diff --git a/changes/22.4_2025-09-13/4882.JpgQAHHnCponMKStbC4JsG.toml b/changes/22.4_2025-09-13/4882.JpgQAHHnCponMKStbC4JsG.toml new file mode 100644 index 00000000000..f47875d9dc3 --- /dev/null +++ b/changes/22.4_2025-09-13/4882.JpgQAHHnCponMKStbC4JsG.toml @@ -0,0 +1,10 @@ +other = """ +Set the default connection pool size for ``HTTPXRequest`` to 256 to allow more concurrent requests by default. Drop the ``httpx`` parameter ``max_keepalive_connections``. This way, the ``httpx`` default of 20 is used, leading to a smaller number of idle connections in large connection pools. + +.. hint:: + If you manually build the ``HTTPXRequest`` objects, please be aware that these changes also applies to you. Kindly double check your settings. To specify custom limits, you can set them via the parameter ``httpx_kwargs`` of ``HTTPXRequest``. See also `the httpx documentation `__ for more details on these settings." +""" +[[pull_requests]] +uid = "4882" +author_uid = "Poolitzer" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4884.ADcdCj6GrMaKffYSLzWdsL.toml b/changes/22.4_2025-09-13/4884.ADcdCj6GrMaKffYSLzWdsL.toml new file mode 100644 index 00000000000..fa0d9fe3ecb --- /dev/null +++ b/changes/22.4_2025-09-13/4884.ADcdCj6GrMaKffYSLzWdsL.toml @@ -0,0 +1,5 @@ +internal = "Add Copilot Instructions and Setup Steps" +[[pull_requests]] +uid = "4884" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4886.XSHPk83uhDQdYzurbb4xx9.toml b/changes/22.4_2025-09-13/4886.XSHPk83uhDQdYzurbb4xx9.toml new file mode 100644 index 00000000000..25cf57c755c --- /dev/null +++ b/changes/22.4_2025-09-13/4886.XSHPk83uhDQdYzurbb4xx9.toml @@ -0,0 +1,5 @@ +internal = "Remove ``black``, ``isort``, ``flake8``, and ``pyupgrade`` in favor of ``ruff``" +[[pull_requests]] +uid = "4886" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4887.5yypYZV7Sq5PN4ihmf2NUr.toml b/changes/22.4_2025-09-13/4887.5yypYZV7Sq5PN4ihmf2NUr.toml new file mode 100644 index 00000000000..335d6755af8 --- /dev/null +++ b/changes/22.4_2025-09-13/4887.5yypYZV7Sq5PN4ihmf2NUr.toml @@ -0,0 +1,5 @@ +internal = "Use Renovate to Keep Dependencies Up-To-Date" +[[pull_requests]] +uid = "4887" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4890.oCzELerGo8dqPygShWMV9S.toml b/changes/22.4_2025-09-13/4890.oCzELerGo8dqPygShWMV9S.toml new file mode 100644 index 00000000000..6048f32bb9b --- /dev/null +++ b/changes/22.4_2025-09-13/4890.oCzELerGo8dqPygShWMV9S.toml @@ -0,0 +1,5 @@ +internal = "Add and use a ``uv.lock`` lockfile when setting up the development environment using ``uv``." +[[pull_requests]] +uid = "4890" +author_uid = "harshil21" +closes_threads = ["4796"] diff --git a/changes/22.4_2025-09-13/4892.iRGvURLUGiMSMEDfdpP5jB.toml b/changes/22.4_2025-09-13/4892.iRGvURLUGiMSMEDfdpP5jB.toml new file mode 100644 index 00000000000..a7b25052e53 --- /dev/null +++ b/changes/22.4_2025-09-13/4892.iRGvURLUGiMSMEDfdpP5jB.toml @@ -0,0 +1,5 @@ +internal = "Bump ``pytest`` from 8.4.0 to 8.4.1" +[[pull_requests]] +uid = "4892" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4893.Fbkdqj4bmTTH6e54gRPwvc.toml b/changes/22.4_2025-09-13/4893.Fbkdqj4bmTTH6e54gRPwvc.toml new file mode 100644 index 00000000000..af83a93db66 --- /dev/null +++ b/changes/22.4_2025-09-13/4893.Fbkdqj4bmTTH6e54gRPwvc.toml @@ -0,0 +1,5 @@ +internal = "Bump ``pytest-xdist`` from 3.6.1 to 3.8.0" +[[pull_requests]] +uid = "4893" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4894.PLQSCgnKXygrGdkekTbx8q.toml b/changes/22.4_2025-09-13/4894.PLQSCgnKXygrGdkekTbx8q.toml new file mode 100644 index 00000000000..7a12066430d --- /dev/null +++ b/changes/22.4_2025-09-13/4894.PLQSCgnKXygrGdkekTbx8q.toml @@ -0,0 +1,5 @@ +documentation = "Bump ``furo`` from 2024.8.6 to 2025.7.19" +[[pull_requests]] +uid = "4894" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4895.UECAN369MCuQaLnTmtYFGr.toml b/changes/22.4_2025-09-13/4895.UECAN369MCuQaLnTmtYFGr.toml new file mode 100644 index 00000000000..13b8b8da8ac --- /dev/null +++ b/changes/22.4_2025-09-13/4895.UECAN369MCuQaLnTmtYFGr.toml @@ -0,0 +1,5 @@ +internal = "Bump ``astral-sh/setup-uv`` from 6.3.1 to 6.4.3" +[[pull_requests]] +uid = "4895" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4896.R92Xq7W6PDbabuk6aZ5FZt.toml b/changes/22.4_2025-09-13/4896.R92Xq7W6PDbabuk6aZ5FZt.toml new file mode 100644 index 00000000000..0536ace20a8 --- /dev/null +++ b/changes/22.4_2025-09-13/4896.R92Xq7W6PDbabuk6aZ5FZt.toml @@ -0,0 +1,5 @@ +internal = "Bump ``github/codeql-action`` from 3.29.2 to 3.29.5" +[[pull_requests]] +uid = "4896" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4906.Fju7LDJgwCz2sxgQy9muBG.toml b/changes/22.4_2025-09-13/4906.Fju7LDJgwCz2sxgQy9muBG.toml new file mode 100644 index 00000000000..584d132930b --- /dev/null +++ b/changes/22.4_2025-09-13/4906.Fju7LDJgwCz2sxgQy9muBG.toml @@ -0,0 +1,5 @@ +features = "Add ``filters.FORUM`` to filter messages from forum topic chats" +[[pull_requests]] +uid = "4906" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4908.KnwujH2JruNRZSrTTvvc9B.toml b/changes/22.4_2025-09-13/4908.KnwujH2JruNRZSrTTvvc9B.toml new file mode 100644 index 00000000000..a8359bfc2e4 --- /dev/null +++ b/changes/22.4_2025-09-13/4908.KnwujH2JruNRZSrTTvvc9B.toml @@ -0,0 +1,5 @@ +bugfixes = "Fix ``ResourceWarning`` when passing ``pathlib.Path`` objects to methods which accept file input" +[[pull_requests]] +uid = "4908" +author_uid = "harshil21" +closes_threads = ["4907"] diff --git a/changes/22.4_2025-09-13/4911.kiF45Y4cfPGMq5cuLpa5da.toml b/changes/22.4_2025-09-13/4911.kiF45Y4cfPGMq5cuLpa5da.toml new file mode 100644 index 00000000000..9b7a7cc4080 --- /dev/null +++ b/changes/22.4_2025-09-13/4911.kiF45Y4cfPGMq5cuLpa5da.toml @@ -0,0 +1,14 @@ +features = "Full Support for Bot API 9.2" + +pull_requests = [ + { uid = "4911", author_uid = "aelkheir", closes_threads = ["4910"] }, + { uid = "4918", author_uid = "Poolitzer" }, + { uid = "4917", author_uid = "Poolitzer" }, + { uid = "4914", author_uid = "harshil21"}, + { uid = "4916", author_uid = "harshil21"}, + { uid = "4912", author_uid = "aelkheir" }, + { uid = "4921", author_uid = "aelkheir" }, + { uid = "4936", author_uid = "Bibo-Joshi" }, + { uid = "4935", author_uid = "Bibo-Joshi" }, + { uid = "4931", author_uid = "aelkheir" }, +] diff --git a/changes/22.4_2025-09-13/4915.D3x6MoyYYDaUcB7jmLUyeY.toml b/changes/22.4_2025-09-13/4915.D3x6MoyYYDaUcB7jmLUyeY.toml new file mode 100644 index 00000000000..a38d8d3facd --- /dev/null +++ b/changes/22.4_2025-09-13/4915.D3x6MoyYYDaUcB7jmLUyeY.toml @@ -0,0 +1,5 @@ +internal = "Don't update ``uv.lock`` in copilot runtime environment" +[[pull_requests]] +uid = "4915" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4923.AEztvspxU4Ajz7N3ms56Wq.toml b/changes/22.4_2025-09-13/4923.AEztvspxU4Ajz7N3ms56Wq.toml new file mode 100644 index 00000000000..6c7db1a97af --- /dev/null +++ b/changes/22.4_2025-09-13/4923.AEztvspxU4Ajz7N3ms56Wq.toml @@ -0,0 +1,5 @@ +dependencies = "Update cachetools requirement from <6.2.0,>=5.3.3 to >=5.3.3,<6.3.0" +[[pull_requests]] +uid = "4923" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4925.FCwe9rrjgv5GmfvuhHXjHg.toml b/changes/22.4_2025-09-13/4925.FCwe9rrjgv5GmfvuhHXjHg.toml new file mode 100644 index 00000000000..90a0991a591 --- /dev/null +++ b/changes/22.4_2025-09-13/4925.FCwe9rrjgv5GmfvuhHXjHg.toml @@ -0,0 +1,5 @@ +internal = "Bump actions/checkout from 4.2.2 to 5.0.0" +[[pull_requests]] +uid = "4925" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4926.GMcTfT2hSAdmdT7wi73mzZ.toml b/changes/22.4_2025-09-13/4926.GMcTfT2hSAdmdT7wi73mzZ.toml new file mode 100644 index 00000000000..36b95d96367 --- /dev/null +++ b/changes/22.4_2025-09-13/4926.GMcTfT2hSAdmdT7wi73mzZ.toml @@ -0,0 +1,5 @@ +internal = "Bump codecov/codecov-action from 5.4.3 to 5.5.0" +[[pull_requests]] +uid = "4926" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4927.mHtL79KfGaRw3LEMdHN6Ry.toml b/changes/22.4_2025-09-13/4927.mHtL79KfGaRw3LEMdHN6Ry.toml new file mode 100644 index 00000000000..2489cd6e31b --- /dev/null +++ b/changes/22.4_2025-09-13/4927.mHtL79KfGaRw3LEMdHN6Ry.toml @@ -0,0 +1,5 @@ +internal = "Bump actions/download-artifact from 4.3.0 to 5.0.0" +[[pull_requests]] +uid = "4927" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4928.ej4TvW3TjhqhpUHNyne2r4.toml b/changes/22.4_2025-09-13/4928.ej4TvW3TjhqhpUHNyne2r4.toml new file mode 100644 index 00000000000..44e3dea0b67 --- /dev/null +++ b/changes/22.4_2025-09-13/4928.ej4TvW3TjhqhpUHNyne2r4.toml @@ -0,0 +1,5 @@ +internal = "Bump astral-sh/setup-uv from 6.4.3 to 6.6.1" +[[pull_requests]] +uid = "4928" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4929.P2dSYTjRtQtXPdKyaDHPJA.toml b/changes/22.4_2025-09-13/4929.P2dSYTjRtQtXPdKyaDHPJA.toml new file mode 100644 index 00000000000..28cec83f04c --- /dev/null +++ b/changes/22.4_2025-09-13/4929.P2dSYTjRtQtXPdKyaDHPJA.toml @@ -0,0 +1,5 @@ +internal = "Bump github/codeql-action from 3.29.7 to 3.30.0" +[[pull_requests]] +uid = "4929" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4933.CPGgVg7gvHC3dqmhzAVDVo.toml b/changes/22.4_2025-09-13/4933.CPGgVg7gvHC3dqmhzAVDVo.toml new file mode 100644 index 00000000000..ce9877c8e2c --- /dev/null +++ b/changes/22.4_2025-09-13/4933.CPGgVg7gvHC3dqmhzAVDVo.toml @@ -0,0 +1,5 @@ +internal = "Bump pytest from 8.4.1 to 8.4.2" +[[pull_requests]] +uid = "4933" +author_uid = "dependabot" +closes_threads = [] diff --git a/changes/22.4_2025-09-13/4934.W4nUZkUHd9Hw4mGaLA6Ayf.toml b/changes/22.4_2025-09-13/4934.W4nUZkUHd9Hw4mGaLA6Ayf.toml new file mode 100644 index 00000000000..49fb8d8afd8 --- /dev/null +++ b/changes/22.4_2025-09-13/4934.W4nUZkUHd9Hw4mGaLA6Ayf.toml @@ -0,0 +1,5 @@ +internal = "Use Tagged Release of `pydantic` in Development Dependencies" +[[pull_requests]] +uid = "4934" +author_uid = "harshil21" +closes_threads = ["4932"] diff --git a/changes/22.4_2025-09-13/4939.YDNzLiFegKD2difT6yr6P7.toml b/changes/22.4_2025-09-13/4939.YDNzLiFegKD2difT6yr6P7.toml new file mode 100644 index 00000000000..1bb6b630184 --- /dev/null +++ b/changes/22.4_2025-09-13/4939.YDNzLiFegKD2difT6yr6P7.toml @@ -0,0 +1,5 @@ +other = "Bump Version to v22.4" +[[pull_requests]] +uid = "4939" +author_uid = "Bibo-Joshi" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4861.HEoGVs2mYXWzqMahi6SEhV.toml b/changes/22.5_2025-09-27/4861.HEoGVs2mYXWzqMahi6SEhV.toml new file mode 100644 index 00000000000..5d1f2a0833f --- /dev/null +++ b/changes/22.5_2025-09-27/4861.HEoGVs2mYXWzqMahi6SEhV.toml @@ -0,0 +1,10 @@ +features = """ +Add convenience methods for ``BusinessOpeningHours``: + +* ``get_opening_hours_for_day``: returns the opening hours applicable for a specific date +* ``is_open``: indicates whether the business is open at the specified date and time. +""" +[[pull_requests]] +uid = "4861" +author_uid = "Aweryc" +closes_threads = ["4194"] diff --git a/changes/22.5_2025-09-27/4938.U2x6X7ZGikYv5eo9aUSNGc.toml b/changes/22.5_2025-09-27/4938.U2x6X7ZGikYv5eo9aUSNGc.toml new file mode 100644 index 00000000000..1f16b7393bc --- /dev/null +++ b/changes/22.5_2025-09-27/4938.U2x6X7ZGikYv5eo9aUSNGc.toml @@ -0,0 +1,5 @@ +internal = "Lock file maintenance" +[[pull_requests]] +uid = "4938" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4943.DdNTkLjQbNGeG3nEBGHjBo.toml b/changes/22.5_2025-09-27/4943.DdNTkLjQbNGeG3nEBGHjBo.toml new file mode 100644 index 00000000000..aad26c94270 --- /dev/null +++ b/changes/22.5_2025-09-27/4943.DdNTkLjQbNGeG3nEBGHjBo.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv digest to b75a909" +[[pull_requests]] +uid = "4943" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4944.mYorcTojrvwihYQ5NPwNcE.toml b/changes/22.5_2025-09-27/4944.mYorcTojrvwihYQ5NPwNcE.toml new file mode 100644 index 00000000000..e215535b055 --- /dev/null +++ b/changes/22.5_2025-09-27/4944.mYorcTojrvwihYQ5NPwNcE.toml @@ -0,0 +1,5 @@ +internal = "Update codecov/codecov-action action to v5.5.1" +[[pull_requests]] +uid = "4944" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4945.LDiTvYjbXuQBp7kLyQMYa5.toml b/changes/22.5_2025-09-27/4945.LDiTvYjbXuQBp7kLyQMYa5.toml new file mode 100644 index 00000000000..5e6ac6c4a0a --- /dev/null +++ b/changes/22.5_2025-09-27/4945.LDiTvYjbXuQBp7kLyQMYa5.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.8.17" +[[pull_requests]] +uid = "4945" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4946.KujJLUNcND72Y8aKs3bzSx.toml b/changes/22.5_2025-09-27/4946.KujJLUNcND72Y8aKs3bzSx.toml new file mode 100644 index 00000000000..cf4fd1b38b3 --- /dev/null +++ b/changes/22.5_2025-09-27/4946.KujJLUNcND72Y8aKs3bzSx.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v3.30.3" +[[pull_requests]] +uid = "4946" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4947.CcLd8YTb8XaANn8gQXruNb.toml b/changes/22.5_2025-09-27/4947.CcLd8YTb8XaANn8gQXruNb.toml new file mode 100644 index 00000000000..165ab8886df --- /dev/null +++ b/changes/22.5_2025-09-27/4947.CcLd8YTb8XaANn8gQXruNb.toml @@ -0,0 +1,5 @@ +internal = "Update Pylint to v3.3.8" +[[pull_requests]] +uid = "4947" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4948.ernZikrNEarFtTZgLN7huS.toml b/changes/22.5_2025-09-27/4948.ernZikrNEarFtTZgLN7huS.toml new file mode 100644 index 00000000000..80611fd62c2 --- /dev/null +++ b/changes/22.5_2025-09-27/4948.ernZikrNEarFtTZgLN7huS.toml @@ -0,0 +1,5 @@ +internal = "Update Chango to v0.5.0" +[[pull_requests]] +uid = "4948" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4949.YhMnQj57Qpm9HD5zkkhfZC.toml b/changes/22.5_2025-09-27/4949.YhMnQj57Qpm9HD5zkkhfZC.toml new file mode 100644 index 00000000000..4fb4495e923 --- /dev/null +++ b/changes/22.5_2025-09-27/4949.YhMnQj57Qpm9HD5zkkhfZC.toml @@ -0,0 +1,5 @@ +internal = "Update Mypy to v1.18.1" +[[pull_requests]] +uid = "4949" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4950.VA9pgTVrLPABkUrxifLZnh.toml b/changes/22.5_2025-09-27/4950.VA9pgTVrLPABkUrxifLZnh.toml new file mode 100644 index 00000000000..74a5ce57e9f --- /dev/null +++ b/changes/22.5_2025-09-27/4950.VA9pgTVrLPABkUrxifLZnh.toml @@ -0,0 +1,5 @@ +internal = "Align pre-commit hook APScheduler to with ``pyproject.toml``" +[[pull_requests]] +uid = "4950" +author_uids = ["renovatebot", "Bibo-Joshi"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4951.7ZExRZuEwEoGXX5FMPWctm.toml b/changes/22.5_2025-09-27/4951.7ZExRZuEwEoGXX5FMPWctm.toml new file mode 100644 index 00000000000..f94dc7df9bf --- /dev/null +++ b/changes/22.5_2025-09-27/4951.7ZExRZuEwEoGXX5FMPWctm.toml @@ -0,0 +1,5 @@ +internal = "Align pre-commit hook cachetools to with ``pyproject.toml``" +[[pull_requests]] +uid = "4951" +author_uids = ["renovatebot", "Bibo-Joshi"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4952.bVCE8YehtKGcwLYdiXkmQb.toml b/changes/22.5_2025-09-27/4952.bVCE8YehtKGcwLYdiXkmQb.toml new file mode 100644 index 00000000000..668a0c24cff --- /dev/null +++ b/changes/22.5_2025-09-27/4952.bVCE8YehtKGcwLYdiXkmQb.toml @@ -0,0 +1,5 @@ +internal = "Update pypa/gh-action-pypi-publish action to v1.13.0" +[[pull_requests]] +uid = "4952" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4953.8aPTEQhSPSgTrguzvsw4tq.toml b/changes/22.5_2025-09-27/4953.8aPTEQhSPSgTrguzvsw4tq.toml new file mode 100644 index 00000000000..a06988837a4 --- /dev/null +++ b/changes/22.5_2025-09-27/4953.8aPTEQhSPSgTrguzvsw4tq.toml @@ -0,0 +1,5 @@ +internal = "Renovate: No README updates, label behaviour change, automerge lockfiles" +[[pull_requests]] +uid = "4953" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4954.XrYn8gzjeQF9LoPQomutuW.toml b/changes/22.5_2025-09-27/4954.XrYn8gzjeQF9LoPQomutuW.toml new file mode 100644 index 00000000000..8b0fe1e271f --- /dev/null +++ b/changes/22.5_2025-09-27/4954.XrYn8gzjeQF9LoPQomutuW.toml @@ -0,0 +1,5 @@ +internal = "Lock file maintenance" +[[pull_requests]] +uid = "4954" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4955.2BXmasa9Ms9vqXngSMKSVX.toml b/changes/22.5_2025-09-27/4955.2BXmasa9Ms9vqXngSMKSVX.toml new file mode 100644 index 00000000000..649c61623af --- /dev/null +++ b/changes/22.5_2025-09-27/4955.2BXmasa9Ms9vqXngSMKSVX.toml @@ -0,0 +1,5 @@ +internal = "Lock file maintenance" +[[pull_requests]] +uid = "4955" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4958.ktVtuEvzAxxFxSwk7KXQ66.toml b/changes/22.5_2025-09-27/4958.ktVtuEvzAxxFxSwk7KXQ66.toml new file mode 100644 index 00000000000..9ab6aa44b95 --- /dev/null +++ b/changes/22.5_2025-09-27/4958.ktVtuEvzAxxFxSwk7KXQ66.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv digest to 208b0c0" +[[pull_requests]] +uid = "4958" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4959.H2BCKBh9WXaoFKmwiMX4m7.toml b/changes/22.5_2025-09-27/4959.H2BCKBh9WXaoFKmwiMX4m7.toml new file mode 100644 index 00000000000..dfdfd84c0d2 --- /dev/null +++ b/changes/22.5_2025-09-27/4959.H2BCKBh9WXaoFKmwiMX4m7.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.8.19" +[[pull_requests]] +uid = "4959" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4960.KyngeakT6uHLQXYftm2Cpe.toml b/changes/22.5_2025-09-27/4960.KyngeakT6uHLQXYftm2Cpe.toml new file mode 100644 index 00000000000..14b8add118b --- /dev/null +++ b/changes/22.5_2025-09-27/4960.KyngeakT6uHLQXYftm2Cpe.toml @@ -0,0 +1,5 @@ +internal = "Update Mypy to v1.18.2" +[[pull_requests]] +uid = "4960" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4961.nd2KdJpSCyLC3K5xninxZL.toml b/changes/22.5_2025-09-27/4961.nd2KdJpSCyLC3K5xninxZL.toml new file mode 100644 index 00000000000..d404ca538f9 --- /dev/null +++ b/changes/22.5_2025-09-27/4961.nd2KdJpSCyLC3K5xninxZL.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v6.7.0" +[[pull_requests]] +uid = "4961" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4962.mZAGRv4sXnpirJCkrKniVJ.toml b/changes/22.5_2025-09-27/4962.mZAGRv4sXnpirJCkrKniVJ.toml new file mode 100644 index 00000000000..6f26eb9a9ce --- /dev/null +++ b/changes/22.5_2025-09-27/4962.mZAGRv4sXnpirJCkrKniVJ.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.13.1" +[[pull_requests]] +uid = "4962" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4964.KbKLtQ5MYHdt49gpBxCaja.toml b/changes/22.5_2025-09-27/4964.KbKLtQ5MYHdt49gpBxCaja.toml new file mode 100644 index 00000000000..bd0179d21a1 --- /dev/null +++ b/changes/22.5_2025-09-27/4964.KbKLtQ5MYHdt49gpBxCaja.toml @@ -0,0 +1,5 @@ +internal = "Update actions/stale action to v10" +[[pull_requests]] +uid = "4964" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4965.488hEhfeZfPvfZszvRKdkJ.toml b/changes/22.5_2025-09-27/4965.488hEhfeZfPvfZszvRKdkJ.toml new file mode 100644 index 00000000000..e94bb08a3d9 --- /dev/null +++ b/changes/22.5_2025-09-27/4965.488hEhfeZfPvfZszvRKdkJ.toml @@ -0,0 +1,5 @@ +internal = "Properly Pin Dependency to ``astral/setup-uv`` in Copilot Setup Steps" +[[pull_requests]] +uid = "4965" +author_uids = ["Bibo-Joshi"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4967.a9vCqXXpyAfzTb8TD7ouqB.toml b/changes/22.5_2025-09-27/4967.a9vCqXXpyAfzTb8TD7ouqB.toml new file mode 100644 index 00000000000..7c2046ffe37 --- /dev/null +++ b/changes/22.5_2025-09-27/4967.a9vCqXXpyAfzTb8TD7ouqB.toml @@ -0,0 +1,5 @@ +internal = "Lock file maintenance" +[[pull_requests]] +uid = "4967" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4968.ecxiPBroDpMUkab6uBCUde.toml b/changes/22.5_2025-09-27/4968.ecxiPBroDpMUkab6uBCUde.toml new file mode 100644 index 00000000000..6b961885c0d --- /dev/null +++ b/changes/22.5_2025-09-27/4968.ecxiPBroDpMUkab6uBCUde.toml @@ -0,0 +1,5 @@ +internal = "Tune Renovate Configuration" +[[pull_requests]] +uid = "4968" +author_uids = ["harshil21"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4972.RX4QftpcFPCv2wXbTAhDAh.toml b/changes/22.5_2025-09-27/4972.RX4QftpcFPCv2wXbTAhDAh.toml new file mode 100644 index 00000000000..4fca643fbd5 --- /dev/null +++ b/changes/22.5_2025-09-27/4972.RX4QftpcFPCv2wXbTAhDAh.toml @@ -0,0 +1,10 @@ +breaking = """Move param ``ReplyParameters.checklist_task_id`` to last position. + +.. hint:: + This change addresses a breaking change accidentally introduced in version 22.4 where the introduction of the new parameter was not done in a backward compatible way. + Existing code using keyword arguments is unaffected. Only code using positional arguments (and based on version 22.4) may need updates. +""" +[[pull_requests]] +uid = "4972" +author_uids = ["aelkheir"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4973.PtSpAPsm7wh4PWc4p3uajX.toml b/changes/22.5_2025-09-27/4973.PtSpAPsm7wh4PWc4p3uajX.toml new file mode 100644 index 00000000000..4c518bff806 --- /dev/null +++ b/changes/22.5_2025-09-27/4973.PtSpAPsm7wh4PWc4p3uajX.toml @@ -0,0 +1,5 @@ +bugfixes = "Fix Handling of Infinite Bootstrap Retries in ``Application.run_*`` and ``Updater.start_*``" +[[pull_requests]] +uid = "4973" +author_uids = ["Bibo-Joshi"] +closes_threads = ["4966"] diff --git a/changes/22.5_2025-09-27/4974.c4UDqA4q7eBr9mYGmJL4CQ.toml b/changes/22.5_2025-09-27/4974.c4UDqA4q7eBr9mYGmJL4CQ.toml new file mode 100644 index 00000000000..11e5e4980ee --- /dev/null +++ b/changes/22.5_2025-09-27/4974.c4UDqA4q7eBr9mYGmJL4CQ.toml @@ -0,0 +1,4 @@ +documentation = "Documentation Improvemennts" +[[pull_requests]] +uid = "4974" +author_uids = ["Bibo-Joshi"] diff --git a/changes/22.5_2025-09-27/4975.cxvpDwNRvRzK9aZxRbav6b.toml b/changes/22.5_2025-09-27/4975.cxvpDwNRvRzK9aZxRbav6b.toml new file mode 100644 index 00000000000..3bd6d50e5cc --- /dev/null +++ b/changes/22.5_2025-09-27/4975.cxvpDwNRvRzK9aZxRbav6b.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.8.22" +[[pull_requests]] +uid = "4975" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4976.GVUSXmvHEopzdff7bVCNF4.toml b/changes/22.5_2025-09-27/4976.GVUSXmvHEopzdff7bVCNF4.toml new file mode 100644 index 00000000000..d02dddb97e7 --- /dev/null +++ b/changes/22.5_2025-09-27/4976.GVUSXmvHEopzdff7bVCNF4.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v3.30.5" +[[pull_requests]] +uid = "4976" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4977.YYYUcDY7oSNQ9FXUGZq7sp.toml b/changes/22.5_2025-09-27/4977.YYYUcDY7oSNQ9FXUGZq7sp.toml new file mode 100644 index 00000000000..a99f05f73bb --- /dev/null +++ b/changes/22.5_2025-09-27/4977.YYYUcDY7oSNQ9FXUGZq7sp.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.13.2" +[[pull_requests]] +uid = "4977" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4978.9hhbtgMWDjm7BUNaNozjoV.toml b/changes/22.5_2025-09-27/4978.9hhbtgMWDjm7BUNaNozjoV.toml new file mode 100644 index 00000000000..0f52602a8cb --- /dev/null +++ b/changes/22.5_2025-09-27/4978.9hhbtgMWDjm7BUNaNozjoV.toml @@ -0,0 +1,5 @@ +internal = "Update dependency furo to v2025.9.25" +[[pull_requests]] +uid = "4978" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.5_2025-09-27/4979.gRpn8vd66DtorTtkMN4AsU.toml b/changes/22.5_2025-09-27/4979.gRpn8vd66DtorTtkMN4AsU.toml new file mode 100644 index 00000000000..ead5a1dbbd3 --- /dev/null +++ b/changes/22.5_2025-09-27/4979.gRpn8vd66DtorTtkMN4AsU.toml @@ -0,0 +1,5 @@ +other = "Bump Version to v22.5" +[[pull_requests]] +uid = "4979" +author_uids = ["Bibo-Joshi"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4827.PBXyEEjvXz5sJbiDWkeGFX.toml b/changes/22.6_2026-01-24/4827.PBXyEEjvXz5sJbiDWkeGFX.toml new file mode 100644 index 00000000000..89da3b9b9b1 --- /dev/null +++ b/changes/22.6_2026-01-24/4827.PBXyEEjvXz5sJbiDWkeGFX.toml @@ -0,0 +1,5 @@ +other = "Remove Support for Python 3.9" +[[pull_requests]] +uid = "4827" +author_uid = "harshil21" +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4980.86EojeXUgUSi87JKMZojGD.toml b/changes/22.6_2026-01-24/4980.86EojeXUgUSi87JKMZojGD.toml new file mode 100644 index 00000000000..92f328ae495 --- /dev/null +++ b/changes/22.6_2026-01-24/4980.86EojeXUgUSi87JKMZojGD.toml @@ -0,0 +1,5 @@ +internal = "Lock file maintenance" +[[pull_requests]] +uid = "4980" +author_uid = "renovatebot" +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4984.XKkS99HJWztHscs6N6wpEh.toml b/changes/22.6_2026-01-24/4984.XKkS99HJWztHscs6N6wpEh.toml new file mode 100644 index 00000000000..d2733305d2f --- /dev/null +++ b/changes/22.6_2026-01-24/4984.XKkS99HJWztHscs6N6wpEh.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v3.30.6" +[[pull_requests]] +uid = "4984" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4985.LrPNHbexoHAmim6zXB9nMp.toml b/changes/22.6_2026-01-24/4985.LrPNHbexoHAmim6zXB9nMp.toml new file mode 100644 index 00000000000..c8fa893b9b2 --- /dev/null +++ b/changes/22.6_2026-01-24/4985.LrPNHbexoHAmim6zXB9nMp.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.13.3" +[[pull_requests]] +uid = "4985" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4986.fwbZif2YKWgeugPof8Fikj.toml b/changes/22.6_2026-01-24/4986.fwbZif2YKWgeugPof8Fikj.toml new file mode 100644 index 00000000000..517db07fe1d --- /dev/null +++ b/changes/22.6_2026-01-24/4986.fwbZif2YKWgeugPof8Fikj.toml @@ -0,0 +1,5 @@ +internal = "Update actions/stale action to v10.1.0" +[[pull_requests]] +uid = "4986" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4987.PLoqzvYm725uVfKcWhwuCb.toml b/changes/22.6_2026-01-24/4987.PLoqzvYm725uVfKcWhwuCb.toml new file mode 100644 index 00000000000..a628db0ae0a --- /dev/null +++ b/changes/22.6_2026-01-24/4987.PLoqzvYm725uVfKcWhwuCb.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v6.8.0" +[[pull_requests]] +uid = "4987" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4988.Lncn8TNDcQF4hLQZJvyzQY.toml b/changes/22.6_2026-01-24/4988.Lncn8TNDcQF4hLQZJvyzQY.toml new file mode 100644 index 00000000000..0e56db89db9 --- /dev/null +++ b/changes/22.6_2026-01-24/4988.Lncn8TNDcQF4hLQZJvyzQY.toml @@ -0,0 +1,5 @@ +documentation = "Documentation Improvements. Among others, fix dead links." + +[[pull_requests]] +uid = "4988" +author_uids = ["Bibo-Joshi"] diff --git a/changes/22.6_2026-01-24/4989.TjTuqb5LqcEXDLvArDzcEY.toml b/changes/22.6_2026-01-24/4989.TjTuqb5LqcEXDLvArDzcEY.toml new file mode 100644 index 00000000000..b75b4f63bf1 --- /dev/null +++ b/changes/22.6_2026-01-24/4989.TjTuqb5LqcEXDLvArDzcEY.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.8.23" +[[pull_requests]] +uid = "4989" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4990.HxHP2zqbTAcGzjapkgX3E5.toml b/changes/22.6_2026-01-24/4990.HxHP2zqbTAcGzjapkgX3E5.toml new file mode 100644 index 00000000000..99b4a0fecbb --- /dev/null +++ b/changes/22.6_2026-01-24/4990.HxHP2zqbTAcGzjapkgX3E5.toml @@ -0,0 +1,5 @@ +internal = "Update configuration of actions/stale to use issue types instead of labels" + +[[pull_requests]] +uid = "4990" +author_uids = ["Bibo-Joshi"] diff --git a/changes/22.6_2026-01-24/4994.K8XVzJe4kuoHmc2yRsrYTb.toml b/changes/22.6_2026-01-24/4994.K8XVzJe4kuoHmc2yRsrYTb.toml new file mode 100644 index 00000000000..fccd71fdc7d --- /dev/null +++ b/changes/22.6_2026-01-24/4994.K8XVzJe4kuoHmc2yRsrYTb.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v3.30.8" +[[pull_requests]] +uid = "4994" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4995.TGRtuG8BUqB6S9JxpPxiNR.toml b/changes/22.6_2026-01-24/4995.TGRtuG8BUqB6S9JxpPxiNR.toml new file mode 100644 index 00000000000..6201c93f14d --- /dev/null +++ b/changes/22.6_2026-01-24/4995.TGRtuG8BUqB6S9JxpPxiNR.toml @@ -0,0 +1,5 @@ +internal = "Update Pylint to v3.3.9" +[[pull_requests]] +uid = "4995" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4996.bRG2iuMsWDR47wDt556q57.toml b/changes/22.6_2026-01-24/4996.bRG2iuMsWDR47wDt556q57.toml new file mode 100644 index 00000000000..0db147afd39 --- /dev/null +++ b/changes/22.6_2026-01-24/4996.bRG2iuMsWDR47wDt556q57.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.9.2" +[[pull_requests]] +uid = "4996" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4997.hJRqz3id5FnzSzPeMhY3k6.toml b/changes/22.6_2026-01-24/4997.hJRqz3id5FnzSzPeMhY3k6.toml new file mode 100644 index 00000000000..6798caa22df --- /dev/null +++ b/changes/22.6_2026-01-24/4997.hJRqz3id5FnzSzPeMhY3k6.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.0" +[[pull_requests]] +uid = "4997" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4998.jvTA7u2hEA7xwa87XoUqec.toml b/changes/22.6_2026-01-24/4998.jvTA7u2hEA7xwa87XoUqec.toml new file mode 100644 index 00000000000..ac897e504fc --- /dev/null +++ b/changes/22.6_2026-01-24/4998.jvTA7u2hEA7xwa87XoUqec.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v7" +[[pull_requests]] +uid = "4998" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/4999.NGTC66pJdAhjtoLykBsxQc.toml b/changes/22.6_2026-01-24/4999.NGTC66pJdAhjtoLykBsxQc.toml new file mode 100644 index 00000000000..b2a5c245a32 --- /dev/null +++ b/changes/22.6_2026-01-24/4999.NGTC66pJdAhjtoLykBsxQc.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v4" +[[pull_requests]] +uid = "4999" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5000.A5P7pNDw2Bc5Uaq3psVEWZ.toml b/changes/22.6_2026-01-24/5000.A5P7pNDw2Bc5Uaq3psVEWZ.toml new file mode 100644 index 00000000000..650d5627bba --- /dev/null +++ b/changes/22.6_2026-01-24/5000.A5P7pNDw2Bc5Uaq3psVEWZ.toml @@ -0,0 +1,5 @@ +internal = "Update Pylint to v4 (major)" +[[pull_requests]] +uid = "5000" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5001.DXjSVgk4h3o8WoPTy8Jpng.toml b/changes/22.6_2026-01-24/5001.DXjSVgk4h3o8WoPTy8Jpng.toml new file mode 100644 index 00000000000..6099fcbd870 --- /dev/null +++ b/changes/22.6_2026-01-24/5001.DXjSVgk4h3o8WoPTy8Jpng.toml @@ -0,0 +1,5 @@ +internal = "Update stefanzweifel/git-auto-commit-action action to v7" +[[pull_requests]] +uid = "5001" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5002.cube9Ku57EajUDF8xoSdfV.toml b/changes/22.6_2026-01-24/5002.cube9Ku57EajUDF8xoSdfV.toml new file mode 100644 index 00000000000..5508daf4ef6 --- /dev/null +++ b/changes/22.6_2026-01-24/5002.cube9Ku57EajUDF8xoSdfV.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v7.1.0" +[[pull_requests]] +uid = "5002" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5004.k7NzG5uTwQxcHP6shbogHL.toml b/changes/22.6_2026-01-24/5004.k7NzG5uTwQxcHP6shbogHL.toml new file mode 100644 index 00000000000..cbe56a74ee6 --- /dev/null +++ b/changes/22.6_2026-01-24/5004.k7NzG5uTwQxcHP6shbogHL.toml @@ -0,0 +1,5 @@ +internal = "Use Python 3.14 Final in the Test Suite" +[[pull_requests]] +uid = "5004" +author_uids = ["harshil21"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5006.hRJtYPL4nBeZ43DWqii68V.toml b/changes/22.6_2026-01-24/5006.hRJtYPL4nBeZ43DWqii68V.toml new file mode 100644 index 00000000000..8bf90cfcb20 --- /dev/null +++ b/changes/22.6_2026-01-24/5006.hRJtYPL4nBeZ43DWqii68V.toml @@ -0,0 +1,5 @@ +internal = "Add Freethreaded Python 3.14 to the Test Suite" +[[pull_requests]] +uid = "5006" +author_uids = ["harshil21"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5007.ANT5x4yXn667UnjSHsCSUb.toml b/changes/22.6_2026-01-24/5007.ANT5x4yXn667UnjSHsCSUb.toml new file mode 100644 index 00000000000..90cec876e36 --- /dev/null +++ b/changes/22.6_2026-01-24/5007.ANT5x4yXn667UnjSHsCSUb.toml @@ -0,0 +1,5 @@ +internal = "Make ``chango`` Commit Re-Trigger Workflows" +[[pull_requests]] +uid = "5007" +author_uids = ["Bibo-Joshi"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5008.4XBBoZAXkrtsuHRxPqAW9V.toml b/changes/22.6_2026-01-24/5008.4XBBoZAXkrtsuHRxPqAW9V.toml new file mode 100644 index 00000000000..16bfab5b754 --- /dev/null +++ b/changes/22.6_2026-01-24/5008.4XBBoZAXkrtsuHRxPqAW9V.toml @@ -0,0 +1,4 @@ +internal = "Temporarily disable ``actions/stale`` due to a bug with ``only-issue-types``." +[[pull_requests]] +uid = "5008" +author_uids = ["Bibo-Joshi"] diff --git a/changes/22.6_2026-01-24/5009.QuigkwxAs8bemB9gnKbXhz.toml b/changes/22.6_2026-01-24/5009.QuigkwxAs8bemB9gnKbXhz.toml new file mode 100644 index 00000000000..ec095e6e6ff --- /dev/null +++ b/changes/22.6_2026-01-24/5009.QuigkwxAs8bemB9gnKbXhz.toml @@ -0,0 +1,4 @@ +internal = "Re-Enable ``actions/stale`` Workflow" +[[pull_requests]] +uid = "5009" +author_uids = ["Bibo-Joshi"] diff --git a/changes/22.6_2026-01-24/5011.PSJLmKo5LhUXjPbnGbZ3bn.toml b/changes/22.6_2026-01-24/5011.PSJLmKo5LhUXjPbnGbZ3bn.toml new file mode 100644 index 00000000000..f2f254b2029 --- /dev/null +++ b/changes/22.6_2026-01-24/5011.PSJLmKo5LhUXjPbnGbZ3bn.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.9.3" +[[pull_requests]] +uid = "5011" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5012.CBCzSf2FaoALedVnSyExqD.toml b/changes/22.6_2026-01-24/5012.CBCzSf2FaoALedVnSyExqD.toml new file mode 100644 index 00000000000..935e16c628f --- /dev/null +++ b/changes/22.6_2026-01-24/5012.CBCzSf2FaoALedVnSyExqD.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v4.30.9" +[[pull_requests]] +uid = "5012" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5013.FrUE8G2c4xicUxiTj4Drfn.toml b/changes/22.6_2026-01-24/5013.FrUE8G2c4xicUxiTj4Drfn.toml new file mode 100644 index 00000000000..8869abbf953 --- /dev/null +++ b/changes/22.6_2026-01-24/5013.FrUE8G2c4xicUxiTj4Drfn.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.1" +[[pull_requests]] +uid = "5013" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5014.MzMmSLFoUDeCV6JeJNuL2H.toml b/changes/22.6_2026-01-24/5014.MzMmSLFoUDeCV6JeJNuL2H.toml new file mode 100644 index 00000000000..6b53759235d --- /dev/null +++ b/changes/22.6_2026-01-24/5014.MzMmSLFoUDeCV6JeJNuL2H.toml @@ -0,0 +1,5 @@ +internal = "Update dependency chango to ~=0.6.0" +[[pull_requests]] +uid = "5014" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5015.6PQeFxBMxfEdw82aAqoRSy.toml b/changes/22.6_2026-01-24/5015.6PQeFxBMxfEdw82aAqoRSy.toml new file mode 100644 index 00000000000..93deeec0825 --- /dev/null +++ b/changes/22.6_2026-01-24/5015.6PQeFxBMxfEdw82aAqoRSy.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.9.4" +[[pull_requests]] +uid = "5015" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5016.bmfLnKrJ932h5QGKWs9kke.toml b/changes/22.6_2026-01-24/5016.bmfLnKrJ932h5QGKWs9kke.toml new file mode 100644 index 00000000000..598a48a0b9d --- /dev/null +++ b/changes/22.6_2026-01-24/5016.bmfLnKrJ932h5QGKWs9kke.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v7.1.1" +[[pull_requests]] +uid = "5016" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5022.Kz9z6826xjnfjXhXTaJhGE.toml b/changes/22.6_2026-01-24/5022.Kz9z6826xjnfjXhXTaJhGE.toml new file mode 100644 index 00000000000..eddecbfce4a --- /dev/null +++ b/changes/22.6_2026-01-24/5022.Kz9z6826xjnfjXhXTaJhGE.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.9.5" +[[pull_requests]] +uid = "5022" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5023.Zeup3tNhhr2F6M5KHxcj5f.toml b/changes/22.6_2026-01-24/5023.Zeup3tNhhr2F6M5KHxcj5f.toml new file mode 100644 index 00000000000..0ff91ad48c8 --- /dev/null +++ b/changes/22.6_2026-01-24/5023.Zeup3tNhhr2F6M5KHxcj5f.toml @@ -0,0 +1,5 @@ +internal = "Update Pylint to v4.0.2" +[[pull_requests]] +uid = "5023" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5024.8UrFV8RpA74s8VaD4FFwaZ.toml b/changes/22.6_2026-01-24/5024.8UrFV8RpA74s8VaD4FFwaZ.toml new file mode 100644 index 00000000000..349cd8b7f8d --- /dev/null +++ b/changes/22.6_2026-01-24/5024.8UrFV8RpA74s8VaD4FFwaZ.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.2" +[[pull_requests]] +uid = "5024" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5025.Zzz5pN2Rmu8DZGtKFzi7wx.toml b/changes/22.6_2026-01-24/5025.Zzz5pN2Rmu8DZGtKFzi7wx.toml new file mode 100644 index 00000000000..88743c7eba7 --- /dev/null +++ b/changes/22.6_2026-01-24/5025.Zzz5pN2Rmu8DZGtKFzi7wx.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v4.31.0" +[[pull_requests]] +uid = "5025" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5026.UgTNGQ3nckNfhFJrRYYznN.toml b/changes/22.6_2026-01-24/5026.UgTNGQ3nckNfhFJrRYYznN.toml new file mode 100644 index 00000000000..513808c72f8 --- /dev/null +++ b/changes/22.6_2026-01-24/5026.UgTNGQ3nckNfhFJrRYYznN.toml @@ -0,0 +1,5 @@ +internal = "Update sigstore/gh-action-sigstore-python action to v3.1.0" +[[pull_requests]] +uid = "5026" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5027.2ostaGWzx3TtWpQACbgDLs.toml b/changes/22.6_2026-01-24/5027.2ostaGWzx3TtWpQACbgDLs.toml new file mode 100644 index 00000000000..4b67d6032d0 --- /dev/null +++ b/changes/22.6_2026-01-24/5027.2ostaGWzx3TtWpQACbgDLs.toml @@ -0,0 +1,5 @@ +internal = "Update GitHub Artifact Actions (major)" +[[pull_requests]] +uid = "5027" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5029.8qJNkyaEhhMckJjNZSyUMT.toml b/changes/22.6_2026-01-24/5029.8qJNkyaEhhMckJjNZSyUMT.toml new file mode 100644 index 00000000000..0784d21c71f --- /dev/null +++ b/changes/22.6_2026-01-24/5029.8qJNkyaEhhMckJjNZSyUMT.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v7.1.2" +[[pull_requests]] +uid = "5029" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5030.bBRC3598kUYHL2Qpo4gjMb.toml b/changes/22.6_2026-01-24/5030.bBRC3598kUYHL2Qpo4gjMb.toml new file mode 100644 index 00000000000..f68fec4d04f --- /dev/null +++ b/changes/22.6_2026-01-24/5030.bBRC3598kUYHL2Qpo4gjMb.toml @@ -0,0 +1,5 @@ +bugfixes = "Fix a Bug in Initialization Logic of ``Bot``" +[[pull_requests]] +uid = "5030" +author_uids = ["codomposer"] +closes_threads = ["5021"] \ No newline at end of file diff --git a/changes/22.6_2026-01-24/5032.gJe5x9EV6iFcEFDy5ov3Qf.toml b/changes/22.6_2026-01-24/5032.gJe5x9EV6iFcEFDy5ov3Qf.toml new file mode 100644 index 00000000000..f5fde2fdcaf --- /dev/null +++ b/changes/22.6_2026-01-24/5032.gJe5x9EV6iFcEFDy5ov3Qf.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.9.7" +[[pull_requests]] +uid = "5032" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5033.JX2YjsVeznxTBqZF5yPyHw.toml b/changes/22.6_2026-01-24/5033.JX2YjsVeznxTBqZF5yPyHw.toml new file mode 100644 index 00000000000..ef44d75c370 --- /dev/null +++ b/changes/22.6_2026-01-24/5033.JX2YjsVeznxTBqZF5yPyHw.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v4.31.2" +[[pull_requests]] +uid = "5033" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5034.TboLf7ySS68mHXaoDkQLJa.toml b/changes/22.6_2026-01-24/5034.TboLf7ySS68mHXaoDkQLJa.toml new file mode 100644 index 00000000000..f5704ed428b --- /dev/null +++ b/changes/22.6_2026-01-24/5034.TboLf7ySS68mHXaoDkQLJa.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.3" +[[pull_requests]] +uid = "5034" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5035.LvduDeof5HBLViwSE3D7Tj.toml b/changes/22.6_2026-01-24/5035.LvduDeof5HBLViwSE3D7Tj.toml new file mode 100644 index 00000000000..eb17b8cfc40 --- /dev/null +++ b/changes/22.6_2026-01-24/5035.LvduDeof5HBLViwSE3D7Tj.toml @@ -0,0 +1,5 @@ +internal = "Lock file maintenance" +[[pull_requests]] +uid = "5035" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5040.fdwnytzmSECx9qsps5YQ8p.toml b/changes/22.6_2026-01-24/5040.fdwnytzmSECx9qsps5YQ8p.toml new file mode 100644 index 00000000000..bcd8853abf2 --- /dev/null +++ b/changes/22.6_2026-01-24/5040.fdwnytzmSECx9qsps5YQ8p.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.9.8" +[[pull_requests]] +uid = "5040" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5041.KvQgtUQd4LsSVxifJurtCa.toml b/changes/22.6_2026-01-24/5041.KvQgtUQd4LsSVxifJurtCa.toml new file mode 100644 index 00000000000..76867b04eb2 --- /dev/null +++ b/changes/22.6_2026-01-24/5041.KvQgtUQd4LsSVxifJurtCa.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.4" +[[pull_requests]] +uid = "5041" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5042.EsubhGfoUGEXXD3iYhpscb.toml b/changes/22.6_2026-01-24/5042.EsubhGfoUGEXXD3iYhpscb.toml new file mode 100644 index 00000000000..6cea2946657 --- /dev/null +++ b/changes/22.6_2026-01-24/5042.EsubhGfoUGEXXD3iYhpscb.toml @@ -0,0 +1,5 @@ +internal = "Update dependency pytest to v9" +[[pull_requests]] +uid = "5042" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5044.QdtCmSghVutBX7hdFxy39Y.toml b/changes/22.6_2026-01-24/5044.QdtCmSghVutBX7hdFxy39Y.toml new file mode 100644 index 00000000000..58eac75ac7b --- /dev/null +++ b/changes/22.6_2026-01-24/5044.QdtCmSghVutBX7hdFxy39Y.toml @@ -0,0 +1,4 @@ +internal = "Reduce Frequence of Renovate Updates for Development Dependencies" +[[pull_requests]] +uid = "5044" +author_uids = ["Bibo-Joshi"] diff --git a/changes/22.6_2026-01-24/5048.GCKPwc255mjcVTXBemz9PS.toml b/changes/22.6_2026-01-24/5048.GCKPwc255mjcVTXBemz9PS.toml new file mode 100644 index 00000000000..f143cd5f468 --- /dev/null +++ b/changes/22.6_2026-01-24/5048.GCKPwc255mjcVTXBemz9PS.toml @@ -0,0 +1,5 @@ +internal = "Update Pylint to v4.0.3" +[[pull_requests]] +uid = "5048" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5049.S5gFYpudshPwYzoLYVJQM4.toml b/changes/22.6_2026-01-24/5049.S5gFYpudshPwYzoLYVJQM4.toml new file mode 100644 index 00000000000..2e3e40e5532 --- /dev/null +++ b/changes/22.6_2026-01-24/5049.S5gFYpudshPwYzoLYVJQM4.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.5" +[[pull_requests]] +uid = "5049" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5050.V7Svc2KJrD2Rpn3YnDuYoi.toml b/changes/22.6_2026-01-24/5050.V7Svc2KJrD2Rpn3YnDuYoi.toml new file mode 100644 index 00000000000..a6dffd3174f --- /dev/null +++ b/changes/22.6_2026-01-24/5050.V7Svc2KJrD2Rpn3YnDuYoi.toml @@ -0,0 +1,4 @@ +internal = "Stabilize some unit tests" +[[pull_requests]] +uid = "5050" +author_uids = ["Bibo-Joshi"] diff --git a/changes/22.6_2026-01-24/5055.B8q4kJCbfLU8km5JAGSFZj.toml b/changes/22.6_2026-01-24/5055.B8q4kJCbfLU8km5JAGSFZj.toml new file mode 100644 index 00000000000..67f0e3bf8cb --- /dev/null +++ b/changes/22.6_2026-01-24/5055.B8q4kJCbfLU8km5JAGSFZj.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.6" +[[pull_requests]] +uid = "5055" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5056.XnhzpGSAsyoXMGS5mV47Ec.toml b/changes/22.6_2026-01-24/5056.XnhzpGSAsyoXMGS5mV47Ec.toml new file mode 100644 index 00000000000..6f956c20d44 --- /dev/null +++ b/changes/22.6_2026-01-24/5056.XnhzpGSAsyoXMGS5mV47Ec.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.7" +[[pull_requests]] +uid = "5056" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5057.F3hY56ZMSYAUszphSqQHiK.toml b/changes/22.6_2026-01-24/5057.F3hY56ZMSYAUszphSqQHiK.toml new file mode 100644 index 00000000000..1b29b4c38d0 --- /dev/null +++ b/changes/22.6_2026-01-24/5057.F3hY56ZMSYAUszphSqQHiK.toml @@ -0,0 +1,5 @@ +internal = "Update Pylint to v4.0.4" +[[pull_requests]] +uid = "5057" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5058.28oHRt4BwX6yeg2LRMaCXQ.toml b/changes/22.6_2026-01-24/5058.28oHRt4BwX6yeg2LRMaCXQ.toml new file mode 100644 index 00000000000..54281e298f0 --- /dev/null +++ b/changes/22.6_2026-01-24/5058.28oHRt4BwX6yeg2LRMaCXQ.toml @@ -0,0 +1,5 @@ +internal = "Update actions/checkout action to v5.0.1" +[[pull_requests]] +uid = "5058" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5059.4hDeRNcVVVgJkN8fV2ahxJ.toml b/changes/22.6_2026-01-24/5059.4hDeRNcVVVgJkN8fV2ahxJ.toml new file mode 100644 index 00000000000..6ed692df4e2 --- /dev/null +++ b/changes/22.6_2026-01-24/5059.4hDeRNcVVVgJkN8fV2ahxJ.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v7.1.4" +[[pull_requests]] +uid = "5059" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5060.3thn8ynSueq4pZEDBTTs52.toml b/changes/22.6_2026-01-24/5060.3thn8ynSueq4pZEDBTTs52.toml new file mode 100644 index 00000000000..cf28803797c --- /dev/null +++ b/changes/22.6_2026-01-24/5060.3thn8ynSueq4pZEDBTTs52.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.9.13" +[[pull_requests]] +uid = "5060" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5061.FmvsKgVUc4Rgb9avL4Wmws.toml b/changes/22.6_2026-01-24/5061.FmvsKgVUc4Rgb9avL4Wmws.toml new file mode 100644 index 00000000000..b73558049e5 --- /dev/null +++ b/changes/22.6_2026-01-24/5061.FmvsKgVUc4Rgb9avL4Wmws.toml @@ -0,0 +1,5 @@ +internal = "Update dependency pytest to v9.0.1" +[[pull_requests]] +uid = "5061" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5062.T5v63ioaphzU2yjAkvQnz2.toml b/changes/22.6_2026-01-24/5062.T5v63ioaphzU2yjAkvQnz2.toml new file mode 100644 index 00000000000..81e3103ef1c --- /dev/null +++ b/changes/22.6_2026-01-24/5062.T5v63ioaphzU2yjAkvQnz2.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v4.31.5" +[[pull_requests]] +uid = "5062" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5067.V47rAvnekJ8nizGaGfXKbb.toml b/changes/22.6_2026-01-24/5067.V47rAvnekJ8nizGaGfXKbb.toml new file mode 100644 index 00000000000..38b7361238e --- /dev/null +++ b/changes/22.6_2026-01-24/5067.V47rAvnekJ8nizGaGfXKbb.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.8" +[[pull_requests]] +uid = "5067" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5068.3Z2v29SeC8j3oB7kDNcpEd.toml b/changes/22.6_2026-01-24/5068.3Z2v29SeC8j3oB7kDNcpEd.toml new file mode 100644 index 00000000000..903c934d1c0 --- /dev/null +++ b/changes/22.6_2026-01-24/5068.3Z2v29SeC8j3oB7kDNcpEd.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.9" +[[pull_requests]] +uid = "5068" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5070.guYXqNjxEqyktC3ndMsdqc.toml b/changes/22.6_2026-01-24/5070.guYXqNjxEqyktC3ndMsdqc.toml new file mode 100644 index 00000000000..bee861e3e40 --- /dev/null +++ b/changes/22.6_2026-01-24/5070.guYXqNjxEqyktC3ndMsdqc.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.10" +[[pull_requests]] +uid = "5070" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5075.YxKmAd2uSaR5xJZG6Zf6h9.toml b/changes/22.6_2026-01-24/5075.YxKmAd2uSaR5xJZG6Zf6h9.toml new file mode 100644 index 00000000000..9b291ca9076 --- /dev/null +++ b/changes/22.6_2026-01-24/5075.YxKmAd2uSaR5xJZG6Zf6h9.toml @@ -0,0 +1,5 @@ +documentation = "Update Copyright to 2026" + +[[pull_requests]] +uid = "5075" +author_uids = ["Bibo-Joshi"] diff --git a/changes/22.6_2026-01-24/5078.FoNwUYLbXQFRebTFhR6UPn.toml b/changes/22.6_2026-01-24/5078.FoNwUYLbXQFRebTFhR6UPn.toml new file mode 100644 index 00000000000..7cc4071b32f --- /dev/null +++ b/changes/22.6_2026-01-24/5078.FoNwUYLbXQFRebTFhR6UPn.toml @@ -0,0 +1,28 @@ +features = """ +Full Support for Bot API 9.3 + +.. warning:: + + - Bot API 9.3 deprecates the field ``last_resale_star_count`` of ``UniqueGiftInfo`` in favor of the new fields ``last_resale_currency`` and ``last_resale_amount``. The field ``last_resale_star_count`` is still present in PTB for backward compatibility, but it will be removed in future releases. Please update your code accordingly. + + - Bot API 9.3 deprecates the argument ``exclude_limited`` of ``Bot.get_business_account_gifts`` in favor of the new arguments ``exclude_limited_upgradable`` and ``exclude_limited_non_upgradable``. The argument ``exclude_limited`` is still present in PTB for backward compatibility, but it will be removed in future releases. Please update your code accordingly. + + - Bot API 9.3 introduces a now required argument ``gift_id`` to ``UniqueGift``. For backward compatibility, the argument is currently still marked as optional in the signature and it's presence is enforced through a runtime check. In future versions, this argument will be made required in the signature as well. + Please make sure to update your code accordingly to avoid potential issues in the future. We recommend using keyword arguments to ensure compatibility with future updates. +""" + +pull_requests = [ + { uid = "5086", author_uids = ["Bibo-Joshi"] }, + { uid = "5078", author_uid = "aelkheir", closes_threads = ["5077"] }, + { uid = "5079", author_uid = "aelkheir" }, + { uid = "5084", author_uids = ["Bibo-Joshi"] }, + { uid = "5085", author_uids = ["Bibo-Joshi"] }, + { uid = "5087", author_uids = ["Bibo-Joshi"] }, + { uid = "5091", author_uids = ["Bibo-Joshi"] }, + { uid = "5090", author_uids = ["Bibo-Joshi"] }, + { uid = "5089", author_uids = ["Bibo-Joshi"] }, + { uid = "5092", author_uids = ["Bibo-Joshi"] }, + { uid = "5095", author_uids = ["Bibo-Joshi"] }, + { uid = "5094", author_uids = ["Bibo-Joshi"] }, + { uid = "5106", author_uid = ["aelkheir"] }, +] diff --git a/changes/22.6_2026-01-24/5080.BYez3ivHiHpRDEjnsCStKM.toml b/changes/22.6_2026-01-24/5080.BYez3ivHiHpRDEjnsCStKM.toml new file mode 100644 index 00000000000..cc7fa90a9e0 --- /dev/null +++ b/changes/22.6_2026-01-24/5080.BYez3ivHiHpRDEjnsCStKM.toml @@ -0,0 +1,5 @@ +documentation = "Fix Broken Links in Documentation" +[[pull_requests]] +uid = "5080" +author_uids = ["calm329"] +closes_threads = ["5074"] \ No newline at end of file diff --git a/changes/22.6_2026-01-24/5083.MebfmAm8GVX9To4ZWxeU4h.toml b/changes/22.6_2026-01-24/5083.MebfmAm8GVX9To4ZWxeU4h.toml new file mode 100644 index 00000000000..40e5ec22381 --- /dev/null +++ b/changes/22.6_2026-01-24/5083.MebfmAm8GVX9To4ZWxeU4h.toml @@ -0,0 +1,4 @@ +internal = "Bump ``pre-commit`` Hooks to Latest VErsion" +[[pull_requests]] +uid = "5083" +author_uids = ["pre-commit-ci", "Bibo-Joshi"] diff --git a/changes/22.6_2026-01-24/5088.dbco4MBy5FHuS4qawHww3M.toml b/changes/22.6_2026-01-24/5088.dbco4MBy5FHuS4qawHww3M.toml new file mode 100644 index 00000000000..011a85bb884 --- /dev/null +++ b/changes/22.6_2026-01-24/5088.dbco4MBy5FHuS4qawHww3M.toml @@ -0,0 +1,6 @@ +features = "Add Fallback Name Support for Job Callbacks without ``__name__``" +[[pull_requests]] +uid = "5088" +author_uids = ["SmartDever02"] +closes_threads = ["4992"] + diff --git a/changes/22.6_2026-01-24/5100.NnTUgrxZibXT6N3SAw28aH.toml b/changes/22.6_2026-01-24/5100.NnTUgrxZibXT6N3SAw28aH.toml new file mode 100644 index 00000000000..1fc7a5a8d3d --- /dev/null +++ b/changes/22.6_2026-01-24/5100.NnTUgrxZibXT6N3SAw28aH.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.11" +[[pull_requests]] +uid = "5100" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5101.SUFGhSSTbqovDSZpVF2LYi.toml b/changes/22.6_2026-01-24/5101.SUFGhSSTbqovDSZpVF2LYi.toml new file mode 100644 index 00000000000..72fc4fbb979 --- /dev/null +++ b/changes/22.6_2026-01-24/5101.SUFGhSSTbqovDSZpVF2LYi.toml @@ -0,0 +1,5 @@ +internal = "Bump ``chango`` to 0.6.1" +[[pull_requests]] +uid = "5101" +author_uids = ["Bibo-Joshi"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5107.dkBZXpsYuApxXCiLSPkeZR.toml b/changes/22.6_2026-01-24/5107.dkBZXpsYuApxXCiLSPkeZR.toml new file mode 100644 index 00000000000..2a3c9d780a9 --- /dev/null +++ b/changes/22.6_2026-01-24/5107.dkBZXpsYuApxXCiLSPkeZR.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.13" +[[pull_requests]] +uid = "5107" +author_uids = ["renovatebot"] +closes_threads = [] diff --git a/changes/22.6_2026-01-24/5108.CUt49oxmPxDZ3BPhDDCAm5.toml b/changes/22.6_2026-01-24/5108.CUt49oxmPxDZ3BPhDDCAm5.toml new file mode 100644 index 00000000000..fb69e538612 --- /dev/null +++ b/changes/22.6_2026-01-24/5108.CUt49oxmPxDZ3BPhDDCAm5.toml @@ -0,0 +1,5 @@ +other = "Bump Version to v22.6" +[[pull_requests]] +uid = "5108" +author_uids = ["Bibo-Joshi"] +closes_threads = [] diff --git a/changes/LEGACY.rst b/changes/LEGACY.rst new file mode 100644 index 00000000000..81c4205cc29 --- /dev/null +++ b/changes/LEGACY.rst @@ -0,0 +1,2849 @@ +Version 21.11.1 +=============== + +*Released 2025-03-01* + +This is the technical changelog for version 21.11. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Documentation Improvements +-------------------------- + +- Fix ReadTheDocs Build (:pr:`4695`) + +Version 21.11 +============= + +*Released 2025-03-01* + +This is the technical changelog for version 21.11. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes and New Features +------------------------------ + +- Full Support for Bot API 8.3 (:pr:`4676` closes :issue:`4677`, :pr:`4682` by `aelkheir `_, :pr:`4690` by `aelkheir `_, :pr:`4691` by `aelkheir `_) +- Make ``provider_token`` Argument Optional (:pr:`4689`) +- Remove Deprecated ``InlineQueryResultArticle.hide_url`` (:pr:`4640` closes :issue:`4638`) +- Accept ``datetime.timedelta`` Input in ``Bot`` Method Parameters (:pr:`4651`) +- Extend Customization Support for ``Bot.base_(file_)url`` (:pr:`4632` closes :issue:`3355`) +- Support ``allow_paid_broadcast`` in ``AIORateLimiter`` (:pr:`4627` closes :issue:`4578`) +- Add ``BaseUpdateProcessor.current_concurrent_updates`` (:pr:`4626` closes :issue:`3984`) + +Minor Changes and Bug Fixes +--------------------------- + +- Add Bootstrapping Logic to ``Application.run_*`` (:pr:`4673` closes :issue:`4657`) +- Fix a Bug in ``edit_user_star_subscription`` (:pr:`4681` by `vavasik800 `_) +- Simplify Handling of Empty Data in ``TelegramObject.de_json`` and Friends (:pr:`4617` closes :issue:`4614`) + +Documentation Improvements +-------------------------- + +- Documentation Improvements (:pr:`4641`) +- Overhaul Admonition Insertion in Documentation (:pr:`4462` closes :issue:`4414`) + +Internal Changes +---------------- + +- Stabilize Linkcheck Test (:pr:`4693`) +- Bump ``pre-commit`` Hooks to Latest Versions (:pr:`4643`) +- Refactor Tests for ``TelegramObject`` Classes with Subclasses (:pr:`4654` closes :issue:`4652`) +- Use Fine Grained Permissions for GitHub Actions Workflows (:pr:`4668`) + +Dependency Updates +------------------ + +- Bump ``actions/setup-python`` from 5.3.0 to 5.4.0 (:pr:`4665`) +- Bump ``dependabot/fetch-metadata`` from 2.2.0 to 2.3.0 (:pr:`4666`) +- Bump ``actions/stale`` from 9.0.0 to 9.1.0 (:pr:`4667`) +- Bump ``astral-sh/setup-uv`` from 5.1.0 to 5.2.2 (:pr:`4664`) +- Bump ``codecov/test-results-action`` from 1.0.1 to 1.0.2 (:pr:`4663`) + +Version 21.10 +============= + +*Released 2025-01-03* + +This is the technical changelog for version 21.10. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- + +- Full Support for Bot API 8.2 (:pr:`4633`) +- Bump ``apscheduler`` & Deprecate ``pytz`` Support (:pr:`4582`) + +New Features +------------ +- Add Parameter ``pattern`` to ``JobQueue.jobs()`` (:pr:`4613` closes :issue:`4544`) +- Allow Input of Type ``Sticker`` for Several Methods (:pr:`4616` closes :issue:`4580`) + +Bug Fixes +--------- +- Ensure Forward Compatibility of ``Gift`` and ``Gifts`` (:pr:`4634` closes :issue:`4637`) + + +Documentation Improvements & Internal Changes +--------------------------------------------- + +- Use Custom Labels for ``dependabot`` PRs (:pr:`4621`) +- Remove Redundant ``pylint`` Suppressions (:pr:`4628`) +- Update Copyright to 2025 (:pr:`4631`) +- Refactor Module Structure and Tests for Star Payments Classes (:pr:`4615` closes :issue:`4593`) +- Unify ``datetime`` Imports (:pr:`4605` by `cuevasrja `_ closes :issue:`4577`) +- Add Static Security Analysis of GitHub Actions Workflows (:pr:`4606`) + +Dependency Updates +------------------ + +- Bump ``astral-sh/setup-uv`` from 4.2.0 to 5.1.0 (:pr:`4625`) +- Bump ``codecov/codecov-action`` from 5.1.1 to 5.1.2 (:pr:`4622`) +- Bump ``actions/upload-artifact`` from 4.4.3 to 4.5.0 (:pr:`4623`) +- Bump ``github/codeql-action`` from 3.27.9 to 3.28.0 (:pr:`4624`) + +Version 21.9 +============ + +*Released 2024-12-07* + +This is the technical changelog for version 21.9. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- + +- Full Support for Bot API 8.1 (:pr:`4594` closes :issue:`4592`) + +Minor Changes +------------- + +- Use ``MessageLimit.DEEP_LINK_LENGTH`` in ``helpers.create_deep_linked_url`` (:pr:`4597` by `nemacysts `_) +- Allow ``Sequence`` Input for ``allowed_updates`` in ``Application`` and ``Updater`` Methods (:pr:`4589` by `nemacysts `_) + +Dependency Updates +------------------ + +- Update ``aiolimiter`` requirement from ~=1.1.0 to >=1.1,<1.3 (:pr:`4595`) +- Bump ``pytest`` from 8.3.3 to 8.3.4 (:pr:`4596`) +- Bump ``codecov/codecov-action`` from 4 to 5 (:pr:`4585`) +- Bump ``pylint`` to v3.3.2 to Improve Python 3.13 Support (:pr:`4590` by `nemacysts `_) +- Bump ``srvaroa/labeler`` from 1.11.1 to 1.12.0 (:pr:`4586`) + +Version 21.8 +============ +*Released 2024-12-01* + +This is the technical changelog for version 21.8. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- + +- Full Support for Bot API 8.0 (:pr:`4568`, :pr:`4566` closes :issue:`4567`, :pr:`4572`, :pr:`4571`, :pr:`4570`, :pr:`4576`, :pr:`4574`) + +Documentation Improvements +-------------------------- + +- Documentation Improvements (:pr:`4565` by Snehashish06, :pr:`4573`) + +Version 21.7 +============ +*Released 2024-11-04* + +This is the technical changelog for version 21.7. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- + +- Full Support for Bot API 7.11 (:pr:`4546` closes :issue:`4543`) +- Add ``Message.reply_paid_media`` (:pr:`4551`) +- Drop Support for Python 3.8 (:pr:`4398` by `elpekenin `_) + +Minor Changes +------------- + +- Allow ``Sequence`` in ``Application.add_handlers`` (:pr:`4531` by `roast-lord `_ closes :issue:`4530`) +- Improve Exception Handling in ``File.download_*`` (:pr:`4542`) +- Use Stable Python 3.13 Release in Test Suite (:pr:`4535`) + +Documentation Improvements +-------------------------- + +- Documentation Improvements (:pr:`4536` by `Ecode2 `_, :pr:`4556`) +- Fix Linkcheck Workflow (:pr:`4545`) +- Use ``sphinx-build-compatibility`` to Keep Sphinx Compatibility (:pr:`4492`) + +Internal Changes +---------------- + +- Improve Test Instability Caused by ``Message`` Fixtures (:pr:`4507`) +- Stabilize Some Flaky Tests (:pr:`4500`) +- Reduce Creation of HTTP Clients in Tests (:pr:`4493`) +- Update ``pytest-xdist`` Usage (:pr:`4491`) +- Fix Failing Tests by Making Them Independent (:pr:`4494`) +- Introduce Codecov's Test Analysis (:pr:`4487`) +- Maintenance Work on ``Bot`` Tests (:pr:`4489`) +- Introduce ``conftest.py`` for File Related Tests (:pr:`4488`) +- Update Issue Templates to Use Issue Types (:pr:`4553`) +- Update Automation to Label Changes (:pr:`4552`) + +Dependency Updates +------------------ + +- Bump ``srvaroa/labeler`` from 1.11.0 to 1.11.1 (:pr:`4549`) +- Bump ``sphinx`` from 8.0.2 to 8.1.3 (:pr:`4532`) +- Bump ``sphinxcontrib-mermaid`` from 0.9.2 to 1.0.0 (:pr:`4529`) +- Bump ``srvaroa/labeler`` from 1.10.1 to 1.11.0 (:pr:`4509`) +- Bump ``Bibo-Joshi/pyright-type-completeness`` from 1.0.0 to 1.0.1 (:pr:`4510`) + +Version 21.6 +============ + +*Released 2024-09-19* + +This is the technical changelog for version 21.6. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +New Features +------------ + +- Full Support for Bot API 7.10 (:pr:`4461` closes :issue:`4459`, :pr:`4460`, :pr:`4463` by `aelkheir `_, :pr:`4464`) +- Add Parameter ``httpx_kwargs`` to ``HTTPXRequest`` (:pr:`4451` closes :issue:`4424`) + +Minor Changes +------------- + +- Improve Type Completeness (:pr:`4466`) + +Internal Changes +---------------- + +- Update Python 3.13 Test Suite to RC2 (:pr:`4471`) +- Enforce the ``offline_bot`` Fixture in ``Test*WithoutRequest`` (:pr:`4465`) +- Make Tests for ``telegram.ext`` Independent of Networking (:pr:`4454`) +- Rename Testing Base Classes (:pr:`4453`) + +Dependency Updates +------------------ + +- Bump ``pytest`` from 8.3.2 to 8.3.3 (:pr:`4475`) + +Version 21.5 +============ + +*Released 2024-09-01* + +This is the technical changelog for version 21.5. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- + +- Full Support for Bot API 7.9 (:pr:`4429`) +- Full Support for Bot API 7.8 (:pr:`4408`) + +New Features +------------ + +- Add ``MessageEntity.shift_entities`` and ``MessageEntity.concatenate`` (:pr:`4376` closes :issue:`4372`) +- Add Parameter ``game_pattern`` to ``CallbackQueryHandler`` (:pr:`4353` by `jainamoswal `_ closes :issue:`4269`) +- Add Parameter ``read_file_handle`` to ``InputFile`` (:pr:`4388` closes :issue:`4339`) + +Documentation Improvements +-------------------------- + +- Bugfix for "Available In" Admonitions (:pr:`4413`) +- Documentation Improvements (:pr:`4400` closes :issue:`4446`, :pr:`4448` by `Palaptin `_) +- Document Return Types of ``RequestData`` Members (:pr:`4396`) +- Add Introductory Paragraphs to Telegram Types Subsections (:pr:`4389` by `mohdyusuf2312 `_ closes :issue:`4380`) +- Start Adapting to RTD Addons (:pr:`4386`) + +Minor and Internal Changes +--------------------------- + +- Remove Surplus Logging from ``Updater`` Network Loop (:pr:`4432` by `MartinHjelmare `_) +- Add Internal Constants for Encodings (:pr:`4378` by `elpekenin `_) +- Improve PyPI Automation (:pr:`4375` closes :issue:`4373`) +- Update Test Suite to New Test Channel Setup (:pr:`4435`) +- Improve Fixture Usage in ``test_message.py`` (:pr:`4431` by `Palaptin `_) +- Update Python 3.13 Test Suite to RC1 (:pr:`4415`) +- Bump ``ruff`` and Add New Rules (:pr:`4416`) + +Dependency Updates +------------------ + +- Update ``cachetools`` requirement from <5.5.0,>=5.3.3 to >=5.3.3,<5.6.0 (:pr:`4437`) +- Bump ``sphinx`` from 7.4.7 to 8.0.2 and ``furo`` from 2024.7.18 to 2024.8.6 (:pr:`4412`) +- Bump ``test-summary/action`` from 2.3 to 2.4 (:pr:`4410`) +- Bump ``pytest`` from 8.2.2 to 8.3.2 (:pr:`4403`) +- Bump ``dependabot/fetch-metadata`` from 2.1.0 to 2.2.0 (:pr:`4411`) +- Update ``cachetools`` requirement from ~=5.3.3 to >=5.3.3,<5.5.0 (:pr:`4390`) +- Bump ``sphinx`` from 7.3.7 to 7.4.7 (:pr:`4395`) +- Bump ``furo`` from 2024.5.6 to 2024.7.18 (:pr:`4392`) + +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* + +This is the technical changelog for version 21.3. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- + +- Full Support for Bot API 7.4 (:pr:`4286`, :pr:`4276` closes :issue:`4275`, :pr:`4285`, :pr:`4283`, :pr:`4280`, :pr:`4278`, :pr:`4279`) +- Deprecate ``python-telegram-bot-raw`` (:pr:`4270`) +- Remove Functionality Deprecated in Bot API 7.3 (:pr:`4266` closes :issue:`4244`) + +New Features +------------ + +- Add Parameter ``chat_id`` to ``ChatMemberHandler`` (:pr:`4290` by `uniquetrij `_ closes :issue:`4287`) + +Documentation Improvements +-------------------------- + +- Documentation Improvements (:pr:`4264` closes :issue:`4240`) + +Internal Changes +---------------- + +- Add ``setuptools`` to ``requirements-dev.txt`` (:pr:`4282`) +- Update Settings for pre-commit.ci (:pr:`4265`) + +Dependency Updates +------------------ + +- Bump ``pytest`` from 8.2.0 to 8.2.1 (:pr:`4272`) + +Version 21.2 +============ + +*Released 2024-05-20* + +This is the technical changelog for version 21.2. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- + +- Full Support for Bot API 7.3 (:pr:`4246`, :pr:`4260`, :pr:`4243`, :pr:`4248`, :pr:`4242` closes :issue:`4236`, :pr:`4247` by `aelkheir `_) +- Remove Functionality Deprecated by Bot API 7.2 (:pr:`4245`) + +New Features +------------ + +- Add Version to ``PTBDeprecationWarning`` (:pr:`4262` closes :issue:`4261`) +- Handle Exceptions in building ``CallbackContext`` (:pr:`4222`) + +Bug Fixes +--------- + +- Call ``Application.post_stop`` Only if ``Application.stop`` was called (:pr:`4211` closes :issue:`4210`) +- Handle ``SystemExit`` raised in Handlers (:pr:`4157` closes :issue:`4155` and :issue:`4156`) +- Make ``Birthdate.to_date`` Return a ``datetime.date`` Object (:pr:`4251`) + +Documentation Improvements +-------------------------- + +- Documentation Improvements (:pr:`4217`) + +Internal Changes +---------------- + +- Add New Rules to ``ruff`` Config (:pr:`4250`) +- Adapt Test Suite to Changes in Error Messages (:pr:`4238`) + +Dependency Updates +------------------ + +- Bump ``furo`` from 2024.4.27 to 2024.5.6 (:pr:`4252`) +- ``pre-commit`` autoupdate (:pr:`4239`) +- Bump ``pytest`` from 8.1.1 to 8.2.0 (:pr:`4231`) +- Bump ``dependabot/fetch-metadata`` from 2.0.0 to 2.1.0 (:pr:`4228`) +- Bump ``pytest-asyncio`` from 0.21.1 to 0.21.2 (:pr:`4232`) +- Bump ``pytest-xdist`` from 3.6.0 to 3.6.1 (:pr:`4233`) +- Bump ``furo`` from 2024.1.29 to 2024.4.27 (:pr:`4230`) +- Bump ``srvaroa/labeler`` from 1.10.0 to 1.10.1 (:pr:`4227`) +- Bump ``pytest`` from 7.4.4 to 8.1.1 (:pr:`4218`) +- Bump ``sphinx`` from 7.2.6 to 7.3.7 (:pr:`4215`) +- Bump ``pytest-xdist`` from 3.5.0 to 3.6.0 (:pr:`4215`) + +Version 21.1.1 +============== + +*Released 2024-04-15* + +This is the technical changelog for version 21.1.1. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `__. + +Bug Fixes +--------- + +- Fix Bug With Parameter ``message_thread_id`` of ``Message.reply_*`` (:pr:`4207` closes :issue:`4205`) + +Minor Changes +------------- + +- Remove Deprecation Warning in ``JobQueue.run_daily`` (:pr:`4206` by `@Konano `__) +- Fix Annotation of ``EncryptedCredentials.decrypted_secret`` (:pr:`4199` by `@marinelay `__ closes :issue:`4198`) + + +Version 21.1 +============== + +*Released 2024-04-12* + +This is the technical changelog for version 21.1. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `__. + +Major Changes +------------- + +- API 7.2 (:pr:`4180` closes :issue:`4179` and :issue:`4181`, :issue:`4181`) +- Make ``ChatAdministratorRights/ChatMemberAdministrator.can_*_stories`` Required (API 7.1) (:pr:`4192`) + +Minor Changes +------------- + +- Refactor Debug logging in ``Bot`` to Improve Type Hinting (:pr:`4151` closes :issue:`4010`) + +New Features +------------ + +- Make ``Message.reply_*`` Reply in the Same Topic by Default (:pr:`4170` by `@aelkheir `__ closes :issue:`4139`) +- Accept Socket Objects for Webhooks (:pr:`4161` closes :issue:`4078`) +- Add ``Update.effective_sender`` (:pr:`4168` by `@aelkheir `__ closes :issue:`4085`) + +Documentation Improvements +-------------------------- + +- Documentation Improvements (:pr:`4171`, :pr:`4158` by `@teslaedison `__) + +Internal Changes +---------------- + +- Temporarily Mark Tests with ``get_sticker_set`` as XFAIL due to API 7.2 Update (:pr:`4190`) + +Dependency Updates +------------------ + +- ``pre-commit`` autoupdate (:pr:`4184`) +- Bump ``dependabot/fetch-metadata`` from 1.6.0 to 2.0.0 (:pr:`4185`) + + +Version 21.0.1 +============== + +*Released 2024-03-06* + +This is the technical changelog for version 21.0.1. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `__. + +Bug Fixes +--------- + +- Remove ``docs`` from Package (:pr:`4150`) + + +Version 21.0 +============ + +*Released 2024-03-06* + +This is the technical changelog for version 21.0. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `__. + +Major Changes +------------- + +- Remove Functionality Deprecated in API 7.0 (:pr:`4114` closes :issue:`4099`) +- API 7.1 (:pr:`4118`) + +New Features +------------ + +- Add Parameter ``media_write_timeout`` to ``HTTPXRequest`` and Method ``ApplicationBuilder.media_write_timeout`` (:pr:`4120` closes :issue:`3864`) +- Handle Properties in ``TelegramObject.__setstate__`` (:pr:`4134` closes :issue:`4111`) + +Bug Fixes +--------- + +- Add Missing Slot to ``Updater`` (:pr:`4130` closes :issue:`4127`) + +Documentation Improvements +-------------------------- + +- Improve HTML Download of Documentation (:pr:`4146` closes :issue:`4050`) +- Documentation Improvements (:pr:`4109`, :issue:`4116`) +- Update Copyright to 2024 (:pr:`4121` by `@aelkheir `__ closes :issue:`4041`) + +Internal Changes +---------------- + +- Apply ``pre-commit`` Checks More Widely (:pr:`4135`) +- Refactor and Overhaul ``test_official`` (:pr:`4087` closes :issue:`3874`) +- Run Unit Tests in PRs on Requirements Changes (:pr:`4144`) +- Make ``Updater.stop`` Independent of ``CancelledError`` (:pr:`4126`) + +Dependency Updates +------------------ + +- Relax Upper Bound for ``httpx`` Dependency (:pr:`4148`) +- Bump ``test-summary/action`` from 2.2 to 2.3 (:pr:`4142`) +- Update ``cachetools`` requirement from ~=5.3.2 to ~=5.3.3 (:pr:`4141`) +- Update ``httpx`` requirement from ~=0.26.0 to ~=0.27.0 (:pr:`4131`) + + +Version 20.8 +============ + +*Released 2024-02-08* + +This is the technical changelog for version 20.8. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `__. + +Major Changes +------------- + +- API 7.0 (:pr:`4034` closes :issue:`4033`, :pr:`4038` by `@aelkheir `__) + +Minor Changes +------------- + +- Fix Type Hint for ``filters`` Parameter of ``MessageHandler`` (:pr:`4039` by `@Palaptin `__) +- Deprecate ``filters.CHAT`` (:pr:`4083` closes :issue:`4062`) +- Improve Error Handling in Built-In Webhook Handler (:pr:`3987` closes :issue:`3979`) + +New Features +------------ + +- Add Parameter ``pattern`` to ``PreCheckoutQueryHandler`` and ``filters.SuccessfulPayment`` (:pr:`4005` by `@aelkheir `__ closes :issue:`3752`) +- Add Missing Conversions of ``type`` to Corresponding Enum from ``telegram.constants`` (:pr:`4067`) +- Add Support for Unix Sockets to ``Updater.start_webhook`` (:pr:`3986` closes :issue:`3978`) +- Add ``Bot.do_api_request`` (:pr:`4084` closes :issue:`4053`) +- Add ``AsyncContextManager`` as Parent Class to ``BaseUpdateProcessor`` (:pr:`4001`) + +Documentation Improvements +-------------------------- + +- Documentation Improvements (:pr:`3919`) +- Add Docstring to Dunder Methods (:pr:`3929` closes :issue:`3926`) +- Documentation Improvements (:pr:`4002`, :pr:`4079` by `@kenjitagawa `__, :pr:`4104` by `@xTudoS `__) + +Internal Changes +---------------- + +- Drop Usage of DeepSource (:pr:`4100`) +- Improve Type Completeness & Corresponding Workflow (:pr:`4035`) +- Bump ``ruff`` and Remove ``sort-all`` (:pr:`4075`) +- Move Handler Files to ``_handlers`` Subdirectory (:pr:`4064` by `@lucasmolinari `__ closes :issue:`4060`) +- Introduce ``sort-all`` Hook for ``pre-commit`` (:pr:`4052`) +- Use Recommended ``pre-commit`` Mirror for ``black`` (:pr:`4051`) +- Remove Unused ``DEFAULT_20`` (:pr:`3997`) +- Migrate From ``setup.cfg`` to ``pyproject.toml`` Where Possible (:pr:`4088`) + +Dependency Updates +------------------ + +- Bump ``black`` and ``ruff`` (:pr:`4089`) +- Bump ``srvaroa/labeler`` from 1.8.0 to 1.10.0 (:pr:`4048`) +- Update ``tornado`` requirement from ~=6.3.3 to ~=6.4 (:pr:`3992`) +- Bump ``actions/stale`` from 8 to 9 (:pr:`4046`) +- Bump ``actions/setup-python`` from 4 to 5 (:pr:`4047`) +- ``pre-commit`` autoupdate (:pr:`4101`) +- Bump ``actions/upload-artifact`` from 3 to 4 (:pr:`4045`) +- ``pre-commit`` autoupdate (:pr:`3996`) +- Bump ``furo`` from 2023.9.10 to 2024.1.29 (:pr:`4094`) +- ``pre-commit`` autoupdate (:pr:`4043`) +- Bump ``codecov/codecov-action`` from 3 to 4 (:pr:`4091`) +- Bump ``EndBug/add-and-commit`` from 9.1.3 to 9.1.4 (:pr:`4090`) +- Update ``httpx`` requirement from ~=0.25.2 to ~=0.26.0 (:pr:`4024`) +- Bump ``pytest`` from 7.4.3 to 7.4.4 (:pr:`4056`) +- Bump ``srvaroa/labeler`` from 1.7.0 to 1.8.0 (:pr:`3993`) +- Bump ``test-summary/action`` from 2.1 to 2.2 (:pr:`4044`) +- Bump ``dessant/lock-threads`` from 4.0.1 to 5.0.1 (:pr:`3994`) + + +Version 20.7 +============ + +*Released 2023-11-27* + +This is the technical changelog for version 20.7. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `__. + +New Features +------------ + +- Add ``JobQueue.scheduler_configuration`` and Corresponding Warnings (:pr:`3913` closes :issue:`3837`) +- Add Parameter ``socket_options`` to ``HTTPXRequest`` (:pr:`3935` closes :issue:`2965`) +- Add ``ApplicationBuilder.(get_updates_)socket_options`` (:pr:`3943`) +- Improve ``write_timeout`` Handling for Media Methods (:pr:`3952`) +- Add ``filters.Mention`` (:pr:`3941` closes :issue:`3799`) +- Rename ``proxy_url`` to ``proxy`` and Allow ``httpx.{Proxy, URL}`` as Input (:pr:`3939` closes :issue:`3844`) + +Bug Fixes & Changes +------------------- + +- Adjust ``read_timeout`` Behavior for ``Bot.get_updates`` (:pr:`3963` closes :issue:`3893`) +- Improve ``BaseHandler.__repr__`` for Callbacks without ``__qualname__`` (:pr:`3934`) +- Fix Persistency Issue with Ended Non-Blocking Conversations (:pr:`3962`) +- Improve Type Hinting for Arguments with Default Values in ``Bot`` (:pr:`3942`) + +Documentation Improvements +-------------------------- + +- Add Documentation for ``__aenter__`` and ``__aexit__`` Methods (:pr:`3907` closes :issue:`3886`) +- Improve Insertion of Kwargs into ``Bot`` Methods (:pr:`3965`) + +Internal Changes +---------------- + +- Adjust Tests to New Error Messages (:pr:`3970`) + +Dependency Updates +------------------ + +- Bump ``pytest-xdist`` from 3.3.1 to 3.4.0 (:pr:`3975`) +- ``pre-commit`` autoupdate (:pr:`3967`) +- Update ``httpx`` requirement from ~=0.25.1 to ~=0.25.2 (:pr:`3983`) +- Bump ``pytest-xdist`` from 3.4.0 to 3.5.0 (:pr:`3982`) +- Update ``httpx`` requirement from ~=0.25.0 to ~=0.25.1 (:pr:`3961`) +- Bump ``srvaroa/labeler`` from 1.6.1 to 1.7.0 (:pr:`3958`) +- Update ``cachetools`` requirement from ~=5.3.1 to ~=5.3.2 (:pr:`3954`) +- Bump ``pytest`` from 7.4.2 to 7.4.3 (:pr:`3953`) + + +Version 20.6 +============ + +*Released 2023-10-03* + +This is the technical changelog for version 20.6. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `__. + +Major Changes +------------- + +- Drop Backward Compatibility Layer Introduced in :pr:`3853` (API 6.8) (:pr:`3873`) +- Full Support for Bot API 6.9 (:pr:`3898`) + +New Features +------------ + +- Add Rich Equality Comparison to ``WriteAccessAllowed`` (:pr:`3911` closes :issue:`3909`) +- Add ``__repr__`` Methods Added in :pr:`3826` closes :issue:`3770` to Sphinx Documentation (:pr:`3901` closes :issue:`3889`) +- Add String Representation for Selected Classes (:pr:`3826` closes :issue:`3770`) + +Minor Changes +------------- + +- Add Support Python 3.12 (:pr:`3915`) +- Documentation Improvements (:pr:`3910`) + +Internal Changes +---------------- + +- Verify Type Hints for Bot Method & Telegram Class Parameters (:pr:`3868`) +- Move Bot API Tests to Separate Workflow File (:pr:`3912`) +- Fix Failing ``file_size`` Tests (:pr:`3906`) +- Set Threshold for DeepSource’s PY-R1000 to High (:pr:`3888`) +- One-Time Code Formatting Improvement via ``--preview`` Flag of ``black`` (:pr:`3882`) +- Move Dunder Methods to the Top of Class Bodies (:pr:`3883`) +- Remove Superfluous ``Defaults.__ne__`` (:pr:`3884`) + +Dependency Updates +------------------ + +- ``pre-commit`` autoupdate (:pr:`3876`) +- Update ``pre-commit`` Dependencies (:pr:`3916`) +- Bump ``actions/checkout`` from 3 to 4 (:pr:`3914`) +- Update ``httpx`` requirement from ~=0.24.1 to ~=0.25.0 (:pr:`3891`) +- Bump ``furo`` from 2023.8.19 to 2023.9.10 (:pr:`3890`) +- Bump ``sphinx`` from 7.2.5 to 7.2.6 (:pr:`3892`) +- Update ``tornado`` requirement from ~=6.2 to ~=6.3.3 (:pr:`3675`) +- Bump ``pytest`` from 7.4.0 to 7.4.2 (:pr:`3881`) + + +Version 20.5 +============ +*Released 2023-09-03* + +This is the technical changelog for version 20.5. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `__. + +Major Changes +------------- + +- API 6.8 (:pr:`3853`) +- Remove Functionality Deprecated Since Bot API 6.5, 6.6 or 6.7 (:pr:`3858`) + +New Features +------------ + +- Extend Allowed Values for HTTP Version (:pr:`3823` closes :issue:`3821`) +- Add ``has_args`` Parameter to ``CommandHandler`` (:pr:`3854` by `@thatguylah `__ closes :issue:`3798`) +- Add ``Application.stop_running()`` and Improve Marking Updates as Read on ``Updater.stop()`` (:pr:`3804`) + +Minor Changes +------------- + +- Type Hinting Fixes for ``WebhookInfo`` (:pr:`3871`) +- Test and Document ``Exception.__cause__`` on ``NetworkError`` (:pr:`3792` closes :issue:`3778`) +- Add Support for Python 3.12 RC (:pr:`3847`) + +Documentation Improvements +-------------------------- + +- Remove Version Check from Examples (:pr:`3846`) +- Documentation Improvements (:pr:`3803`, :pr:`3797`, :pr:`3816` by `@trim21 `__, :pr:`3829` by `@aelkheir `__) +- Provide Versions of ``customwebhookbot.py`` with Different Frameworks (:pr:`3820` closes :issue:`3717`) + +Dependency Updates +------------------ + +- ``pre-commit`` autoupdate (:pr:`3824`) +- Bump ``srvaroa/labeler`` from 1.6.0 to 1.6.1 (:pr:`3870`) +- Bump ``sphinx`` from 7.0.1 to 7.1.1 (:pr:`3818`) +- Bump ``sphinx`` from 7.2.3 to 7.2.5 (:pr:`3869`) +- Bump ``furo`` from 2023.5.20 to 2023.7.26 (:pr:`3817`) +- Update ``apscheduler`` requirement from ~=3.10.3 to ~=3.10.4 (:pr:`3862`) +- Bump ``sphinx`` from 7.2.2 to 7.2.3 (:pr:`3861`) +- Bump ``pytest-asyncio`` from 0.21.0 to 0.21.1 (:pr:`3801`) +- Bump ``sphinx-paramlinks`` from 0.5.4 to 0.6.0 (:pr:`3840`) +- Update ``apscheduler`` requirement from ~=3.10.1 to ~=3.10.3 (:pr:`3851`) +- Bump ``furo`` from 2023.7.26 to 2023.8.19 (:pr:`3850`) +- Bump ``sphinx`` from 7.1.2 to 7.2.2 (:pr:`3852`) +- Bump ``sphinx`` from 7.1.1 to 7.1.2 (:pr:`3827`) + + +Version 20.4 +============ + +*Released 2023-07-09* + +This is the technical changelog for version 20.4. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `__. + +Major Changes +------------- + +- Drop Support for Python 3.7 (:pr:`3728`, :pr:`3742` by `@Trifase `__, :pr:`3749` by `@thefunkycat `__, :pr:`3740` closes :issue:`3732`, :pr:`3754` closes :issue:`3731`, :pr:`3753`, :pr:`3764`, :pr:`3762`, :pr:`3759` closes :issue:`3733`) + +New Features +------------ + +- Make Integration of ``APScheduler`` into ``JobQueue`` More Explicit (:pr:`3695`) +- Introduce ``BaseUpdateProcessor`` for Customized Concurrent Handling of Updates (:pr:`3654` closes :issue:`3509`) + +Minor Changes +------------- + +- Fix Inconsistent Type Hints for ``timeout`` Parameter of ``Bot.get_updates`` (:pr:`3709` by `@revolter `__) +- Use Explicit Optionals (:pr:`3692` by `@MiguelX413 `__) + +Bug Fixes +--------- + +- Fix Wrong Warning Text in ``KeyboardButton.__eq__`` (:pr:`3768`) + +Documentation Improvements +-------------------------- + +- Explicitly set ``allowed_updates`` in Examples (:pr:`3741` by `@Trifase `__ closes :issue:`3726`) +- Bump ``furo`` and ``sphinx`` (:pr:`3719`) +- Documentation Improvements (:pr:`3698`, :pr:`3708` by `@revolter `__, :pr:`3767`) +- Add Quotes for Installation Instructions With Optional Dependencies (:pr:`3780`) +- Exclude Type Hints from Stability Policy (:pr:`3712`) +- Set ``httpx`` Logging Level to Warning in Examples (:pr:`3746` closes :issue:`3743`) + +Internal Changes +---------------- + +- Drop a Legacy ``pre-commit.ci`` Configuration (:pr:`3697`) +- Add Python 3.12 Beta to the Test Matrix (:pr:`3751`) +- Use Temporary Files for Testing File Downloads (:pr:`3777`) +- Auto-Update Changed Version in Other Files After Dependabot PRs (:pr:`3716`) +- Add More ``ruff`` Rules (:pr:`3763`) +- Rename ``_handler.py`` to ``_basehandler.py`` (:pr:`3761`) +- Automatically Label ``pre-commit-ci`` PRs (:pr:`3713`) +- Rework ``pytest`` Integration into GitHub Actions (:pr:`3776`) +- Fix Two Bugs in GitHub Actions Workflows (:pr:`3739`) + +Dependency Updates +------------------ + +- Update ``cachetools`` requirement from ~=5.3.0 to ~=5.3.1 (:pr:`3738`) +- Update ``aiolimiter`` requirement from ~=1.0.0 to ~=1.1.0 (:pr:`3707`) +- ``pre-commit`` autoupdate (:pr:`3791`) +- Bump ``sphinxcontrib-mermaid`` from 0.8.1 to 0.9.2 (:pr:`3737`) +- Bump ``pytest-xdist`` from 3.2.1 to 3.3.0 (:pr:`3705`) +- Bump ``srvaroa/labeler`` from 1.5.0 to 1.6.0 (:pr:`3786`) +- Bump ``dependabot/fetch-metadata`` from 1.5.1 to 1.6.0 (:pr:`3787`) +- Bump ``dessant/lock-threads`` from 4.0.0 to 4.0.1 (:pr:`3785`) +- Bump ``pytest`` from 7.3.2 to 7.4.0 (:pr:`3774`) +- Update ``httpx`` requirement from ~=0.24.0 to ~=0.24.1 (:pr:`3715`) +- Bump ``pytest-xdist`` from 3.3.0 to 3.3.1 (:pr:`3714`) +- Bump ``pytest`` from 7.3.1 to 7.3.2 (:pr:`3758`) +- ``pre-commit`` autoupdate (:pr:`3747`) + + +Version 20.3 +============ +*Released 2023-05-07* + +This is the technical changelog for version 20.3. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- + +- Full support for API 6.7 (:pr:`3673`) +- Add a Stability Policy (:pr:`3622`) + +New Features +------------ + +- Add ``Application.mark_data_for_update_persistence`` (:pr:`3607`) +- Make ``Message.link`` Point to Thread View Where Possible (:pr:`3640`) +- Localize Received ``datetime`` Objects According to ``Defaults.tzinfo`` (:pr:`3632`) + +Minor Changes, Documentation Improvements and CI +------------------------------------------------ + +- Empower ``ruff`` (:pr:`3594`) +- Drop Usage of ``sys.maxunicode`` (:pr:`3630`) +- Add String Representation for ``RequestParameter`` (:pr:`3634`) +- Stabilize CI by Rerunning Failed Tests (:pr:`3631`) +- Give Loggers Better Names (:pr:`3623`) +- Add Logging for Invalid JSON Data in ``BasePersistence.parse_json_payload`` (:pr:`3668`) +- Improve Warning Categories & Stacklevels (:pr:`3674`) +- Stabilize ``test_delete_sticker_set`` (:pr:`3685`) +- Shield Update Fetcher Task in ``Application.start`` (:pr:`3657`) +- Recover 100% Type Completeness (:pr:`3676`) +- Documentation Improvements (:pr:`3628`, :pr:`3636`, :pr:`3694`) + +Dependencies +------------ + +- Bump ``actions/stale`` from 7 to 8 (:pr:`3644`) +- Bump ``furo`` from 2023.3.23 to 2023.3.27 (:pr:`3643`) +- ``pre-commit`` autoupdate (:pr:`3646`, :pr:`3688`) +- Remove Deprecated ``codecov`` Package from CI (:pr:`3664`) +- Bump ``sphinx-copybutton`` from 0.5.1 to 0.5.2 (:pr:`3662`) +- Update ``httpx`` requirement from ~=0.23.3 to ~=0.24.0 (:pr:`3660`) +- Bump ``pytest`` from 7.2.2 to 7.3.1 (:pr:`3661`) + +Version 20.2 +============ +*Released 2023-03-25* + +This is the technical changelog for version 20.2. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- +- Full Support for API 6.6 (:pr:`3584`) +- Revert to HTTP/1.1 as Default and make HTTP/2 an Optional Dependency (:pr:`3576`) + +Minor Changes, Documentation Improvements and CI +------------------------------------------------ +- Documentation Improvements (:pr:`3565`, :pr:`3600`) +- Handle Symbolic Links in ``was_called_by`` (:pr:`3552`) +- Tidy Up Tests Directory (:pr:`3553`) +- Enhance ``Application.create_task`` (:pr:`3543`) +- Make Type Completeness Workflow Usable for ``PRs`` from Forks (:pr:`3551`) +- Refactor and Overhaul the Test Suite (:pr:`3426`) + +Dependencies +------------ +- Bump ``pytest-asyncio`` from 0.20.3 to 0.21.0 (:pr:`3624`) +- Bump ``furo`` from 2022.12.7 to 2023.3.23 (:pr:`3625`) +- Bump ``pytest-xdist`` from 3.2.0 to 3.2.1 (:pr:`3606`) +- ``pre-commit`` autoupdate (:pr:`3577`) +- Update ``apscheduler`` requirement from ~=3.10.0 to ~=3.10.1 (:pr:`3572`) +- Bump ``pytest`` from 7.2.1 to 7.2.2 (:pr:`3573`) +- Bump ``pytest-xdist`` from 3.1.0 to 3.2.0 (:pr:`3550`) +- Bump ``sphinxcontrib-mermaid`` from 0.7.1 to 0.8 (:pr:`3549`) + +Version 20.1 +============ +*Released 2023-02-09* + +This is the technical changelog for version 20.1. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- + +- Full Support for Bot API 6.5 (:pr:`3530`) + +New Features +------------ + +- Add ``Application(Builder).post_stop`` (:pr:`3466`) +- Add ``Chat.effective_name`` Convenience Property (:pr:`3485`) +- Allow to Adjust HTTP Version and Use HTTP/2 by Default (:pr:`3506`) + +Documentation Improvements +-------------------------- + +- Enhance ``chatmemberbot`` Example (:pr:`3500`) +- Automatically Generate Cross-Reference Links (:pr:`3501`, :pr:`3529`, :pr:`3523`) +- Add Some Graphic Elements to Docs (:pr:`3535`) +- Various Smaller Improvements (:pr:`3464`, :pr:`3483`, :pr:`3484`, :pr:`3497`, :pr:`3512`, :pr:`3515`, :pr:`3498`) + +Minor Changes, Documentation Improvements and CI +------------------------------------------------ + +- Update Copyright to 2023 (:pr:`3459`) +- Stabilize Tests on Closing and Hiding the General Forum Topic (:pr:`3460`) +- Fix Dependency Warning Typo (:pr:`3474`) +- Cache Dependencies on ``GitHub`` Actions (:pr:`3469`) +- Store Documentation Builts as ``GitHub`` Actions Artifacts (:pr:`3468`) +- Add ``ruff`` to ``pre-commit`` Hooks (:pr:`3488`) +- Improve Warning for ``days`` Parameter of ``JobQueue.run_daily`` (:pr:`3503`) +- Improve Error Message for ``NetworkError`` (:pr:`3505`) +- Lock Inactive Threads Only Once Each Day (:pr:`3510`) +- Bump ``pytest`` from 7.2.0 to 7.2.1 (:pr:`3513`) +- Check for 3D Arrays in ``check_keyboard_type`` (:pr:`3514`) +- Explicit Type Annotations (:pr:`3508`) +- Increase Verbosity of Type Completeness CI Job (:pr:`3531`) +- Fix CI on Python 3.11 + Windows (:pr:`3547`) + +Dependencies +------------ + +- Bump ``actions/stale`` from 6 to 7 (:pr:`3461`) +- Bump ``dessant/lock-threads`` from 3.0.0 to 4.0.0 (:pr:`3462`) +- ``pre-commit`` autoupdate (:pr:`3470`) +- Update ``httpx`` requirement from ~=0.23.1 to ~=0.23.3 (:pr:`3489`) +- Update ``cachetools`` requirement from ~=5.2.0 to ~=5.2.1 (:pr:`3502`) +- Improve Config for ``ruff`` and Bump to ``v0.0.222`` (:pr:`3507`) +- Update ``cachetools`` requirement from ~=5.2.1 to ~=5.3.0 (:pr:`3520`) +- Bump ``isort`` to 5.12.0 (:pr:`3525`) +- Update ``apscheduler`` requirement from ~=3.9.1 to ~=3.10.0 (:pr:`3532`) +- ``pre-commit`` autoupdate (:pr:`3537`) +- Update ``cryptography`` requirement to >=39.0.1 to address Vulnerability (:pr:`3539`) + +Version 20.0 +============ +*Released 2023-01-01* + +This is the technical changelog for version 20.0. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- + +- Full Support For Bot API 6.4 (:pr:`3449`) + +Minor Changes, Documentation Improvements and CI +------------------------------------------------ + +- Documentation Improvements (:pr:`3428`, :pr:`3423`, :pr:`3429`, :pr:`3441`, :pr:`3404`, :pr:`3443`) +- Allow ``Sequence`` Input for Bot Methods (:pr:`3412`) +- Update Link-Check CI and Replace a Dead Link (:pr:`3456`) +- Freeze Classes Without Arguments (:pr:`3453`) +- Add New Constants (:pr:`3444`) +- Override ``Bot.__deepcopy__`` to Raise ``TypeError`` (:pr:`3446`) +- Add Log Decorator to ``Bot.get_webhook_info`` (:pr:`3442`) +- Add Documentation On Verifying Releases (:pr:`3436`) +- Drop Undocumented ``Job.__lt__`` (:pr:`3432`) + +Dependencies +------------ + +- Downgrade ``sphinx`` to 5.3.0 to Fix Search (:pr:`3457`) +- Bump ``sphinx`` from 5.3.0 to 6.0.0 (:pr:`3450`) + +Version 20.0b0 +============== +*Released 2022-12-15* + +This is the technical changelog for version 20.0b0. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- + +- Make ``TelegramObject`` Immutable (:pr:`3249`) + +Minor Changes, Documentation Improvements and CI +------------------------------------------------ + +- Reduce Code Duplication in Testing ``Defaults`` (:pr:`3419`) +- Add Notes and Warnings About Optional Dependencies (:pr:`3393`) +- Simplify Internals of ``Bot`` Methods (:pr:`3396`) +- Reduce Code Duplication in Several ``Bot`` Methods (:pr:`3385`) +- Documentation Improvements (:pr:`3386`, :pr:`3395`, :pr:`3398`, :pr:`3403`) + +Dependencies +------------ + +- Bump ``pytest-xdist`` from 3.0.2 to 3.1.0 (:pr:`3415`) +- Bump ``pytest-asyncio`` from 0.20.2 to 0.20.3 (:pr:`3417`) +- ``pre-commit`` autoupdate (:pr:`3409`) + +Version 20.0a6 +============== +*Released 2022-11-24* + +This is the technical changelog for version 20.0a6. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Bug Fixes +--------- + +- Only Persist Arbitrary ``callback_data`` if ``ExtBot.callback_data_cache`` is Present (:pr:`3384`) +- Improve Backwards Compatibility of ``TelegramObjects`` Pickle Behavior (:pr:`3382`) +- Fix Naming and Keyword Arguments of ``File.download_*`` Methods (:pr:`3380`) +- Fix Return Value Annotation of ``Chat.create_forum_topic`` (:pr:`3381`) + +Version 20.0a5 +============== +*Released 2022-11-22* + +This is the technical changelog for version 20.0a5. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- + +- API 6.3 (:pr:`3346`, :pr:`3343`, :pr:`3342`, :pr:`3360`) +- Explicit ``local_mode`` Setting (:pr:`3154`) +- Make Almost All 3rd Party Dependencies Optional (:pr:`3267`) +- Split ``File.download`` Into ``File.download_to_drive`` And ``File.download_to_memory`` (:pr:`3223`) + +New Features +------------ + +- Add Properties for API Settings of ``Bot`` (:pr:`3247`) +- Add ``chat_id`` and ``username`` Parameters to ``ChatJoinRequestHandler`` (:pr:`3261`) +- Introduce ``TelegramObject.api_kwargs`` (:pr:`3233`) +- Add Two Constants Related to Local Bot API Servers (:pr:`3296`) +- Add ``recursive`` Parameter to ``TelegramObject.to_dict()`` (:pr:`3276`) +- Overhaul String Representation of ``TelegramObject`` (:pr:`3234`) +- Add Methods ``Chat.mention_{html, markdown, markdown_v2}`` (:pr:`3308`) +- Add ``constants.MessageLimit.DEEP_LINK_LENGTH`` (:pr:`3315`) +- Add Shortcut Parameters ``caption``, ``parse_mode`` and ``caption_entities`` to ``Bot.send_media_group`` (:pr:`3295`) +- Add Several New Enums To Constants (:pr:`3351`) + +Bug Fixes +--------- + +- Fix ``CallbackQueryHandler`` Not Handling Non-String Data Correctly With Regex Patterns (:pr:`3252`) +- Fix Defaults Handling in ``Bot.answer_web_app_query`` (:pr:`3362`) + +Documentation Improvements +-------------------------- + +- Update PR Template (:pr:`3361`) +- Document Dunder Methods of ``TelegramObject`` (:pr:`3319`) +- Add Several References to Wiki pages (:pr:`3306`) +- Overhaul Search bar (:pr:`3218`) +- Unify Documentation of Arguments and Attributes of Telegram Classes (:pr:`3217`, :pr:`3292`, :pr:`3303`, :pr:`3312`, :pr:`3314`) +- Several Smaller Improvements (:pr:`3214`, :pr:`3271`, :pr:`3289`, :pr:`3326`, :pr:`3370`, :pr:`3376`, :pr:`3366`) + +Minor Changes, Documentation Improvements and CI +------------------------------------------------ + +- Improve Warning About Unknown ``ConversationHandler`` States (:pr:`3242`) +- Switch from Stale Bot to ``GitHub`` Actions (:pr:`3243`) +- Bump Python 3.11 to RC2 in Test Matrix (:pr:`3246`) +- Make ``Job.job`` a Property and Make ``Jobs`` Hashable (:pr:`3250`) +- Skip ``JobQueue`` Tests on Windows Again (:pr:`3280`) +- Read-Only ``CallbackDataCache`` (:pr:`3266`) +- Type Hinting Fix for ``Message.effective_attachment`` (:pr:`3294`) +- Run Unit Tests in Parallel (:pr:`3283`) +- Update Test Matrix to Use Stable Python 3.11 (:pr:`3313`) +- Don't Edit Objects In-Place When Inserting ``ext.Defaults`` (:pr:`3311`) +- Add a Test for ``MessageAttachmentType`` (:pr:`3335`) +- Add Three New Test Bots (:pr:`3347`) +- Improve Unit Tests Regarding ``ChatMemberUpdated.difference`` (:pr:`3352`) +- Flaky Unit Tests: Use ``pytest`` Marker (:pr:`3354`) +- Fix ``DeepSource`` Issues (:pr:`3357`) +- Handle Lists and Tuples and Datetimes Directly in ``TelegramObject.to_dict`` (:pr:`3353`) +- Update Meta Config (:pr:`3365`) +- Merge ``ChatDescriptionLimit`` Enum Into ``ChatLimit`` (:pr:`3377`) + +Dependencies +------------ + +- Bump ``pytest`` from 7.1.2 to 7.1.3 (:pr:`3228`) +- ``pre-commit`` Updates (:pr:`3221`) +- Bump ``sphinx`` from 5.1.1 to 5.2.3 (:pr:`3269`) +- Bump ``furo`` from 2022.6.21 to 2022.9.29 (:pr:`3268`) +- Bump ``actions/stale`` from 5 to 6 (:pr:`3277`) +- ``pre-commit`` autoupdate (:pr:`3282`) +- Bump ``sphinx`` from 5.2.3 to 5.3.0 (:pr:`3300`) +- Bump ``pytest-asyncio`` from 0.19.0 to 0.20.1 (:pr:`3299`) +- Bump ``pytest`` from 7.1.3 to 7.2.0 (:pr:`3318`) +- Bump ``pytest-xdist`` from 2.5.0 to 3.0.2 (:pr:`3317`) +- ``pre-commit`` autoupdate (:pr:`3325`) +- Bump ``pytest-asyncio`` from 0.20.1 to 0.20.2 (:pr:`3359`) +- Update ``httpx`` requirement from ~=0.23.0 to ~=0.23.1 (:pr:`3373`) + +Version 20.0a4 +============== +*Released 2022-08-27* + +This is the technical changelog for version 20.0a4. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Hot Fixes +--------- + +* Fix a Bug in ``setup.py`` Regarding Optional Dependencies (:pr:`3209`) + +Version 20.0a3 +============== +*Released 2022-08-27* + +This is the technical changelog for version 20.0a3. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- + +- Full Support for API 6.2 (:pr:`3195`) + +New Features +------------ + +- New Rate Limiting Mechanism (:pr:`3148`) +- Make ``chat/user_data`` Available in Error Handler for Errors in Jobs (:pr:`3152`) +- Add ``Application.post_shutdown`` (:pr:`3126`) + +Bug Fixes +--------- + +- Fix ``helpers.mention_markdown`` for Markdown V1 and Improve Related Unit Tests (:pr:`3155`) +- Add ``api_kwargs`` Parameter to ``Bot.log_out`` and Improve Related Unit Tests (:pr:`3147`) +- Make ``Bot.delete_my_commands`` a Coroutine Function (:pr:`3136`) +- Fix ``ConversationHandler.check_update`` not respecting ``per_user`` (:pr:`3128`) + +Minor Changes, Documentation Improvements and CI +------------------------------------------------ + +- Add Python 3.11 to Test Suite & Adapt Enum Behaviour (:pr:`3168`) +- Drop Manual Token Validation (:pr:`3167`) +- Simplify Unit Tests for ``Bot.send_chat_action`` (:pr:`3151`) +- Drop ``pre-commit`` Dependencies from ``requirements-dev.txt`` (:pr:`3120`) +- Change Default Values for ``concurrent_updates`` and ``connection_pool_size`` (:pr:`3127`) +- Documentation Improvements (:pr:`3139`, :pr:`3153`, :pr:`3135`) +- Type Hinting Fixes (:pr:`3202`) + +Dependencies +------------ + +- Bump ``sphinx`` from 5.0.2 to 5.1.1 (:pr:`3177`) +- Update ``pre-commit`` Dependencies (:pr:`3085`) +- Bump ``pytest-asyncio`` from 0.18.3 to 0.19.0 (:pr:`3158`) +- Update ``tornado`` requirement from ~=6.1 to ~=6.2 (:pr:`3149`) +- Bump ``black`` from 22.3.0 to 22.6.0 (:pr:`3132`) +- Bump ``actions/setup-python`` from 3 to 4 (:pr:`3131`) + +Version 20.0a2 +============== +*Released 2022-06-27* + +This is the technical changelog for version 20.0a2. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes +------------- + +- Full Support for API 6.1 (:pr:`3112`) + +New Features +------------ + +- Add Additional Shortcut Methods to ``Chat`` (:pr:`3115`) +- Mermaid-based Example State Diagrams (:pr:`3090`) + +Minor Changes, Documentation Improvements and CI +------------------------------------------------ + +- Documentation Improvements (:pr:`3103`, :pr:`3121`, :pr:`3098`) +- Stabilize CI (:pr:`3119`) +- Bump ``pyupgrade`` from 2.32.1 to 2.34.0 (:pr:`3096`) +- Bump ``furo`` from 2022.6.4 to 2022.6.4.1 (:pr:`3095`) +- Bump ``mypy`` from 0.960 to 0.961 (:pr:`3093`) + +Version 20.0a1 +============== +*Released 2022-06-09* + +This is the technical changelog for version 20.0a1. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes: +-------------- + +- Drop Support for ``ujson`` and instead ``BaseRequest.parse_json_payload`` (:pr:`3037`, :pr:`3072`) +- Drop ``InputFile.is_image`` (:pr:`3053`) +- Drop Explicit Type conversions in ``__init__`` s (:pr:`3056`) +- Handle List-Valued Attributes More Consistently (:pr:`3057`) +- Split ``{Command, Prefix}Handler`` And Make Attributes Immutable (:pr:`3045`) +- Align Behavior Of ``JobQueue.run_daily`` With ``cron`` (:pr:`3046`) +- Make PTB Specific Keyword-Only Arguments for PTB Specific in Bot methods (:pr:`3035`) +- Adjust Equality Comparisons to Fit Bot API 6.0 (:pr:`3033`) +- Add Tuple Based Version Info (:pr:`3030`) +- Improve Type Annotations for ``CallbackContext`` and Move Default Type Alias to ``ContextTypes.DEFAULT_TYPE`` (:pr:`3017`, :pr:`3023`) +- Rename ``Job.context`` to ``Job.data`` (:pr:`3028`) +- Rename ``Handler`` to ``BaseHandler`` (:pr:`3019`) + +New Features: +------------- + +- Add ``Application.post_init`` (:pr:`3078`) +- Add Arguments ``chat/user_id`` to ``CallbackContext`` And Example On Custom Webhook Setups (:pr:`3059`) +- Add Convenience Property ``Message.id`` (:pr:`3077`) +- Add Example for ``WebApp`` (:pr:`3052`) +- Rename ``telegram.bot_api_version`` to ``telegram.__bot_api_version__`` (:pr:`3030`) + +Bug Fixes: +---------- + +- Fix Non-Blocking Entry Point in ``ConversationHandler`` (:pr:`3068`) +- Escape Backslashes in ``escape_markdown`` (:pr:`3055`) + +Dependencies: +------------- + +- Update ``httpx`` requirement from ~=0.22.0 to ~=0.23.0 (:pr:`3069`) +- Update ``cachetools`` requirement from ~=5.0.0 to ~=5.2.0 (:pr:`3058`, :pr:`3080`) + +Minor Changes, Documentation Improvements and CI: +------------------------------------------------- + +- Move Examples To Documentation (:pr:`3089`) +- Documentation Improvements and Update Dependencies (:pr:`3010`, :pr:`3007`, :pr:`3012`, :pr:`3067`, :pr:`3081`, :pr:`3082`) +- Improve Some Unit Tests (:pr:`3026`) +- Update Code Quality dependencies (:pr:`3070`, :pr:`3032`,:pr:`2998`, :pr:`2999`) +- Don't Set Signal Handlers On Windows By Default (:pr:`3065`) +- Split ``{Command, Prefix}Handler`` And Make Attributes Immutable (:pr:`3045`) +- Apply ``isort`` and Update ``pre-commit.ci`` Configuration (:pr:`3049`) +- Adjust ``pre-commit`` Settings for ``isort`` (:pr:`3043`) +- Add Version Check to Examples (:pr:`3036`) +- Use ``Collection`` Instead of ``List`` and ``Tuple`` (:pr:`3025`) +- Remove Client-Side Parameter Validation (:pr:`3024`) +- Don't Pass Default Values of Optional Parameters to Telegram (:pr:`2978`) +- Stabilize ``Application.run_*`` on Python 3.7 (:pr:`3009`) +- Ignore Code Style Commits in ``git blame`` (:pr:`3003`) +- Adjust Tests to Changed API Behavior (:pr:`3002`) + +Version 20.0a0 +============== +*Released 2022-05-06* + +This is the technical changelog for version 20.0a0. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +Major Changes: +-------------- + +- Refactor Initialization of Persistence Classes + (:pr:`2604`) +- Drop Non-``CallbackContext`` API + (:pr:`2617`) +- Remove ``__dict__`` from ``__slots__`` and drop Python 3.6 + (:pr:`2619`, + :pr:`2636`) +- Move and Rename ``TelegramDecryptionError`` to + ``telegram.error.PassportDecryptionError`` + (:pr:`2621`) +- Make ``BasePersistence`` Methods Abstract + (:pr:`2624`) +- Remove ``day_is_strict`` argument of ``JobQueue.run_monthly`` + (:pr:`2634` + by `iota-008 `__) +- Move ``Defaults`` to ``telegram.ext`` + (:pr:`2648`) +- Remove Deprecated Functionality + (:pr:`2644`, + :pr:`2740`, + :pr:`2745`) +- Overhaul of Filters + (:pr:`2759`, + :pr:`2922`) +- Switch to ``asyncio`` and Refactor PTBs Architecture + (:pr:`2731`) +- Improve ``Job.__getattr__`` + (:pr:`2832`) +- Remove ``telegram.ReplyMarkup`` + (:pr:`2870`) +- Persistence of ``Bots``: Refactor Automatic Replacement and + Integration with ``TelegramObject`` + (:pr:`2893`) + +New Features: +------------- + +- Introduce Builder Pattern + (:pr:`2646`) +- Add ``Filters.update.edited`` + (:pr:`2705` + by `PhilippFr `__) +- Introduce ``Enums`` for ``telegram.constants`` + (:pr:`2708`) +- Accept File Paths for ``private_key`` + (:pr:`2724`) +- Associate ``Jobs`` with ``chat/user_id`` + (:pr:`2731`) +- Convenience Functionality for ``ChatInviteLinks`` + (:pr:`2782`) +- Add ``Dispatcher.add_handlers`` + (:pr:`2823`) +- Improve Error Messages in ``CommandHandler.__init__`` + (:pr:`2837`) +- ``Defaults.protect_content`` + (:pr:`2840`) +- Add ``Dispatcher.migrate_chat_data`` + (:pr:`2848` + by `DonalDuck004 `__) +- Add Method ``drop_chat/user_data`` to ``Dispatcher`` and Persistence + (:pr:`2852`) +- Add methods ``ChatPermissions.{all, no}_permissions`` (:pr:`2948`) +- Full Support for API 6.0 + (:pr:`2956`) +- Add Python 3.10 to Test Suite + (:pr:`2968`) + +Bug Fixes & Minor Changes: +-------------------------- + +- Improve Type Hinting for ``CallbackContext`` + (:pr:`2587` + by `revolter `__) +- Fix Signatures and Improve ``test_official`` + (:pr:`2643`) +- Refine ``Dispatcher.dispatch_error`` + (:pr:`2660`) +- Make ``InlineQuery.answer`` Raise ``ValueError`` + (:pr:`2675`) +- Improve Signature Inspection for Bot Methods + (:pr:`2686`) +- Introduce ``TelegramObject.set/get_bot`` + (:pr:`2712` + by `zpavloudis `__) +- Improve Subscription of ``TelegramObject`` + (:pr:`2719` + by `SimonDamberg `__) +- Use Enums for Dynamic Types & Rename Two Attributes in ``ChatMember`` + (:pr:`2817`) +- Return Plain Dicts from ``BasePersistence.get_*_data`` + (:pr:`2873`) +- Fix a Bug in ``ChatMemberUpdated.difference`` + (:pr:`2947`) +- Update Dependency Policy + (:pr:`2958`) + +Internal Restructurings & Improvements: +--------------------------------------- + +- Add User Friendly Type Check For Init Of + ``{Inline, Reply}KeyboardMarkup`` + (:pr:`2657`) +- Warnings Overhaul + (:pr:`2662`) +- Clear Up Import Policy + (:pr:`2671`) +- Mark Internal Modules As Private + (:pr:`2687` + by `kencx `__) +- Handle Filepaths via the ``pathlib`` Module + (:pr:`2688` + by `eldbud `__) +- Refactor MRO of ``InputMedia*`` and Some File-Like Classes + (:pr:`2717` + by `eldbud `__) +- Update Exceptions for Immutable Attributes + (:pr:`2749`) +- Refactor Warnings in ``ConversationHandler`` + (:pr:`2755`, + :pr:`2784`) +- Use ``__all__`` Consistently + (:pr:`2805`) + +CI, Code Quality & Test Suite Improvements: +------------------------------------------- + +- Add Custom ``pytest`` Marker to Ease Development + (:pr:`2628`) +- Pass Failing Jobs to Error Handlers + (:pr:`2692`) +- Update Notification Workflows + (:pr:`2695`) +- Use Error Messages for ``pylint`` Instead of Codes + (:pr:`2700` + by `Piraty `__) +- Make Tests Agnostic of the CWD + (:pr:`2727` + by `eldbud `__) +- Update Code Quality Dependencies + (:pr:`2748`) +- Improve Code Quality + (:pr:`2783`) +- Update ``pre-commit`` Settings & Improve a Test + (:pr:`2796`) +- Improve Code Quality & Test Suite + (:pr:`2843`) +- Fix failing animation tests + (:pr:`2865`) +- Update and Expand Tests & pre-commit Settings and Improve Code + Quality + (:pr:`2925`) +- Extend Code Formatting With Black + (:pr:`2972`) +- Update Workflow Permissions + (:pr:`2984`) +- Adapt Tests to Changed ``Bot.get_file`` Behavior + (:pr:`2995`) + +Documentation Improvements: +--------------------------- + +- Doc Fixes + (:pr:`2597`) +- Add Code Comment Guidelines to Contribution Guide + (:pr:`2612`) +- Add Cross-References to External Libraries & Other Documentation + Improvements + (:pr:`2693`, + :pr:`2691` + by `joesinghh `__, + :pr:`2739` + by `eldbud `__) +- Use Furo Theme, Make Parameters Referenceable, Add Documentation + Building to CI, Improve Links to Source Code & Other Improvements + (:pr:`2856`, + :pr:`2798`, + :pr:`2854`, + :pr:`2841`) +- Documentation Fixes & Improvements + (:pr:`2822`) +- Replace ``git.io`` Links + (:pr:`2872` + by `murugu-21 `__) +- Overhaul Readmes, Update RTD Startpage & Other Improvements + (:pr:`2969`) + +Version 13.11 +============= +*Released 2022-02-02* + +This is the technical changelog for version 13.11. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +**Major Changes:** + +- Full Support for Bot API 5.7 (:pr:`2881`) + +Version 13.10 +============= +*Released 2022-01-03* + +This is the technical changelog for version 13.10. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +**Major Changes:** + +- Full Support for API 5.6 (:pr:`2835`) + +**Minor Changes & Doc fixes:** + +- Update Copyright to 2022 (:pr:`2836`) +- Update Documentation of ``BotCommand`` (:pr:`2820`) + +Version 13.9 +============ +*Released 2021-12-11* + +This is the technical changelog for version 13.9. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +**Major Changes:** + +- Full Support for Api 5.5 (:pr:`2809`) + +**Minor Changes** + +- Adjust Automated Locking of Inactive Issues (:pr:`2775`) + +Version 13.8.1 +============== +*Released 2021-11-08* + +This is the technical changelog for version 13.8.1. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +**Doc fixes:** + +- Add ``ChatJoinRequest(Handler)`` to Docs (:pr:`2771`) + +Version 13.8 +============ +*Released 2021-11-08* + +This is the technical changelog for version 13.8. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +**Major Changes:** + +- Full support for API 5.4 (:pr:`2767`) + +**Minor changes, CI improvements, Doc fixes and Type hinting:** + +- Create Issue Template Forms (:pr:`2689`) +- Fix ``camelCase`` Functions in ``ExtBot`` (:pr:`2659`) +- Fix Empty Captions not Being Passed by ``Bot.copy_message`` (:pr:`2651`) +- Fix Setting Thumbs When Uploading A Single File (:pr:`2583`) +- Fix Bug in ``BasePersistence.insert``/``replace_bot`` for Objects with ``__dict__`` not in ``__slots__`` (:pr:`2603`) + +Version 13.7 +============ +*Released 2021-07-01* + +This is the technical changelog for version 13.7. More elaborate release notes can be found in the news channel `@pythontelegrambotchannel `_. + +**Major Changes:** + +- Full support for Bot API 5.3 (:pr:`2572`) + +**Bug Fixes:** + +- Fix Bug in ``BasePersistence.insert/replace_bot`` for Objects with ``__dict__`` in their slots (:pr:`2561`) +- Remove Incorrect Warning About ``Defaults`` and ``ExtBot`` (:pr:`2553`) + +**Minor changes, CI improvements, Doc fixes and Type hinting:** + +- Type Hinting Fixes (:pr:`2552`) +- Doc Fixes (:pr:`2551`) +- Improve Deprecation Warning for ``__slots__`` (:pr:`2574`) +- Stabilize CI (:pr:`2575`) +- Fix Coverage Configuration (:pr:`2571`) +- Better Exception-Handling for ``BasePersistence.replace/insert_bot`` (:pr:`2564`) +- Remove Deprecated ``pass_args`` from Deeplinking Example (:pr:`2550`) + +Version 13.6 +============ +*Released 2021-06-06* + +New Features: + +- Arbitrary ``callback_data`` (:pr:`1844`) +- Add ``ContextTypes`` & ``BasePersistence.refresh_user/chat/bot_data`` (:pr:`2262`) +- Add ``Filters.attachment`` (:pr:`2528`) +- Add ``pattern`` Argument to ``ChosenInlineResultHandler`` (:pr:`2517`) + +Major Changes: + +- Add ``slots`` (:pr:`2345`) + +Minor changes, CI improvements, Doc fixes and Type hinting: + +- Doc Fixes (:pr:`2495`, :pr:`2510`) +- Add ``max_connections`` Parameter to ``Updater.start_webhook`` (:pr:`2547`) +- Fix for ``Promise.done_callback`` (:pr:`2544`) +- Improve Code Quality (:pr:`2536`, :pr:`2454`) +- Increase Test Coverage of ``CallbackQueryHandler`` (:pr:`2520`) +- Stabilize CI (:pr:`2522`, :pr:`2537`, :pr:`2541`) +- Fix ``send_phone_number_to_provider`` argument for ``Bot.send_invoice`` (:pr:`2527`) +- Handle Classes as Input for ``BasePersistence.replace/insert_bot`` (:pr:`2523`) +- Bump Tornado Version and Remove Workaround from :pr:`2067` (:pr:`2494`) + +Version 13.5 +============ +*Released 2021-04-30* + +**Major Changes:** + +- Full support of Bot API 5.2 (:pr:`2489`). + + .. note:: + The ``start_parameter`` argument of ``Bot.send_invoice`` and the corresponding shortcuts is now optional, so the order of + parameters had to be changed. Make sure to update your method calls accordingly. + +- Update ``ChatActions``, Deprecating ``ChatAction.RECORD_AUDIO`` and ``ChatAction.UPLOAD_AUDIO`` (:pr:`2460`) + +**New Features:** + +- Convenience Utilities & Example for Handling ``ChatMemberUpdated`` (:pr:`2490`) +- ``Filters.forwarded_from`` (:pr:`2446`) + +**Minor changes, CI improvements, Doc fixes and Type hinting:** + +- Improve Timeouts in ``ConversationHandler`` (:pr:`2417`) +- Stabilize CI (:pr:`2480`) +- Doc Fixes (:pr:`2437`) +- Improve Type Hints of Data Filters (:pr:`2456`) +- Add Two ``UserWarnings`` (:pr:`2464`) +- Improve Code Quality (:pr:`2450`) +- Update Fallback Test-Bots (:pr:`2451`) +- Improve Examples (:pr:`2441`, :pr:`2448`) + +Version 13.4.1 +============== +*Released 2021-03-14* + +**Hot fix release:** + +- Fixed a bug in ``setup.py`` (:pr:`2431`) + +Version 13.4 +============ +*Released 2021-03-14* + +**Major Changes:** + +- Full support of Bot API 5.1 (:pr:`2424`) + +**Minor changes, CI improvements, doc fixes and type hinting:** + +- Improve ``Updater.set_webhook`` (:pr:`2419`) +- Doc Fixes (:pr:`2404`) +- Type Hinting Fixes (:pr:`2425`) +- Update ``pre-commit`` Settings (:pr:`2415`) +- Fix Logging for Vendored ``urllib3`` (:pr:`2427`) +- Stabilize Tests (:pr:`2409`) + +Version 13.3 +============ +*Released 2021-02-19* + +**Major Changes:** + +- Make ``cryptography`` Dependency Optional & Refactor Some Tests (:pr:`2386`, :pr:`2370`) +- Deprecate ``MessageQueue`` (:pr:`2393`) + +**Bug Fixes:** + +- Refactor ``Defaults`` Integration (:pr:`2363`) +- Add Missing ``telegram.SecureValue`` to init and Docs (:pr:`2398`) + +**Minor changes:** + +- Doc Fixes (:pr:`2359`) + +Version 13.2 +============ +*Released 2021-02-02* + +**Major Changes:** + +- Introduce ``python-telegram-bot-raw`` (:pr:`2324`) +- Explicit Signatures for Shortcuts (:pr:`2240`) + +**New Features:** + +- Add Missing Shortcuts to ``Message`` (:pr:`2330`) +- Rich Comparison for ``Bot`` (:pr:`2320`) +- Add ``run_async`` Parameter to ``ConversationHandler`` (:pr:`2292`) +- Add New Shortcuts to ``Chat`` (:pr:`2291`) +- Add New Constant ``MAX_ANSWER_CALLBACK_QUERY_TEXT_LENGTH`` (:pr:`2282`) +- Allow Passing Custom Filename For All Media (:pr:`2249`) +- Handle Bytes as File Input (:pr:`2233`) + +**Bug Fixes:** + +- Fix Escaping in Nested Entities in ``Message`` Properties (:pr:`2312`) +- Adjust Calling of ``Dispatcher.update_persistence`` (:pr:`2285`) +- Add ``quote`` kwarg to ``Message.reply_copy`` (:pr:`2232`) +- ``ConversationHandler``: Docs & ``edited_channel_post`` behavior (:pr:`2339`) + +**Minor changes, CI improvements, doc fixes and type hinting:** + +- Doc Fixes (:pr:`2253`, :pr:`2225`) +- Reduce Usage of ``typing.Any`` (:pr:`2321`) +- Extend Deeplinking Example (:pr:`2335`) +- Add pyupgrade to pre-commit Hooks (:pr:`2301`) +- Add PR Template (:pr:`2299`) +- Drop Nightly Tests & Update Badges (:pr:`2323`) +- Update Copyright (:pr:`2289`, :pr:`2287`) +- Change Order of Class DocStrings (:pr:`2256`) +- Add macOS to Test Matrix (:pr:`2266`) +- Start Using Versioning Directives in Docs (:pr:`2252`) +- Improve Annotations & Docs of Handlers (:pr:`2243`) + +Version 13.1 +============ +*Released 2020-11-29* + +**Major Changes:** + +- Full support of Bot API 5.0 (:pr:`2181`, :pr:`2186`, :pr:`2190`, :pr:`2189`, :pr:`2183`, :pr:`2184`, :pr:`2188`, :pr:`2185`, :pr:`2192`, :pr:`2196`, :pr:`2193`, :pr:`2223`, :pr:`2199`, :pr:`2187`, :pr:`2147`, :pr:`2205`) + +**New Features:** + +- Add ``Defaults.run_async`` (:pr:`2210`) +- Improve and Expand ``CallbackQuery`` Shortcuts (:pr:`2172`) +- Add XOR Filters and make ``Filters.name`` a Property (:pr:`2179`) +- Add ``Filters.document.file_extension`` (:pr:`2169`) +- Add ``Filters.caption_regex`` (:pr:`2163`) +- Add ``Filters.chat_type`` (:pr:`2128`) +- Handle Non-Binary File Input (:pr:`2202`) + +**Bug Fixes:** + +- Improve Handling of Custom Objects in ``BasePersistence.insert``/``replace_bot`` (:pr:`2151`) +- Fix bugs in ``replace/insert_bot`` (:pr:`2218`) + +**Minor changes, CI improvements, doc fixes and type hinting:** + +- Improve Type hinting (:pr:`2204`, :pr:`2118`, :pr:`2167`, :pr:`2136`) +- Doc Fixes & Extensions (:pr:`2201`, :pr:`2161`) +- Use F-Strings Where Possible (:pr:`2222`) +- Rename kwargs to _kwargs where possible (:pr:`2182`) +- Comply with PEP561 (:pr:`2168`) +- Improve Code Quality (:pr:`2131`) +- Switch Code Formatting to Black (:pr:`2122`, :pr:`2159`, :pr:`2158`) +- Update Wheel Settings (:pr:`2142`) +- Update ``timerbot.py`` to ``v13.0`` (:pr:`2149`) +- Overhaul Constants (:pr:`2137`) +- Add Python 3.9 to Test Matrix (:pr:`2132`) +- Switch Codecov to ``GitHub`` Action (:pr:`2127`) +- Specify Required pytz Version (:pr:`2121`) + +Version 13.0 +============ +*Released 2020-10-07* + +**For a detailed guide on how to migrate from v12 to v13, see this** `wiki page `_. + +**Major Changes:** + +- Deprecate old-style callbacks, i.e. set ``use_context=True`` by default (:pr:`2050`) +- Refactor Handling of Message VS Update Filters (:pr:`2032`) +- Deprecate ``Message.default_quote`` (:pr:`1965`) +- Refactor persistence of Bot instances (:pr:`1994`) +- Refactor ``JobQueue`` (:pr:`1981`) +- Refactor handling of kwargs in Bot methods (:pr:`1924`) +- Refactor ``Dispatcher.run_async``, deprecating the ``@run_async`` decorator (:pr:`2051`) + +**New Features:** + +- Type Hinting (:pr:`1920`) +- Automatic Pagination for ``answer_inline_query`` (:pr:`2072`) +- ``Defaults.tzinfo`` (:pr:`2042`) +- Extend rich comparison of objects (:pr:`1724`) +- Add ``Filters.via_bot`` (:pr:`2009`) +- Add missing shortcuts (:pr:`2043`) +- Allow ``DispatcherHandlerStop`` in ``ConversationHandler`` (:pr:`2059`) +- Make Errors picklable (:pr:`2106`) + +**Minor changes, CI improvements, doc fixes or bug fixes:** + +- Fix Webhook not working on Windows with Python 3.8+ (:pr:`2067`) +- Fix setting thumbs with ``send_media_group`` (:pr:`2093`) +- Make ``MessageHandler`` filter for ``Filters.update`` first (:pr:`2085`) +- Fix ``PicklePersistence.flush()`` with only ``bot_data`` (:pr:`2017`) +- Add test for clean argument of ``Updater.start_polling/webhook`` (:pr:`2002`) +- Doc fixes, refinements and additions (:pr:`2005`, :pr:`2008`, :pr:`2089`, :pr:`2094`, :pr:`2090`) +- CI fixes (:pr:`2018`, :pr:`2061`) +- Refine ``pollbot.py`` example (:pr:`2047`) +- Refine Filters in examples (:pr:`2027`) +- Rename ``echobot`` examples (:pr:`2025`) +- Use Lock-Bot to lock old threads (:pr:`2048`, :pr:`2052`, :pr:`2049`, :pr:`2053`) + +Version 12.8 +============ +*Released 2020-06-22* + +**Major Changes:** + +- Remove Python 2 support (:pr:`1715`) +- Bot API 4.9 support (:pr:`1980`) +- IDs/Usernames of ``Filters.user`` and ``Filters.chat`` can now be updated (:pr:`1757`) + +**Minor changes, CI improvements, doc fixes or bug fixes:** + +- Update contribution guide and stale bot (:pr:`1937`) +- Remove ``NullHandlers`` (:pr:`1913`) +- Improve and expand examples (:pr:`1943`, :pr:`1995`, :pr:`1983`, :pr:`1997`) +- Doc fixes (:pr:`1940`, :pr:`1962`) +- Add ``User.send_poll()`` shortcut (:pr:`1968`) +- Ignore private attributes en ``TelegramObject.to_dict()`` (:pr:`1989`) +- Stabilize CI (:pr:`2000`) + +Version 12.7 +============ +*Released 2020-05-02* + +**Major Changes:** + +- Bot API 4.8 support. **Note:** The ``Dice`` object now has a second positional argument ``emoji``. This is relevant, if you instantiate ``Dice`` objects manually. (:pr:`1917`) +- Added ``tzinfo`` argument to ``helpers.from_timestamp``. It now returns an timezone aware object. This is relevant for ``Message.{date,forward_date,edit_date}``, ``Poll.close_date`` and ``ChatMember.until_date`` (:pr:`1621`) + +**New Features:** + +- New method ``run_monthly`` for the ``JobQueue`` (:pr:`1705`) +- ``Job.next_t`` now gives the datetime of the jobs next execution (:pr:`1685`) + +**Minor changes, CI improvements, doc fixes or bug fixes:** + +- Stabalize CI (:pr:`1919`, :pr:`1931`) +- Use ABCs ``@abstractmethod`` instead of raising ``NotImplementedError`` for ``Handler``, ``BasePersistence`` and ``BaseFilter`` (:pr:`1905`) +- Doc fixes (:pr:`1914`, :pr:`1902`, :pr:`1910`) + +Version 12.6.1 +============== +*Released 2020-04-11* + +**Bug fixes:** + +- Fix serialization of ``reply_markup`` in media messages (:pr:`1889`) + +Version 12.6 +============ +*Released 2020-04-10* + +**Major Changes:** + +- Bot API 4.7 support. **Note:** In ``Bot.create_new_sticker_set`` and ``Bot.add_sticker_to_set``, the order of the parameters had be changed, as the ``png_sticker`` parameter is now optional. (:pr:`1858`) + +**Minor changes, CI improvements or bug fixes:** + +- Add tests for ``swtich_inline_query(_current_chat)`` with empty string (:pr:`1635`) +- Doc fixes (:pr:`1854`, :pr:`1874`, :pr:`1884`) +- Update issue templates (:pr:`1880`) +- Favor concrete types over "Iterable" (:pr:`1882`) +- Pass last valid ``CallbackContext`` to ``TIMEOUT`` handlers of ``ConversationHandler`` (:pr:`1826`) +- Tweak handling of persistence and update persistence after job calls (:pr:`1827`) +- Use checkout@v2 for GitHub actions (:pr:`1887`) + +Version 12.5.1 +============== +*Released 2020-03-30* + +**Minor changes, doc fixes or bug fixes:** + +- Add missing docs for `PollHandler` and `PollAnswerHandler` (:pr:`1853`) +- Fix wording in `Filters` docs (:pr:`1855`) +- Reorder tests to make them more stable (:pr:`1835`) +- Make `ConversationHandler` attributes immutable (:pr:`1756`) +- Make `PrefixHandler` attributes `command` and `prefix` editable (:pr:`1636`) +- Fix UTC as default `tzinfo` for `Job` (:pr:`1696`) + +Version 12.5 +============ +*Released 2020-03-29* + +**New Features:** + +- `Bot.link` gives the `t.me` link of the bot (:pr:`1770`) + +**Major Changes:** + +- Bot API 4.5 and 4.6 support. (:pr:`1508`, :pr:`1723`) + +**Minor changes, CI improvements or bug fixes:** + +- Remove legacy CI files (:pr:`1783`, :pr:`1791`) +- Update pre-commit config file (:pr:`1787`) +- Remove builtin names (:pr:`1792`) +- CI improvements (:pr:`1808`, :pr:`1848`) +- Support Python 3.8 (:pr:`1614`, :pr:`1824`) +- Use stale bot for auto closing stale issues (:pr:`1820`, :pr:`1829`, :pr:`1840`) +- Doc fixes (:pr:`1778`, :pr:`1818`) +- Fix typo in `edit_message_media` (:pr:`1779`) +- In examples, answer CallbackQueries and use `edit_message_text` shortcut (:pr:`1721`) +- Revert accidental change in vendored urllib3 (:pr:`1775`) + +Version 12.4.2 +============== +*Released 2020-02-10* + +**Bug Fixes** + +- Pass correct parse_mode to InlineResults if bot.defaults is None (:pr:`1763`) +- Make sure PP can read files that dont have bot_data (:pr:`1760`) + +Version 12.4.1 +============== +*Released 2020-02-08* + +This is a quick release for :pr:`1744` which was accidently left out of v12.4.0 though mentioned in the +release notes. + +Version 12.4.0 +============== +*Released 2020-02-08* + +**New features:** + +- Set default values for arguments appearing repeatedly. We also have a `wiki page for the new defaults`_. (:pr:`1490`) +- Store data in ``CallbackContext.bot_data`` to access it in every callback. Also persists. (:pr:`1325`) +- ``Filters.poll`` allows only messages containing a poll (:pr:`1673`) + +**Major changes:** + +- ``Filters.text`` now accepts messages that start with a slash, because ``CommandHandler`` checks for ``MessageEntity.BOT_COMMAND`` since v12. This might lead to your MessageHandlers receiving more updates than before (:pr:`1680`). +- ``Filters.command`` new checks for ``MessageEntity.BOT_COMMAND`` instead of just a leading slash. Also by ``Filters.command(False)`` you can now filters for messages containing a command `anywhere` in the text (:pr:`1744`). + +**Minor changes, CI improvements or bug fixes:** + +- Add ``disptacher`` argument to ``Updater`` to allow passing a customized ``Dispatcher`` (:pr:`1484`) +- Add missing names for ``Filters`` (:pr:`1632`) +- Documentation fixes (:pr:`1624`, :pr:`1647`, :pr:`1669`, :pr:`1703`, :pr:`1718`, :pr:`1734`, :pr:`1740`, :pr:`1642`, :pr:`1739`, :pr:`1746`) +- CI improvements (:pr:`1716`, :pr:`1731`, :pr:`1738`, :pr:`1748`, :pr:`1749`, :pr:`1750`, :pr:`1752`) +- Fix spelling issue for ``encode_conversations_to_json`` (:pr:`1661`) +- Remove double assignement of ``Dispatcher.job_queue`` (:pr:`1698`) +- Expose dispatcher as property for ``CallbackContext`` (:pr:`1684`) +- Fix ``None`` check in ``JobQueue._put()`` (:pr:`1707`) +- Log datetimes correctly in ``JobQueue`` (:pr:`1714`) +- Fix false ``Message.link`` creation for private groups (:pr:`1741`) +- Add option ``--with-upstream-urllib3`` to `setup.py` to allow using non-vendored version (:pr:`1725`) +- Fix persistence for nested ``ConversationHandlers`` (:pr:`1679`) +- Improve handling of non-decodable server responses (:pr:`1623`) +- Fix download for files without ``file_path`` (:pr:`1591`) +- test_webhook_invalid_posts is now considered flaky and retried on failure (:pr:`1758`) + +.. _`wiki page for the new defaults`: https://github.com/python-telegram-bot/python-telegram-bot/wiki/Adding-defaults-to-your-bot + +Version 12.3.0 +============== +*Released 2020-01-11* + +**New features:** + +- `Filters.caption` allows only messages with caption (:pr:`1631`). +- Filter for exact messages/captions with new capability of `Filters.text` and `Filters.caption`. Especially useful in combination with ReplyKeyboardMarkup. (:pr:`1631`). + +**Major changes:** + +- Fix inconsistent handling of naive datetimes (:pr:`1506`). + +**Minor changes, CI improvements or bug fixes:** + +- Documentation fixes (:pr:`1558`, :pr:`1569`, :pr:`1579`, :pr:`1572`, :pr:`1566`, :pr:`1577`, :pr:`1656`). +- Add mutex protection on `ConversationHandler` (:pr:`1533`). +- Add `MAX_PHOTOSIZE_UPLOAD` constant (:pr:`1560`). +- Add args and kwargs to `Message.forward()` (:pr:`1574`). +- Transfer to GitHub Actions CI (:pr:`1555`, :pr:`1556`, :pr:`1605`, :pr:`1606`, :pr:`1607`, :pr:`1612`, :pr:`1615`, :pr:`1645`). +- Fix deprecation warning with Py3.8 by vendored urllib3 (:pr:`1618`). +- Simplify assignements for optional arguments (:pr:`1600`) +- Allow private groups for `Message.link` (:pr:`1619`). +- Fix wrong signature call for `ConversationHandler.TIMEOUT` handlers (:pr:`1653`). + +Version 12.2.0 +============== +*Released 2019-10-14* + +**New features:** + +- Nested ConversationHandlers (:pr:`1512`). + +**Minor changes, CI improvments or bug fixes:** + +- Fix CI failures due to non-backward compat attrs depndency (:pr:`1540`). +- travis.yaml: TEST_OFFICIAL removed from allowed_failures. +- Fix typos in examples (:pr:`1537`). +- Fix Bot.to_dict to use proper first_name (:pr:`1525`). +- Refactor ``test_commandhandler.py`` (:pr:`1408`). +- Add Python 3.8 (RC version) to Travis testing matrix (:pr:`1543`). +- test_bot.py: Add to_dict test (:pr:`1544`). +- Flake config moved into setup.cfg (:pr:`1546`). + +Version 12.1.1 +============== +*Released 2019-09-18* + +**Hot fix release** + +Fixed regression in the vendored urllib3 (:pr:`1517`). + +Version 12.1.0 +================ +*Released 2019-09-13* + +**Major changes:** + +- Bot API 4.4 support (:pr:`1464`, :pr:`1510`) +- Add `get_file` method to `Animation` & `ChatPhoto`. Add, `get_small_file` & `get_big_file` + methods to `ChatPhoto` (:pr:`1489`) +- Tools for deep linking (:pr:`1049`) + +**Minor changes and/or bug fixes:** + +- Documentation fixes (:pr:`1500`, :pr:`1499`) +- Improved examples (:pr:`1502`) + +Version 12.0.0 +================ +*Released 2019-08-29* + +Well... This felt like decades. But here we are with a new release. + +Expect minor releases soon (mainly complete Bot API 4.4 support) + +**Major and/or breaking changes:** + +- Context based callbacks +- Persistence +- PrefixHandler added (Handler overhaul) +- Deprecation of RegexHandler and edited_messages, channel_post, etc. arguments (Filter overhaul) +- Various ConversationHandler changes and fixes +- Bot API 4.1, 4.2, 4.3 support +- Python 3.4 is no longer supported +- Error Handler now handles all types of exceptions (:pr:`1485`) +- Return UTC from from_timestamp() (:pr:`1485`) + +**See the wiki page at https://github.com/python-telegram-bot/python-telegram-bot/wiki/Transition-guide-to-Version-12.0 for a detailed guide on how to migrate from version 11 to version 12.** + +Context based callbacks (:pr:`1100`) +------------------------------------ + +- Use of ``pass_`` in handlers is deprecated. +- Instead use ``use_context=True`` on ``Updater`` or ``Dispatcher`` and change callback from (bot, update, others...) to (update, context). +- This also applies to error handlers ``Dispatcher.add_error_handler`` and JobQueue jobs (change (bot, job) to (context) here). +- For users with custom handlers subclassing Handler, this is mostly backwards compatible, but to use the new context based callbacks you need to implement the new collect_additional_context method. +- Passing bot to ``JobQueue.__init__`` is deprecated. Use JobQueue.set_dispatcher with a dispatcher instead. +- Dispatcher makes sure to use a single `CallbackContext` for a entire update. This means that if an update is handled by multiple handlers (by using the group argument), you can add custom arguments to the `CallbackContext` in a lower group handler and use it in higher group handler. NOTE: Never use with @run_async, see docs for more info. (:pr:`1283`) +- If you have custom handlers they will need to be updated to support the changes in this release. +- Update all examples to use context based callbacks. + +Persistence (:pr:`1017`) +------------------------ + +- Added PicklePersistence and DictPersistence for adding persistence to your bots. +- BasePersistence can be subclassed for all your persistence needs. +- Add a new example that shows a persistent ConversationHandler bot + +Handler overhaul (:pr:`1114`) +----------------------------- + +- CommandHandler now only triggers on actual commands as defined by telegram servers (everything that the clients mark as a tabable link). +- PrefixHandler can be used if you need to trigger on prefixes (like all messages starting with a "/" (old CommandHandler behaviour) or even custom prefixes like "#" or "!"). + +Filter overhaul (:pr:`1221`) +---------------------------- + +- RegexHandler is deprecated and should be replaced with a MessageHandler with a regex filter. +- Use update filters to filter update types instead of arguments (message_updates, channel_post_updates and edited_updates) on the handlers. +- Completely remove allow_edited argument - it has been deprecated for a while. +- data_filters now exist which allows filters that return data into the callback function. This is how the regex filter is implemented. +- All this means that it no longer possible to use a list of filters in a handler. Use bitwise operators instead! + +ConversationHandler +------------------- + +- Remove ``run_async_timeout`` and ``timed_out_behavior`` arguments (:pr:`1344`) +- Replace with ``WAITING`` constant and behavior from states (:pr:`1344`) +- Only emit one warning for multiple CallbackQueryHandlers in a ConversationHandler (:pr:`1319`) +- Use warnings.warn for ConversationHandler warnings (:pr:`1343`) +- Fix unresolvable promises (:pr:`1270`) + +Bug fixes & improvements +------------------------ + +- Handlers should be faster due to deduped logic. +- Avoid compiling compiled regex in regex filter. (:pr:`1314`) +- Add missing ``left_chat_member`` to Message.MESSAGE_TYPES (:pr:`1336`) +- Make custom timeouts actually work properly (:pr:`1330`) +- Add convenience classmethods (from_button, from_row and from_column) to InlineKeyboardMarkup +- Small typo fix in setup.py (:pr:`1306`) +- Add Conflict error (HTTP error code 409) (:pr:`1154`) +- Change MAX_CAPTION_LENGTH to 1024 (:pr:`1262`) +- Remove some unnecessary clauses (:pr:`1247`, :pr:`1239`) +- Allow filenames without dots in them when sending files (:pr:`1228`) +- Fix uploading files with unicode filenames (:pr:`1214`) +- Replace http.server with Tornado (:pr:`1191`) +- Allow SOCKSConnection to parse username and password from URL (:pr:`1211`) +- Fix for arguments in passport/data.py (:pr:`1213`) +- Improve message entity parsing by adding text_mention (:pr:`1206`) +- Documentation fixes (:pr:`1348`, :pr:`1397`, :pr:`1436`) +- Merged filters short-circuit (:pr:`1350`) +- Fix webhook listen with tornado (:pr:`1383`) +- Call task_done() on update queue after update processing finished (:pr:`1428`) +- Fix send_location() - latitude may be 0 (:pr:`1437`) +- Make MessageEntity objects comparable (:pr:`1465`) +- Add prefix to thread names (:pr:`1358`) + +Buf fixes since v12.0.0b1 +------------------------- + +- Fix setting bot on ShippingQuery (:pr:`1355`) +- Fix _trigger_timeout() missing 1 required positional argument: 'job' (:pr:`1367`) +- Add missing message.text check in PrefixHandler check_update (:pr:`1375`) +- Make updates persist even on DispatcherHandlerStop (:pr:`1463`) +- Dispatcher force updating persistence object's chat data attribute(:pr:`1462`) + +Internal improvements +--------------------- + +- Finally fix our CI builds mostly (too many commits and PRs to list) +- Use multiple bots for CI to improve testing times significantly. +- Allow pypy to fail in CI. +- Remove the last CamelCase CheckUpdate methods from the handlers we missed earlier. +- test_official is now executed in a different job + +Version 11.1.0 +============== +*Released 2018-09-01* + +Fixes and updates for Telegram Passport: (:pr:`1198`) + +- Fix passport decryption failing at random times +- Added support for middle names. +- Added support for translations for documents +- Add errors for translations for documents +- Added support for requesting names in the language of the user's country of residence +- Replaced the payload parameter with the new parameter nonce +- Add hash to EncryptedPassportElement + +Version 11.0.0 +============== +*Released 2018-08-29* + +Fully support Bot API version 4.0! +(also some bugfixes :)) + +Telegram Passport (:pr:`1174`): + +- Add full support for telegram passport. + - New types: PassportData, PassportFile, EncryptedPassportElement, EncryptedCredentials, PassportElementError, PassportElementErrorDataField, PassportElementErrorFrontSide, PassportElementErrorReverseSide, PassportElementErrorSelfie, PassportElementErrorFile and PassportElementErrorFiles. + - New bot method: set_passport_data_errors + - New filter: Filters.passport_data + - Field passport_data field on Message + - PassportData can be easily decrypted. + - PassportFiles are automatically decrypted if originating from decrypted PassportData. +- See new passportbot.py example for details on how to use, or go to `our telegram passport wiki page`_ for more info +- NOTE: Passport decryption requires new dependency `cryptography`. + +Inputfile rework (:pr:`1184`): + +- Change how Inputfile is handled internally +- This allows support for specifying the thumbnails of photos and videos using the thumb= argument in the different send\_ methods. +- Also allows Bot.send_media_group to actually finally send more than one media. +- Add thumb to Audio, Video and Videonote +- Add Bot.edit_message_media together with InputMediaAnimation, InputMediaAudio, and inputMediaDocument. + +Other Bot API 4.0 changes: + +- Add forusquare_type to Venue, InlineQueryResultVenue, InputVenueMessageContent, and Bot.send_venue. (:pr:`1170`) +- Add vCard support by adding vcard field to Contact, InlineQueryResultContact, InputContactMessageContent, and Bot.send_contact. (:pr:`1166`) +- Support new message entities: CASHTAG and PHONE_NUMBER. (:pr:`1179`) + - Cashtag seems to be things like `$USD` and `$GBP`, but it seems telegram doesn't currently send them to bots. + - Phone number also seems to have limited support for now +- Add Bot.send_animation, add width, height, and duration to Animation, and add Filters.animation. (:pr:`1172`) + +Non Bot API 4.0 changes: + +- Minor integer comparison fix (:pr:`1147`) +- Fix Filters.regex failing on non-text message (:pr:`1158`) +- Fix ProcessLookupError if process finishes before we kill it (:pr:`1126`) +- Add t.me links for User, Chat and Message if available and update User.mention_* (:pr:`1092`) +- Fix mention_markdown/html on py2 (:pr:`1112`) + +.. _`our telegram passport wiki page`: https://github.com/python-telegram-bot/python-telegram-bot/wiki/Telegram-Passport + +Version 10.1.0 +============== +*Released 2018-05-02* + +Fixes changing previous behaviour: + +- Add urllib3 fix for socks5h support (:pr:`1085`) +- Fix send_sticker() timeout=20 (:pr:`1088`) + +Fixes: + +- Add a caption_entity filter for filtering caption entities (:pr:`1068`) +- Inputfile encode filenames (:pr:`1086`) +- InputFile: Fix proper naming of file when reading from subprocess.PIPE (:pr:`1079`) +- Remove pytest-catchlog from requirements (:pr:`1099`) +- Documentation fixes (:pr:`1061`, :pr:`1078`, :pr:`1081`, :pr:`1096`) + +Version 10.0.2 +============== +*Released 2018-04-17* + +Important fix: + +- Handle utf8 decoding errors (:pr:`1076`) + +New features: + +- Added Filter.regex (:pr:`1028`) +- Filters for Category and file types (:pr:`1046`) +- Added video note filter (:pr:`1067`) + +Fixes: + +- Fix in telegram.Message (:pr:`1042`) +- Make chat_id a positional argument inside shortcut methods of Chat and User classes (:pr:`1050`) +- Make Bot.full_name return a unicode object. (:pr:`1063`) +- CommandHandler faster check (:pr:`1074`) +- Correct documentation of Dispatcher.add_handler (:pr:`1071`) +- Various small fixes to documentation. + +Version 10.0.1 +============== +*Released 2018-03-05* + +Fixes: + +- Fix conversationhandler timeout (PR :pr:`1032`) +- Add missing docs utils (PR :pr:`912`) + +Version 10.0.0 +============== +*Released 2018-03-02* + +Non backward compatabile changes and changed defaults + +- JobQueue: Remove deprecated prevent_autostart & put() (PR :pr:`1012`) +- Bot, Updater: Remove deprecated network_delay (PR :pr:`1012`) +- Remove deprecated Message.new_chat_member (PR :pr:`1012`) +- Retry bootstrap phase indefinitely (by default) on network errors (PR :pr:`1018`) + +New Features + +- Support v3.6 API (PR :pr:`1006`) +- User.full_name convinience property (PR :pr:`949`) +- Add `send_phone_number_to_provider` and `send_email_to_provider` arguments to send_invoice (PR :pr:`986`) +- Bot: Add shortcut methods reply_{markdown,html} (PR :pr:`827`) +- Bot: Add shortcut method reply_media_group (PR :pr:`994`) +- Added utils.helpers.effective_message_type (PR :pr:`826`) +- Bot.get_file now allows passing a file in addition to file_id (PR :pr:`963`) +- Add .get_file() to Audio, Document, PhotoSize, Sticker, Video, VideoNote and Voice (PR :pr:`963`) +- Add .send_*() methods to User and Chat (PR :pr:`963`) +- Get jobs by name (PR :pr:`1011`) +- Add Message caption html/markdown methods (PR :pr:`1013`) +- File.download_as_bytearray - new method to get a d/led file as bytearray (PR :pr:`1019`) +- File.download(): Now returns a meaningful return value (PR :pr:`1019`) +- Added conversation timeout in ConversationHandler (PR :pr:`895`) + +Changes + +- Store bot in PreCheckoutQuery (PR :pr:`953`) +- Updater: Issue INFO log upon received signal (PR :pr:`951`) +- JobQueue: Thread safety fixes (PR :pr:`977`) +- WebhookHandler: Fix exception thrown during error handling (PR :pr:`985`) +- Explicitly check update.effective_chat in ConversationHandler.check_update (PR :pr:`959`) +- Updater: Better handling of timeouts during get_updates (PR :pr:`1007`) +- Remove unnecessary to_dict() (PR :pr:`834`) +- CommandHandler - ignore strings in entities and "/" followed by whitespace (PR :pr:`1020`) +- Documentation & style fixes (PR :pr:`942`, PR :pr:`956`, PR :pr:`962`, PR :pr:`980`, PR :pr:`983`) + +Version 9.0.0 +============= +*Released 2017-12-08* + +Breaking changes (possibly) + +- Drop support for python 3.3 (PR :pr:`930`) + +New Features + +- Support Bot API 3.5 (PR :pr:`920`) + +Changes + +- Fix race condition in dispatcher start/stop (:pr:`887`) +- Log error trace if there is no error handler registered (:pr:`694`) +- Update examples with consistent string formatting (:pr:`870`) +- Various changes and improvements to the docs. + +Version 8.1.1 +============= +*Released 2017-10-15* + +- Fix Commandhandler crashing on single character messages (PR :pr:`873`). + +Version 8.1.0 +============= +*Released 2017-10-14* + +New features +- Support Bot API 3.4 (PR :pr:`865`). + +Changes +- MessageHandler & RegexHandler now consider channel_updates. +- Fix command not recognized if it is directly followed by a newline (PR :pr:`869`). +- Removed Bot._message_wrapper (PR :pr:`822`). +- Unitests are now also running on AppVeyor (Windows VM). +- Various unitest improvements. +- Documentation fixes. + +Version 8.0.0 +============= +*Released 2017-09-01* + +New features + +- Fully support Bot Api 3.3 (PR :pr:`806`). +- DispatcherHandlerStop (`see docs`_). +- Regression fix for text_html & text_markdown (PR :pr:`777`). +- Added effective_attachment to message (PR :pr:`766`). + +Non backward compatible changes + +- Removed Botan support from the library (PR :pr:`776`). +- Fully support Bot Api 3.3 (PR :pr:`806`). +- Remove de_json() (PR :pr:`789`). + +Changes + +- Sane defaults for tcp socket options on linux (PR :pr:`754`). +- Add RESTRICTED as constant to ChatMember (PR :pr:`761`). +- Add rich comparison to CallbackQuery (PR :pr:`764`). +- Fix get_game_high_scores (PR :pr:`771`). +- Warn on small con_pool_size during custom initalization of Updater (PR :pr:`793`). +- Catch exceptions in error handlerfor errors that happen during polling (PR :pr:`810`). +- For testing we switched to pytest (PR :pr:`788`). +- Lots of small improvements to our tests and documentation. + +.. _`see docs`: https://docs.python-telegram-bot.org/en/v13.11/telegram.ext.dispatcher.html?highlight=Dispatcher.add_handler#telegram.ext.Dispatcher.add_handler + +Version 7.0.1 +=============== +*Released 2017-07-28* + +- Fix TypeError exception in RegexHandler (PR #751). +- Small documentation fix (PR #749). + +Version 7.0.0 +============= +*Released 2017-07-25* + +- Fully support Bot API 3.2. +- New filters for handling messages from specific chat/user id (PR #677). +- Add the possibility to add objects as arguments to send_* methods (PR #742). +- Fixed download of URLs with UTF-8 chars in path (PR #688). +- Fixed URL parsing for ``Message`` text properties (PR #689). +- Fixed args dispatching in ``MessageQueue``'s decorator (PR #705). +- Fixed regression preventing IPv6 only hosts from connnecting to Telegram servers (Issue #720). +- ConvesationHandler - check if a user exist before using it (PR #699). +- Removed deprecated ``telegram.Emoji``. +- Removed deprecated ``Botan`` import from ``utils`` (``Botan`` is still available through ``contrib``). +- Removed deprecated ``ReplyKeyboardHide``. +- Removed deprecated ``edit_message`` argument of ``bot.set_game_score``. +- Internal restructure of files. +- Improved documentation. +- Improved unitests. + +Pre-version 7.0 +=============== + +**2017-06-18** + +*Released 6.1.0* + +- Fully support Bot API 3.0 +- Add more fine-grained filters for status updates +- Bug fixes and other improvements + +**2017-05-29** + +*Released 6.0.3* + +- Faulty PyPI release + +**2017-05-29** + +*Released 6.0.2* + +- Avoid confusion with user's ``urllib3`` by renaming vendored ``urllib3`` to ``ptb_urllib3`` + +**2017-05-19** + +*Released 6.0.1* + +- Add support for ``User.language_code`` +- Fix ``Message.text_html`` and ``Message.text_markdown`` for messages with emoji + +**2017-05-19** + +*Released 6.0.0* + +- Add support for Bot API 2.3.1 +- Add support for ``deleteMessage`` API method +- New, simpler API for ``JobQueue`` - :pr:`484` +- Download files into file-like objects - :pr:`459` +- Use vendor ``urllib3`` to address issues with timeouts + - The default timeout for messages is now 5 seconds. For sending media, the default timeout is now 20 seconds. +- String attributes that are not set are now ``None`` by default, instead of empty strings +- Add ``text_markdown`` and ``text_html`` properties to ``Message`` - :pr:`507` +- Add support for Socks5 proxy - :pr:`518` +- Add support for filters in ``CommandHandler`` - :pr:`536` +- Add the ability to invert (not) filters - :pr:`552` +- Add ``Filters.group`` and ``Filters.private`` +- Compatibility with GAE via ``urllib3.contrib`` package - :pr:`583` +- Add equality rich comparision operators to telegram objects - :pr:`604` +- Several bugfixes and other improvements +- Remove some deprecated code + +**2017-04-17** + +*Released 5.3.1* + +- Hotfix release due to bug introduced by urllib3 version 1.21 + +**2016-12-11** + +*Released 5.3* + +- Implement API changes of November 21st (Bot API 2.3) +- ``JobQueue`` now supports ``datetime.timedelta`` in addition to seconds +- ``JobQueue`` now supports running jobs only on certain days +- New ``Filters.reply`` filter +- Bugfix for ``Message.edit_reply_markup`` +- Other bugfixes + +**2016-10-25** + +*Released 5.2* + +- Implement API changes of October 3rd (games update) +- Add ``Message.edit_*`` methods +- Filters for the ``MessageHandler`` can now be combined using bitwise operators (``& and |``) +- Add a way to save user- and chat-related data temporarily +- Other bugfixes and improvements + +**2016-09-24** + +*Released 5.1* + +- Drop Python 2.6 support +- Deprecate ``telegram.Emoji`` + +- Use ``ujson`` if available +- Add instance methods to ``Message``, ``Chat``, ``User``, ``InlineQuery`` and ``CallbackQuery`` +- RegEx filtering for ``CallbackQueryHandler`` and ``InlineQueryHandler`` +- New ``MessageHandler`` filters: ``forwarded`` and ``entity`` +- Add ``Message.get_entity`` to correctly handle UTF-16 codepoints and ``MessageEntity`` offsets +- Fix bug in ``ConversationHandler`` when first handler ends the conversation +- Allow multiple ``Dispatcher`` instances +- Add ``ChatMigrated`` Exception +- Properly split and handle arguments in ``CommandHandler`` + +**2016-07-15** + +*Released 5.0* + +- Rework ``JobQueue`` +- Introduce ``ConversationHandler`` +- Introduce ``telegram.constants`` - :pr:`342` + +**2016-07-12** + +*Released 4.3.4* + +- Fix proxy support with ``urllib3`` when proxy requires auth + +**2016-07-08** + +*Released 4.3.3* + +- Fix proxy support with ``urllib3`` + +**2016-07-04** + +*Released 4.3.2* + +- Fix: Use ``timeout`` parameter in all API methods + +**2016-06-29** + +*Released 4.3.1* + +- Update wrong requirement: ``urllib3>=1.10`` + +**2016-06-28** + +*Released 4.3* + +- Use ``urllib3.PoolManager`` for connection re-use +- Rewrite ``run_async`` decorator to re-use threads +- New requirements: ``urllib3`` and ``certifi`` + +**2016-06-10** + +*Released 4.2.1* + +- Fix ``CallbackQuery.to_dict()`` bug (thanks to @jlmadurga) +- Fix ``editMessageText`` exception when receiving a ``CallbackQuery`` + +**2016-05-28** + +*Released 4.2* + +- Implement Bot API 2.1 +- Move ``botan`` module to ``telegram.contrib`` +- New exception type: ``BadRequest`` + +**2016-05-22** + +*Released 4.1.2* + +- Fix ``MessageEntity`` decoding with Bot API 2.1 changes + +**2016-05-16** + +*Released 4.1.1* + +- Fix deprecation warning in ``Dispatcher`` + +**2016-05-15** + +*Released 4.1* + +- Implement API changes from May 6, 2016 +- Fix bug when ``start_polling`` with ``clean=True`` +- Methods now have snake_case equivalent, for example ``telegram.Bot.send_message`` is the same as ``telegram.Bot.sendMessage`` + +**2016-05-01** + +*Released 4.0.3* + +- Add missing attribute ``location`` to ``InlineQuery`` + +**2016-04-29** + +*Released 4.0.2* + +- Bugfixes +- ``KeyboardReplyMarkup`` now accepts ``str`` again + +**2016-04-27** + +*Released 4.0.1* + +- Implement Bot API 2.0 +- Almost complete recode of ``Dispatcher`` +- Please read the `Transition Guide to 4.0 `_ +- **Changes from 4.0rc1** + - The syntax of filters for ``MessageHandler`` (upper/lower cases) + - Handler groups are now identified by ``int`` only, and ordered +- **Note:** v4.0 has been skipped due to a PyPI accident + +**2016-04-22** + +*Released 4.0rc1* + +- Implement Bot API 2.0 +- Almost complete recode of ``Dispatcher`` +- Please read the `Transistion Guide to 4.0 `_ + +**2016-03-22** + +*Released 3.4* + +- Move ``Updater``, ``Dispatcher`` and ``JobQueue`` to new ``telegram.ext`` submodule (thanks to @rahiel) +- Add ``disable_notification`` parameter (thanks to @aidarbiktimirov) +- Fix bug where commands sent by Telegram Web would not be recognized (thanks to @shelomentsevd) +- Add option to skip old updates on bot startup +- Send files from ``BufferedReader`` + +**2016-02-28** + +*Released 3.3* + +- Inline bots +- Send any file by URL +- Specialized exceptions: ``Unauthorized``, ``InvalidToken``, ``NetworkError`` and ``TimedOut`` +- Integration for botan.io (thanks to @ollmer) +- HTML Parsemode (thanks to @jlmadurga) +- Bugfixes and under-the-hood improvements + +**Very special thanks to Noam Meltzer (@tsnoam) for all of his work!** + +**2016-01-09** + +*Released 3.3b1* + +- Implement inline bots (beta) + +**2016-01-05** + +*Released 3.2.0* + +- Introducing ``JobQueue`` (original author: @franciscod) +- Streamlining all exceptions to ``TelegramError`` (Special thanks to @tsnoam) +- Proper locking of ``Updater`` and ``Dispatcher`` ``start`` and ``stop`` methods +- Small bugfixes + +**2015-12-29** + +*Released 3.1.2* + +- Fix custom path for file downloads +- Don't stop the dispatcher thread on uncaught errors in handlers + +**2015-12-21** + +*Released 3.1.1* + +- Fix a bug where asynchronous handlers could not have additional arguments +- Add ``groups`` and ``groupdict`` as additional arguments for regex-based handlers + +**2015-12-16** + +*Released 3.1.0* + +- The ``chat``-field in ``Message`` is now of type ``Chat``. (API update Oct 8 2015) +- ``Message`` now contains the optional fields ``supergroup_chat_created``, ``migrate_to_chat_id``, ``migrate_from_chat_id`` and ``channel_chat_created``. (API update Nov 2015) + +**2015-12-08** + +*Released 3.0.0* + +- Introducing the ``Updater`` and ``Dispatcher`` classes + +**2015-11-11** + +*Released 2.9.2* + +- Error handling on request timeouts has been improved + +**2015-11-10** + +*Released 2.9.1* + +- Add parameter ``network_delay`` to Bot.getUpdates for slow connections + +**2015-11-10** + +*Released 2.9* + +- Emoji class now uses ``bytes_to_native_str`` from ``future`` 3rd party lib +- Make ``user_from`` optional to work with channels +- Raise exception if Telegram times out on long-polling + +*Special thanks to @jh0ker for all hard work* + +**2015-10-08** + +*Released 2.8.7* + +- Type as optional for ``GroupChat`` class + +**2015-10-08** + +*Released 2.8.6* + +- Adds type to ``User`` and ``GroupChat`` classes (pre-release Telegram feature) + +**2015-09-24** + +*Released 2.8.5* + +- Handles HTTP Bad Gateway (503) errors on request +- Fixes regression on ``Audio`` and ``Document`` for unicode fields + +**2015-09-20** + +*Released 2.8.4* + +- ``getFile`` and ``File.download`` is now fully supported + +**2015-09-10** + +*Released 2.8.3* + +- Moved ``Bot._requestURL`` to its own class (``telegram.utils.request``) +- Much better, such wow, Telegram Objects tests +- Add consistency for ``str`` properties on Telegram Objects +- Better design to test if ``chat_id`` is invalid +- Add ability to set custom filename on ``Bot.sendDocument(..,filename='')`` +- Fix Sticker as ``InputFile`` +- Send JSON requests over urlencoded post data +- Markdown support for ``Bot.sendMessage(..., parse_mode=ParseMode.MARKDOWN)`` +- Refactor of ``TelegramError`` class (no more handling ``IOError`` or ``URLError``) + +**2015-09-05** + +*Released 2.8.2* + +- Fix regression on Telegram ReplyMarkup +- Add certificate to ``is_inputfile`` method + +**2015-09-05** + +*Released 2.8.1* + +- Fix regression on Telegram objects with thumb properties + +**2015-09-04** + +*Released 2.8* + +- TelegramError when ``chat_id`` is empty for send* methods +- ``setWebhook`` now supports sending self-signed certificate +- Huge redesign of existing Telegram classes +- Added support for PyPy +- Added docstring for existing classes + +**2015-08-19** + +*Released 2.7.1* + +- Fixed JSON serialization for ``message`` + +**2015-08-17** + +*Released 2.7* + +- Added support for ``Voice`` object and ``sendVoice`` method +- Due backward compatibility performer or/and title will be required for ``sendAudio`` +- Fixed JSON serialization when forwarded message + +**2015-08-15** + +*Released 2.6.1* + +- Fixed parsing image header issue on < Python 2.7.3 + +**2015-08-14** + +*Released 2.6.0* + +- Depreciation of ``require_authentication`` and ``clearCredentials`` methods +- Giving ``AUTHORS`` the proper credits for their contribution for this project +- ``Message.date`` and ``Message.forward_date`` are now ``datetime`` objects + +**2015-08-12** + +*Released 2.5.3* + +- ``telegram.Bot`` now supports to be unpickled + +**2015-08-11** + +*Released 2.5.2* + +- New changes from Telegram Bot API have been applied +- ``telegram.Bot`` now supports to be pickled +- Return empty ``str`` instead ``None`` when ``message.text`` is empty + +**2015-08-10** + +*Released 2.5.1* + +- Moved from GPLv2 to LGPLv3 + +**2015-08-09** + +*Released 2.5* + +- Fixes logging calls in API + +**2015-08-08** + +*Released 2.4* + +- Fixes ``Emoji`` class for Python 3 +- ``PEP8`` improvements + +**2015-08-08** + +*Released 2.3* + +- Fixes ``ForceReply`` class +- Remove ``logging.basicConfig`` from library + +**2015-07-25** + +*Released 2.2* + +- Allows ``debug=True`` when initializing ``telegram.Bot`` + +**2015-07-20** + +*Released 2.1* + +- Fix ``to_dict`` for ``Document`` and ``Video`` + +**2015-07-19** + +*Released 2.0* + +- Fixes bugs +- Improves ``__str__`` over ``to_json()`` +- Creates abstract class ``TelegramObject`` + +**2015-07-15** + +*Released 1.9* + +- Python 3 officially supported +- ``PEP8`` improvements + +**2015-07-12** + +*Released 1.8* + +- Fixes crash when replying an unicode text message (special thanks to JRoot3D) + +**2015-07-11** + +*Released 1.7* + +- Fixes crash when ``username`` is not defined on ``chat`` (special thanks to JRoot3D) + +**2015-07-10** + +*Released 1.6* + +- Improvements for GAE support + +**2015-07-10** + +*Released 1.5* + +- Fixes randomly unicode issues when using ``InputFile`` + +**2015-07-10** + +*Released 1.4* + +- ``requests`` lib is no longer required +- Google App Engine (GAE) is supported + +**2015-07-10** + +*Released 1.3* + +- Added support to ``setWebhook`` (special thanks to macrojames) + +**2015-07-09** + +*Released 1.2* + +- ``CustomKeyboard`` classes now available +- Emojis available +- ``PEP8`` improvements + +**2015-07-08** + +*Released 1.1* + +- PyPi package now available + +**2015-07-08** + +*Released 1.0* + +- Initial checkin of python-telegram-bot diff --git a/changes/config.py b/changes/config.py new file mode 100644 index 00000000000..aab47484def --- /dev/null +++ b/changes/config.py @@ -0,0 +1,104 @@ +# noqa: INP001 +# pylint: disable=import-error +"""Configuration for the chango changelog tool""" + +import re +from collections.abc import Collection +from pathlib import Path + +from chango import Version +from chango.concrete import DirectoryChanGo, DirectoryVersionScanner, HeaderVersionHistory +from chango.concrete.sections import GitHubSectionChangeNote, Section, SectionVersionNote + +version_scanner = DirectoryVersionScanner(base_directory=".", unreleased_directory="unreleased") + + +class ChangoSectionChangeNote( + GitHubSectionChangeNote.with_sections( # type: ignore[misc] + [ + Section(uid="highlights", title="Highlights", sort_order=0), + Section(uid="breaking", title="Breaking Changes", sort_order=1), + Section(uid="security", title="Security Changes", sort_order=2), + Section(uid="deprecations", title="Deprecations", sort_order=3), + Section(uid="features", title="New Features", sort_order=4), + Section(uid="bugfixes", title="Bug Fixes", sort_order=5), + Section(uid="dependencies", title="Dependencies", sort_order=6), + Section(uid="other", title="Other Changes", sort_order=7), + Section(uid="documentation", title="Documentation", sort_order=8), + Section(uid="internal", title="Internal Changes", sort_order=9), + ] + ) +): + """Custom change note type for PTB. Mainly overrides get_sections to map labels to sections""" + + OWNER = "python-telegram-bot" + REPOSITORY = "python-telegram-bot" + + @classmethod + def get_sections( + cls, + labels: Collection[str], + issue_types: Collection[str] | None, + ) -> set[str]: + """Override get_sections to have customized auto-detection of relevant sections based on + the pull request and linked issues. Certainly not perfect in all cases, but should be a + good start for most PRs. + """ + combined_labels = set(labels) | (set(issue_types or [])) + + mapping = { + "🐛 bug": "bugfixes", + "💡 feature": "features", + "🧹 chore": "internal", + "⚙️ bot-api": "features", + "⚙️ documentation": "documentation", + "⚙️ tests": "internal", + "⚙️ ci-cd": "internal", + "⚙️ security": "security", + "⚙️ examples": "documentation", + "⚙️ type-hinting": "other", + "🛠 refactor": "internal", + "🛠 breaking": "breaking", + "⚙️ dependencies": "dependencies", + "🔗 github-actions": "internal", + "🛠 code-quality": "internal", + } + + # we want to return *all* from the mapping that are in the combined_labels + # removing superfluous sections from the fragment is a tad easier than adding them + found = {section for label, section in mapping.items() if label in combined_labels} + + # if we have not found any sections, we default to "other" + return found or {"other"} + + +class CustomChango(DirectoryChanGo): + """Custom ChanGo class for overriding release""" + + def release(self, version: Version) -> bool: + """replace "14.5" with version.uid except in the contrib guide + then call super + """ + root = Path(__file__).parent.parent / "src" + python_files = root.rglob("*.py") + pattern = re.compile(r"NEXT\.VERSION") + excluded_paths = {root / "docs/source/contribute.rst"} + for file_path in python_files: + if str(file_path) in excluded_paths: + continue + + content = file_path.read_text(encoding="utf-8") + modified = pattern.sub(version.uid, content) + + if content != modified: + file_path.write_text(modified, encoding="utf-8") + + return super().release(version) + + +chango_instance = CustomChango( + change_note_type=ChangoSectionChangeNote, + version_note_type=SectionVersionNote, + version_history_type=HeaderVersionHistory, + scanner=version_scanner, +) diff --git a/telegram/_files/__init__.py b/changes/unreleased/.gitkeep similarity index 100% rename from telegram/_files/__init__.py rename to changes/unreleased/.gitkeep diff --git a/changes/unreleased/5110.NfCAazzRFNXvNn9CNmMZpZ.toml b/changes/unreleased/5110.NfCAazzRFNXvNn9CNmMZpZ.toml new file mode 100644 index 00000000000..c9fbd9ec0c2 --- /dev/null +++ b/changes/unreleased/5110.NfCAazzRFNXvNn9CNmMZpZ.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.14.14" +[[pull_requests]] +uid = "5110" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5112.Na2dk8jzBTtTg3EvNSo6kh.toml b/changes/unreleased/5112.Na2dk8jzBTtTg3EvNSo6kh.toml new file mode 100644 index 00000000000..e3a74dc00b9 --- /dev/null +++ b/changes/unreleased/5112.Na2dk8jzBTtTg3EvNSo6kh.toml @@ -0,0 +1,5 @@ +internal = "Update codecov/codecov-action action to v5.5.2" +[[pull_requests]] +uid = "5112" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5113.YfEbfC8WGAxMFb8rKBCUej.toml b/changes/unreleased/5113.YfEbfC8WGAxMFb8rKBCUej.toml new file mode 100644 index 00000000000..331e991fe41 --- /dev/null +++ b/changes/unreleased/5113.YfEbfC8WGAxMFb8rKBCUej.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.9.28" +[[pull_requests]] +uid = "5113" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5114.LGfvVo5cdK74VyVWHFJDW3.toml b/changes/unreleased/5114.LGfvVo5cdK74VyVWHFJDW3.toml new file mode 100644 index 00000000000..fe90c627e2a --- /dev/null +++ b/changes/unreleased/5114.LGfvVo5cdK74VyVWHFJDW3.toml @@ -0,0 +1,5 @@ +internal = "Update dependency pytest to v9.0.2" +[[pull_requests]] +uid = "5114" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5115.CgTBPjBCWJ7reRZG4vu8vJ.toml b/changes/unreleased/5115.CgTBPjBCWJ7reRZG4vu8vJ.toml new file mode 100644 index 00000000000..b28bba852e6 --- /dev/null +++ b/changes/unreleased/5115.CgTBPjBCWJ7reRZG4vu8vJ.toml @@ -0,0 +1,5 @@ +internal = "Update actions/setup-python action to v6.2.0" +[[pull_requests]] +uid = "5115" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/unreleased/5116.9YKyNfUgP73QatTcuD5qNm.toml b/changes/unreleased/5116.9YKyNfUgP73QatTcuD5qNm.toml new file mode 100644 index 00000000000..b3191a9243e --- /dev/null +++ b/changes/unreleased/5116.9YKyNfUgP73QatTcuD5qNm.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v7.2.1" +[[pull_requests]] +uid = "5116" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/telegram/_games/__init__.py b/docs/__init__.py similarity index 100% rename from telegram/_games/__init__.py rename to docs/__init__.py diff --git a/telegram/_inline/__init__.py b/docs/auxil/__init__.py similarity index 100% rename from telegram/_inline/__init__.py rename to docs/auxil/__init__.py diff --git a/docs/auxil/admonition_inserter.py b/docs/auxil/admonition_inserter.py index 870d02f0ab5..945492dd7a3 100644 --- a/docs/auxil/admonition_inserter.py +++ b/docs/auxil/admonition_inserter.py @@ -1,6 +1,6 @@ # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,17 +16,55 @@ # 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 collections.abc +import contextlib import inspect import re +import types import typing from collections import defaultdict -from typing import Any, Iterator, Union +from collections.abc import Iterator +from socket import socket +from types import FunctionType + +from apscheduler.job import Job as APSJob import telegram +import telegram._utils.defaultvalue +import telegram._utils.types import telegram.ext +import telegram.ext._utils.types +from tests.auxil.slots import mro_slots + +# Define the namespace for type resolution. This helps dealing with the internal imports that +# we do in many places +# The .copy() is important to avoid modifying the original namespace +TG_NAMESPACE = vars(telegram).copy() +TG_NAMESPACE.update(vars(telegram._utils.types)) +TG_NAMESPACE.update(vars(telegram._utils.defaultvalue)) +TG_NAMESPACE.update(vars(telegram.ext)) +TG_NAMESPACE.update(vars(telegram.ext._utils.types)) +TG_NAMESPACE.update(vars(telegram.ext._applicationbuilder)) +TG_NAMESPACE.update({"socket": socket, "APSJob": APSJob}) + + +class PublicMethod(typing.NamedTuple): + name: str + method: FunctionType + + +def _is_inherited_method(cls: type, method_name: str) -> bool: + """Checks if a method is inherited from a parent class. + Inheritance is not considered if the parent class is private. + Recurses through all direcot or indirect parent classes. + """ + # The [1:] slice is used to exclude the class itself from the MRO. + for base in cls.__mro__[1:]: + if method_name in base.__dict__ and not base.__name__.startswith("_"): + return True + return False -def _iter_own_public_methods(cls: type) -> Iterator[tuple[str, type]]: +def _iter_own_public_methods(cls: type) -> Iterator[PublicMethod]: """Iterates over methods of a class that are not protected/private, not camelCase and not inherited from the parent class. @@ -34,13 +72,15 @@ def _iter_own_public_methods(cls: type) -> Iterator[tuple[str, type]]: This function is defined outside the class because it is used to create class constants. """ - return ( - m - for m in inspect.getmembers(cls, predicate=inspect.isfunction) # not .ismethod - if not m[0].startswith("_") - and m[0].islower() # to avoid camelCase methods - and m[0] in cls.__dict__ # method is not inherited from parent class - ) + + # Use .isfunction() instead of .ismethod() because we want to include static methods. + for m in inspect.getmembers(cls, predicate=inspect.isfunction): + if ( + not m[0].startswith("_") + and m[0].islower() # to avoid camelCase methods + and not _is_inherited_method(cls, m[0]) + ): + yield PublicMethod(m[0], m[1]) class AdmonitionInserter: @@ -57,24 +97,18 @@ class AdmonitionInserter: start and end markers. """ - FORWARD_REF_SKIP_PATTERN = re.compile(r"^ForwardRef\('DefaultValue\[\w+]'\)$") - """A pattern that will be used to skip known ForwardRef's that need not be resolved - to a Telegram class, e.g.: - ForwardRef('DefaultValue[None]') - ForwardRef('DefaultValue[DVValueType]') - """ - - METHOD_NAMES_FOR_BOT_AND_APPBUILDER: dict[type, str] = { - cls: tuple(m[0] for m in _iter_own_public_methods(cls)) # m[0] means we take only names - for cls in (telegram.Bot, telegram.ext.ApplicationBuilder) + METHOD_NAMES_FOR_BOT_APP_APPBUILDER: typing.ClassVar[dict[type, str]] = { + cls: tuple(m.name for m in _iter_own_public_methods(cls)) + for cls in (telegram.Bot, telegram.ext.ApplicationBuilder, telegram.ext.Application) } - """A dictionary mapping Bot and ApplicationBuilder classes to their relevant methods that will + """A dictionary mapping Bot, Application & ApplicationBuilder classes to their relevant methods + that will be mentioned in 'Returned in' and 'Use in' admonitions in other classes' docstrings. Methods must be public, not aliases, not inherited from TelegramObject. """ def __init__(self): - self.admonitions: dict[str, dict[Union[type, collections.abc.Callable], str]] = { + self.admonitions: dict[str, dict[type | collections.abc.Callable, str]] = { # dynamically determine which method to use to create a sub-dictionary admonition_type: getattr(self, f"_create_{admonition_type}")() for admonition_type in self.ALL_ADMONITION_TYPES @@ -82,20 +116,27 @@ def __init__(self): """Dictionary with admonitions. Contains sub-dictionaries, one per admonition type. Each sub-dictionary matches bot methods (for "Shortcuts") or telegram classes (for other admonition types) to texts of admonitions, e.g.: + ``` { - "use_in": {: - <"Use in" admonition for ChatInviteLink>, ...}, - "available_in": {: - <"Available in" admonition">, ...}, - "returned_in": {...} + "use_in": { + : + <"Use in" admonition for ChatInviteLink>, + ... + }, + "available_in": { + : + <"Available in" admonition">, + ... + }, + "returned_in": {...} } ``` """ def insert_admonitions( self, - obj: Union[type, collections.abc.Callable], + obj: type | collections.abc.Callable, docstring_lines: list[str], ): """Inserts admonitions into docstring lines for a given class or method. @@ -127,79 +168,41 @@ def _create_available_in(self) -> dict[type, str]: # i.e. {telegram._files.sticker.Sticker: {":attr:`telegram.Message.sticker`", ...}} attrs_for_class = defaultdict(set) - # The following regex is supposed to capture a class name in a line like this: - # media (:obj:`str` | :class:`telegram.InputFile`): Audio file to send. - # - # Note that even if such typing description spans over multiple lines but each line ends - # with a backslash (otherwise Sphinx will throw an error) - # (e.g. EncryptedPassportElement.data), then Sphinx will combine these lines into a single - # line automatically, and it will contain no backslash (only some extra many whitespaces - # from the indentation). - - attr_docstr_pattern = re.compile( - r"^\s*(?P[a-z_]+)" # Any number of spaces, named group for attribute - r"\s?\(" # Optional whitespace, opening parenthesis - r".*" # Any number of characters (that could denote a built-in type) - r":class:`.+`" # Marker of a classref, class name in backticks - r".*\):" # Any number of characters, closing parenthesis, colon. - # The ^ colon above along with parenthesis is important because it makes sure that - # the class is mentioned in the attribute description, not in free text. - r".*$", # Any number of characters, end of string (end of line) - re.VERBOSE, - ) - - # for properties: there is no attr name in docstring. Just check if there's a class name. - prop_docstring_pattern = re.compile(r":class:`.+`.*:") - - # pattern for iterating over potentially many class names in docstring for one attribute. - # Tilde is optional (sometimes it is in the docstring, sometimes not). - single_class_name_pattern = re.compile(r":class:`~?(?P[\w.]*)`") - classes_to_inspect = inspect.getmembers(telegram, inspect.isclass) + inspect.getmembers( telegram.ext, inspect.isclass ) - for class_name, inspected_class in classes_to_inspect: + for _class_name, inspected_class in classes_to_inspect: # We need to make "" into # "telegram.StickerSet" because that's the way the classes are mentioned in # docstrings. name_of_inspected_class_in_docstr = self._generate_class_name_for_link(inspected_class) - # Parsing part of the docstring with attributes (parsing of properties follows later) - docstring_lines = inspect.getdoc(inspected_class).splitlines() - lines_with_attrs = [] - for idx, line in enumerate(docstring_lines): - if line.strip() == "Attributes:": - lines_with_attrs = docstring_lines[idx + 1 :] - break - - for line in lines_with_attrs: - line_match = attr_docstr_pattern.match(line) - if not line_match: - continue - - target_attr = line_match.group("attr_name") - # a typing description of one attribute can contain multiple classes - for match in single_class_name_pattern.finditer(line): - name_of_class_in_attr = match.group("class_name") - - # Writing to dictionary: matching the class found in the docstring - # and its subclasses to the attribute of the class being inspected. - # The class in the attribute docstring (or its subclass) is the key, - # ReST link to attribute of the class currently being inspected is the value. - try: - self._resolve_arg_and_add_link( - arg=name_of_class_in_attr, - dict_of_methods_for_class=attrs_for_class, - link=f":attr:`{name_of_inspected_class_in_docstr}.{target_attr}`", - ) - except NotImplementedError as e: - raise NotImplementedError( - f"Error generating Sphinx 'Available in' admonition " - f"(admonition_inserter.py). Class {name_of_class_in_attr} present in " - f"attribute {target_attr} of class {name_of_inspected_class_in_docstr}" - f" could not be resolved. {str(e)}" - ) + # Writing to dictionary: matching the class found in the type hint + # and its subclasses to the attribute of the class being inspected. + # The class in the attribute typehint (or its subclass) is the key, + # ReST link to attribute of the class currently being inspected is the value. + + # best effort - args of __init__ means not all attributes are covered, but there is no + # other way to get type hints of all attributes, other than doing ast parsing maybe. + # (Docstring parsing was discontinued with the closing of #4414) + type_hints = typing.get_type_hints(inspected_class.__init__, localns=TG_NAMESPACE) + class_attrs = [slot for slot in mro_slots(inspected_class) if not slot.startswith("_")] + for target_attr in class_attrs: + try: + self._resolve_arg_and_add_link( + dict_of_methods_for_class=attrs_for_class, + link=f":attr:`{name_of_inspected_class_in_docstr}.{target_attr}`", + type_hints={target_attr: type_hints.get(target_attr)}, + resolve_nested_type_vars=False, + ) + except NotImplementedError as e: + raise NotImplementedError( + "Error generating Sphinx 'Available in' admonition " + f"(admonition_inserter.py). Class {inspected_class} present in " + f"attribute {target_attr} of class {name_of_inspected_class_in_docstr}" + f" could not be resolved. {e!s}" + ) from e # Properties need to be parsed separately because they act like attributes but not # listed as attributes. @@ -210,39 +213,29 @@ def _create_available_in(self) -> dict[type, str]: if prop_name not in inspected_class.__dict__: continue - # 1. Can't use typing.get_type_hints because double-quoted type hints - # (like "Application") will throw a NameError - # 2. Can't use inspect.signature because return annotations of properties can be - # hard to parse (like "(self) -> BD"). - # 3. fget is used to access the actual function under the property wrapper - docstring = inspect.getdoc(getattr(inspected_class, prop_name).fget) - if docstring is None: - continue - - first_line = docstring.splitlines()[0] - if not prop_docstring_pattern.match(first_line): - continue + # fget is used to access the actual function under the property wrapper + type_hints = typing.get_type_hints( + getattr(inspected_class, prop_name).fget, localns=TG_NAMESPACE + ) - for match in single_class_name_pattern.finditer(first_line): - name_of_class_in_prop = match.group("class_name") - - # Writing to dictionary: matching the class found in the docstring and its - # subclasses to the property of the class being inspected. - # The class in the property docstring (or its subclass) is the key, - # ReST link to property of the class currently being inspected is the value. - try: - self._resolve_arg_and_add_link( - arg=name_of_class_in_prop, - dict_of_methods_for_class=attrs_for_class, - link=f":attr:`{name_of_inspected_class_in_docstr}.{prop_name}`", - ) - except NotImplementedError as e: - raise NotImplementedError( - f"Error generating Sphinx 'Available in' admonition " - f"(admonition_inserter.py). Class {name_of_class_in_prop} present in " - f"property {prop_name} of class {name_of_inspected_class_in_docstr}" - f" could not be resolved. {str(e)}" - ) + # Writing to dictionary: matching the class found in the docstring and its + # subclasses to the property of the class being inspected. + # The class in the property docstring (or its subclass) is the key, + # ReST link to property of the class currently being inspected is the value. + try: + self._resolve_arg_and_add_link( + dict_of_methods_for_class=attrs_for_class, + link=f":attr:`{name_of_inspected_class_in_docstr}.{prop_name}`", + type_hints={prop_name: type_hints.get("return")}, + resolve_nested_type_vars=False, + ) + except NotImplementedError as e: + raise NotImplementedError( + "Error generating Sphinx 'Available in' admonition " + f"(admonition_inserter.py). Class {inspected_class} present in " + f"property {prop_name} of class {name_of_inspected_class_in_docstr}" + f" could not be resolved. {e!s}" + ) from e return self._generate_admonitions(attrs_for_class, admonition_type="available_in") @@ -250,30 +243,29 @@ def _create_returned_in(self) -> dict[type, str]: """Creates a dictionary with 'Returned in' admonitions for classes that are returned in Bot's and ApplicationBuilder's methods. """ - # Generate a mapping of classes to ReST links to Bot methods which return it, # i.e. {: {:meth:`telegram.Bot.send_message`, ...}} methods_for_class = defaultdict(set) - for cls, method_names in self.METHOD_NAMES_FOR_BOT_AND_APPBUILDER.items(): + for cls, method_names in self.METHOD_NAMES_FOR_BOT_APP_APPBUILDER.items(): for method_name in method_names: - sig = inspect.signature(getattr(cls, method_name)) - ret_annot = sig.return_annotation - method_link = self._generate_link_to_method(method_name, cls) + arg = getattr(cls, method_name) + ret_type_hint = typing.get_type_hints(arg, localns=TG_NAMESPACE) try: self._resolve_arg_and_add_link( - arg=ret_annot, dict_of_methods_for_class=methods_for_class, link=method_link, + type_hints={"return": ret_type_hint.get("return")}, + resolve_nested_type_vars=False, ) except NotImplementedError as e: raise NotImplementedError( - f"Error generating Sphinx 'Returned in' admonition " + "Error generating Sphinx 'Returned in' admonition " f"(admonition_inserter.py). {cls}, method {method_name}. " - f"Couldn't resolve type hint in return annotation {ret_annot}. {str(e)}" - ) + f"Couldn't resolve type hint in return annotation {ret_type_hint}. {e!s}" + ) from e return self._generate_admonitions(methods_for_class, admonition_type="returned_in") @@ -298,9 +290,14 @@ def _create_shortcuts(self) -> dict[collections.abc.Callable, str]: # inspect methods of all telegram classes for return statements that indicate # that this given method is a shortcut for a Bot method - for class_name, cls in inspect.getmembers(telegram, predicate=inspect.isclass): - # no need to inspect Bot's own methods, as Bot can't have shortcuts in Bot + for _class_name, cls in inspect.getmembers(telegram, predicate=inspect.isclass): + if not cls.__module__.startswith("telegram"): + # For some reason inspect.getmembers() also yields some classes that are + # imported in the namespace but not part of the telegram module. + continue + if cls is telegram.Bot: + # no need to inspect Bot's own methods, as Bot can't have shortcuts in Bot continue for method_name, method in _iter_own_public_methods(cls): @@ -310,9 +307,7 @@ def _create_shortcuts(self) -> dict[collections.abc.Callable, str]: continue bot_method = getattr(telegram.Bot, bot_method_match.group()) - link_to_shortcut_method = self._generate_link_to_method(method_name, cls) - shortcuts_for_bot_method[bot_method].add(link_to_shortcut_method) return self._generate_admonitions(shortcuts_for_bot_method, admonition_type="shortcuts") @@ -327,26 +322,26 @@ def _create_use_in(self) -> dict[type, str]: # {:meth:`telegram.Bot.answer_inline_query`, ...}} methods_for_class = defaultdict(set) - for cls, method_names in self.METHOD_NAMES_FOR_BOT_AND_APPBUILDER.items(): + for cls, method_names in self.METHOD_NAMES_FOR_BOT_APP_APPBUILDER.items(): for method_name in method_names: + if method_name == "get_file": + pass method_link = self._generate_link_to_method(method_name, cls) - sig = inspect.signature(getattr(cls, method_name)) - parameters = sig.parameters - - for param in parameters.values(): - try: - self._resolve_arg_and_add_link( - arg=param.annotation, - dict_of_methods_for_class=methods_for_class, - link=method_link, - ) - except NotImplementedError as e: - raise NotImplementedError( - f"Error generating Sphinx 'Use in' admonition " - f"(admonition_inserter.py). {cls}, method {method_name}, parameter " - f"{param}: Couldn't resolve type hint {param.annotation}. {str(e)}" - ) + arg = getattr(cls, method_name) + param_type_hints = typing.get_type_hints(arg, localns=TG_NAMESPACE) + param_type_hints.pop("return", None) + try: + self._resolve_arg_and_add_link( + dict_of_methods_for_class=methods_for_class, + link=method_link, + type_hints=param_type_hints, + ) + except NotImplementedError as e: + raise NotImplementedError( + "Error generating Sphinx 'Use in' admonition " + f"(admonition_inserter.py). {cls}, method {method_name}, parameter " + ) from e return self._generate_admonitions(methods_for_class, admonition_type="use_in") @@ -360,17 +355,20 @@ def _find_insert_pos_for_admonition(lines: list[str]) -> int: If no key phrases are found, the admonition will be inserted at the very end. """ for idx, value in list(enumerate(lines)): - if ( - value.startswith(".. seealso:") - # The docstring contains heading "Examples:", but Sphinx will have it converted - # to ".. admonition: Examples": - or value.startswith(".. admonition:: Examples") - or value.startswith(".. version") - # The space after ":param" is important because docstring can contain ":paramref:" - # in its plain text in the beginning of a line (e.g. ExtBot): - or value.startswith(":param ") - # some classes (like "Credentials") have no params, so insert before attrs: - or value.startswith(".. attribute::") + if value.startswith( + ( + # ".. seealso:", + # The docstring contains heading "Examples:", but Sphinx will have it converted + # to ".. admonition: Examples": + ".. admonition:: Examples", + ".. version", + "Args:", + # The space after ":param" is important because docstring can contain + # ":paramref:" in its plain text in the beginning of a line (e.g. ExtBot): + ":param ", + # some classes (like "Credentials") have no params, so insert before attrs: + ".. attribute::", + ) ): return idx return len(lines) - 1 @@ -412,7 +410,7 @@ def _generate_admonitions( # so its page needs no admonitions. continue - attrs = sorted(attrs) + sorted_attrs = sorted(attrs) # e.g. for admonition type "use_in" the title will be "Use in" and CSS class "use-in". admonition = f""" @@ -420,11 +418,11 @@ def _generate_admonitions( .. admonition:: {admonition_type.title().replace("_", " ")} :class: {admonition_type.replace("_", "-")} """ - if len(attrs) > 1: - for target_attr in attrs: + if len(sorted_attrs) > 1: + for target_attr in sorted_attrs: admonition += "\n * " + target_attr else: - admonition += f"\n {attrs[0]}" + admonition += f"\n {sorted_attrs[0]}" admonition += "\n " # otherwise an unexpected unindent warning will be issued admonition_for_class[cls] = admonition @@ -432,12 +430,12 @@ def _generate_admonitions( return admonition_for_class @staticmethod - def _generate_class_name_for_link(cls: type) -> str: + def _generate_class_name_for_link(cls_: type) -> str: """Generates class name that can be used in a ReST link.""" # Check for potential presence of ".ext.", we will need to keep it. - ext = ".ext" if ".ext." in str(cls) else "" - return f"telegram{ext}.{cls.__name__}" + ext = ".ext" if ".ext." in str(cls_) else "" + return f"telegram{ext}.{cls_.__name__}" def _generate_link_to_method(self, method_name: str, cls: type) -> str: """Generates a ReST link to a method of a telegram class.""" @@ -445,19 +443,22 @@ def _generate_link_to_method(self, method_name: str, cls: type) -> str: return f":meth:`{self._generate_class_name_for_link(cls)}.{method_name}`" @staticmethod - def _iter_subclasses(cls: type) -> Iterator: + def _iter_subclasses(cls_: type) -> Iterator: + if not hasattr(cls_, "__subclasses__") or cls_ is telegram.TelegramObject: + return iter([]) return ( # exclude private classes c - for c in cls.__subclasses__() + for c in cls_.__subclasses__() if not str(c).split(".")[-1].startswith("_") ) def _resolve_arg_and_add_link( self, - arg: Any, dict_of_methods_for_class: defaultdict, link: str, + type_hints: dict[str, type], + resolve_nested_type_vars: bool = True, ) -> None: """A helper method. Tries to resolve the arg into a valid class. In case of success, adds the link (to a method, attribute, or property) for that class' and its subclasses' @@ -465,7 +466,9 @@ def _resolve_arg_and_add_link( **Modifies dictionary in place.** """ - for cls in self._resolve_arg(arg): + type_hints.pop("self", None) + + for cls in self._resolve_arg(type_hints, resolve_nested_type_vars): # When trying to resolve an argument from args or return annotation, # the method _resolve_arg returns None if nothing could be resolved. # Also, if class was resolved correctly, "telegram" will definitely be in its str(). @@ -477,87 +480,72 @@ def _resolve_arg_and_add_link( for subclass in self._iter_subclasses(cls): dict_of_methods_for_class[subclass].add(link) - def _resolve_arg(self, arg: Any) -> Iterator[Union[type, None]]: + def _resolve_arg( + self, + type_hints: dict[str, type], + resolve_nested_type_vars: bool, + ) -> list[type]: """Analyzes an argument of a method and recursively yields classes that the argument or its sub-arguments (in cases like Union[...]) belong to, if they can be resolved to telegram or telegram.ext classes. + Args: + type_hints: A dictionary of argument names and their types. + resolve_nested_type_vars: If True, nested type variables (like Application[BT, …]) + will be resolved to their actual classes. If False, only the outermost type + variable will be resolved. *Only* affects ptb classes, not built-in types. + Useful for checking the return type of methods, where nested type variables + are not really useful. + Raises `NotImplementedError`. """ - origin = typing.get_origin(arg) - - if ( - origin in (collections.abc.Callable, typing.IO) - or arg is None - # no other check available (by type or origin) for these: - or str(type(arg)) in ("", "") - ): - pass - - # RECURSIVE CALLS - # for cases like Union[Sequence.... - elif origin in ( - Union, - collections.abc.Coroutine, - collections.abc.Sequence, - ): - for sub_arg in typing.get_args(arg): - yield from self._resolve_arg(sub_arg) - - elif isinstance(arg, typing.TypeVar): - # gets access to the "bound=..." parameter - yield from self._resolve_arg(arg.__bound__) - # END RECURSIVE CALLS - - elif isinstance(arg, typing.ForwardRef): - m = self.FORWARD_REF_PATTERN.match(str(arg)) - # We're sure it's a ForwardRef, so, unless it belongs to known exceptions, - # the class must be resolved. - # If it isn't resolved, we'll have the program throw an exception to be sure. - try: - cls = self._resolve_class(m.group("class_name")) - except AttributeError: - # skip known ForwardRef's that need not be resolved to a Telegram class - if self.FORWARD_REF_SKIP_PATTERN.match(str(arg)): - pass - else: - raise NotImplementedError(f"Could not process ForwardRef: {arg}") - else: - yield cls - - # For custom generics like telegram.ext._application.Application[~BT, ~CCT, ~UD...]. - # This must come before the check for isinstance(type) because GenericAlias can also be - # recognized as type if it belongs to . - elif str(type(arg)) in ("", ""): - if "telegram" in str(arg): - # get_origin() of telegram.ext._application.Application[~BT, ~CCT, ~UD...] - # will produce - yield origin - - elif isinstance(arg, type): - if "telegram" in str(arg): - yield arg - - # For some reason "InlineQueryResult", "InputMedia" & some others are currently not - # recognized as ForwardRefs and are identified as plain strings. - elif isinstance(arg, str): - # args like "ApplicationBuilder[BT, CCT, UD, CD, BD, JQ]" can be recognized as strings. - # Remove whatever is in the square brackets because it doesn't need to be parsed. - arg = re.sub(r"\[.+]", "", arg) - - cls = self._resolve_class(arg) - # Here we don't want an exception to be thrown since we're not sure it's ForwardRef - if cls is not None: - yield cls - - else: - raise NotImplementedError( - f"Cannot process argument {arg} of type {type(arg)} (origin {origin})" - ) + def _is_ptb_class(cls: type) -> bool: + if not hasattr(cls, "__module__"): + return False + return cls.__module__.startswith("telegram") + + # will be edited in place + telegram_classes = set() + + def recurse_type(type_, is_recursed_from_ptb_class: bool): + next_is_recursed_from_ptb_class = is_recursed_from_ptb_class or _is_ptb_class(type_) + + if hasattr(type_, "__origin__") or isinstance( + type_, types.UnionType + ): # For generic types like Union, List, etc. + # Make sure it's not a telegram.ext generic type (e.g. ContextTypes[...]) + org = typing.get_origin(type_) + if "telegram.ext" in str(org): + telegram_classes.add(org) + + args = typing.get_args(type_) + for arg in args: + recurse_type(arg, next_is_recursed_from_ptb_class) + elif isinstance(type_, typing.TypeVar) and ( + resolve_nested_type_vars or not is_recursed_from_ptb_class + ): + # gets access to the "bound=..." parameter + recurse_type(type_.__bound__, next_is_recursed_from_ptb_class) + elif inspect.isclass(type_) and "telegram" in inspect.getmodule(type_).__name__: + telegram_classes.add(type_) + elif isinstance(type_, typing.ForwardRef): + # Resolving ForwardRef is not easy. https://peps.python.org/pep-0749/ will + # hopefully make it better by introducing typing.resolve_forward_ref() in py3.14 + # but that's not there yet + # So for now we fall back to a best effort approach of guessing if the class is + # available in tg or tg.ext + with contextlib.suppress(AttributeError): + telegram_classes.add(self._resolve_class(type_.__forward_arg__)) + + for type_hint in type_hints.values(): + if type_hint is not None: + recurse_type(type_hint, False) + + return list(telegram_classes) @staticmethod - def _resolve_class(name: str) -> Union[type, None]: + def _resolve_class(name: str) -> type | None: """The keys in the admonitions dictionary are not strings like "telegram.StickerSet" but classes like . @@ -574,16 +562,15 @@ def _resolve_class(name: str) -> Union[type, None]: f"telegram.ext.{name}", f"telegram.ext.filters.{name}", ): - try: - return eval(option) # NameError will be raised if trying to eval just name and it doesn't work, e.g. # "Name 'ApplicationBuilder' is not defined". # AttributeError will be raised if trying to e.g. eval f"telegram.{name}" when the # class denoted by `name` actually belongs to `telegram.ext`: # "module 'telegram' has no attribute 'ApplicationBuilder'". # If neither option works, this is not a PTB class. - except (NameError, AttributeError): - continue + with contextlib.suppress(NameError, AttributeError): + return eval(option) + return None if __name__ == "__main__": diff --git a/docs/auxil/kwargs_insertion.py b/docs/auxil/kwargs_insertion.py index 4cedfa6b511..b2f05f8741b 100644 --- a/docs/auxil/kwargs_insertion.py +++ b/docs/auxil/kwargs_insertion.py @@ -1,6 +1,6 @@ # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,49 +18,78 @@ import inspect keyword_args = [ - ":keyword _sphinx_paramlinks_telegram.Bot.{method}.read_timeout: Value to pass to " - ":paramref:`telegram.request.BaseRequest.post.read_timeout`. Defaults to {read_timeout}.", - ":kwtype _sphinx_paramlinks_telegram.Bot.{method}.read_timeout: {read_timeout_type}, optional", - ":keyword _sphinx_paramlinks_telegram.Bot.{method}.write_timeout: Value to pass to " - ":paramref:`telegram.request.BaseRequest.post.write_timeout`. Defaults to {write_timeout}.", - ":kwtype _sphinx_paramlinks_telegram.Bot.{method}.write_timeout: :obj:`float` | :obj:`None`, " - "optional", - ":keyword _sphinx_paramlinks_telegram.Bot.{method}.connect_timeout: Value to pass to " - ":paramref:`telegram.request.BaseRequest.post.connect_timeout`. Defaults to " - ":attr:`~telegram.request.BaseRequest.DEFAULT_NONE`.", - ":kwtype _sphinx_paramlinks_telegram.Bot.{method}.connect_timeout: :obj:`float` | " - ":obj:`None`, optional", - ":keyword _sphinx_paramlinks_telegram.Bot.{method}.pool_timeout: Value to pass to " - ":paramref:`telegram.request.BaseRequest.post.pool_timeout`. Defaults to " - ":attr:`~telegram.request.BaseRequest.DEFAULT_NONE`.", - ":kwtype _sphinx_paramlinks_telegram.Bot.{method}.pool_timeout: :obj:`float` | :obj:`None`, " - "optional", - ":keyword _sphinx_paramlinks_telegram.Bot.{method}.api_kwargs: Arbitrary keyword arguments " - "to be passed to the Telegram API.", - ":kwtype _sphinx_paramlinks_telegram.Bot.{method}.api_kwargs: :obj:`dict`, optional", + "Keyword Arguments:", + ( + " read_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to " + " :paramref:`telegram.request.BaseRequest.post.read_timeout`. Defaults to " + " :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. " + ), + ( + " write_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to " + " :paramref:`telegram.request.BaseRequest.post.write_timeout`. Defaults to " + " :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`." + ), + ( + " connect_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to " + " :paramref:`telegram.request.BaseRequest.post.connect_timeout`. Defaults to " + " :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`." + ), + ( + " pool_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to " + " :paramref:`telegram.request.BaseRequest.post.pool_timeout`. Defaults to " + " :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`." + ), + ( + " api_kwargs (:obj:`dict`, optional): Arbitrary keyword arguments" + " to be passed to the Telegram API. See :meth:`~telegram.Bot.do_api_request` for" + " limitations." + ), "", ] -write_timeout_sub = [":attr:`~telegram.request.BaseRequest.DEFAULT_NONE`", "``20``"] -read_timeout_sub = [ - ":attr:`~telegram.request.BaseRequest.DEFAULT_NONE`.", - "``2``. :paramref:`timeout` will be added to this value", + +media_write_timeout_change_methods = [ + "add_sticker_to_set", + "create_new_sticker_set", + "send_animation", + "send_audio", + "send_document", + "send_media_group", + "send_photo", + "send_sticker", + "send_video", + "send_video_note", + "send_voice", + "set_chat_photo", + "upload_sticker_file", +] +media_write_timeout_change = [ + " write_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to " + " :paramref:`telegram.request.BaseRequest.post.write_timeout`. By default, ``20`` " + " seconds are used as write timeout." + "", + "", + " .. versionchanged:: 22.0", + " The default value changed to " + " :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`.", + "", + "", +] +get_updates_read_timeout_addition = [ + " :paramref:`timeout` will be added to this value.", + "", + "", + " .. versionchanged:: 20.7", + " Defaults to :attr:`~telegram.request.BaseRequest.DEFAULT_NONE` instead of ", + " ``2``.", ] -read_timeout_type = [":obj:`float` | :obj:`None`", ":obj:`float`"] def find_insert_pos_for_kwargs(lines: list[str]) -> int: """Finds the correct position to insert the keyword arguments and returns the index.""" for idx, value in reversed(list(enumerate(lines))): # reversed since :returns: is at the end - if value.startswith(":returns:"): + if value.startswith("Returns"): return idx - else: - return False - - -def is_write_timeout_20(obj: object) -> int: - """inspects the default value of write_timeout parameter of the bot method.""" - sig = inspect.signature(obj) - return 1 if (sig.parameters["write_timeout"].default == 20) else 0 + return False def check_timeout_and_api_kwargs_presence(obj: object) -> int: diff --git a/docs/auxil/link_code.py b/docs/auxil/link_code.py index dc32f63945b..a6705082bc2 100644 --- a/docs/auxil/link_code.py +++ b/docs/auxil/link_code.py @@ -1,6 +1,6 @@ # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,7 +19,9 @@ to link to the correct files & lines on github. Can be simplified once https://github.com/sphinx-doc/sphinx/issues/1556 is closed """ + import subprocess +from pathlib import Path from sphinx.util import logging @@ -30,13 +32,13 @@ # must be a module-level variable so that it can be written to by the `autodoc-process-docstring` # event handler in `sphinx_hooks.py` -LINE_NUMBERS = {} +LINE_NUMBERS: dict[str, tuple[Path, int, int]] = {} def _git_branch() -> str: """Get's the current git sha if available or fall back to `master`""" try: - output = subprocess.check_output( # skipcq: BAN-B607 + output = subprocess.check_output( ["git", "describe", "--tags", "--always"], stderr=subprocess.STDOUT ) return output.decode().strip() @@ -52,7 +54,7 @@ def _git_branch() -> str: base_url = "https://github.com/python-telegram-bot/python-telegram-bot/blob/" -def linkcode_resolve(_, info): +def linkcode_resolve(_, info) -> str: """See www.sphinx-doc.org/en/master/usage/extensions/linkcode.html""" combined = ".".join((info["module"], info["fullname"])) # special casing for ExtBot which is due to the special structure of extbot.rst @@ -71,7 +73,7 @@ def linkcode_resolve(_, info): line_info = LINE_NUMBERS.get(info["module"]) if not line_info: - return + return None file, start_line, end_line = line_info return f"{base_url}{git_branch}/{file}#L{start_line}-L{end_line}" diff --git a/docs/auxil/sphinx_hooks.py b/docs/auxil/sphinx_hooks.py index dfe325ccb3b..b9b46a684e5 100644 --- a/docs/auxil/sphinx_hooks.py +++ b/docs/auxil/sphinx_hooks.py @@ -1,6 +1,6 @@ # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -15,11 +15,12 @@ # # 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 collections.abc +import contextlib import inspect import re import typing from pathlib import Path +from typing import TYPE_CHECKING from sphinx.application import Sphinx @@ -29,14 +30,17 @@ from docs.auxil.kwargs_insertion import ( check_timeout_and_api_kwargs_presence, find_insert_pos_for_kwargs, - is_write_timeout_20, + get_updates_read_timeout_addition, keyword_args, - read_timeout_sub, - read_timeout_type, - write_timeout_sub, + media_write_timeout_change, + media_write_timeout_change_methods, ) from docs.auxil.link_code import LINE_NUMBERS +if TYPE_CHECKING: + import collections.abc + + ADMONITION_INSERTER = AdmonitionInserter() # Some base classes are implementation detail @@ -47,9 +51,12 @@ "_BaseThumbedMedium": "TelegramObject", "_BaseMedium": "TelegramObject", "_CredentialsBase": "TelegramObject", + "_ChatBase": "TelegramObject", } +# Resolves to the parent directory of `telegram/`, depending on installation setup, +# could either be `/src` or `/site-packages` FILE_ROOT = Path(inspect.getsourcefile(telegram)).parent.parent.resolve() @@ -68,9 +75,9 @@ def autodoc_skip_member(app, what, name, obj, skip, options): return True break - if name == "filter" and obj.__module__ == "telegram.ext.filters": - if not included_in_obj: - return True # return True to exclude from docs. + if name == "filter" and obj.__module__ == "telegram.ext.filters" and not included_in_obj: + return True # return True to exclude from docs. + return None def autodoc_process_docstring( @@ -107,22 +114,27 @@ def autodoc_process_docstring( f"Couldn't find the correct position to insert the keyword args for {obj}." ) - long_write_timeout = is_write_timeout_20(obj) - get_updates_sub = 1 if (method_name == "get_updates") else 0 + get_updates: bool = method_name == "get_updates" # The below can be done in 1 line with itertools.chain, but this must be modified in-place + insert_idx = insert_index for i in range(insert_index, insert_index + len(keyword_args)): - lines.insert( - i, - keyword_args[i - insert_index].format( - method=method_name, - write_timeout=write_timeout_sub[long_write_timeout], - read_timeout=read_timeout_sub[get_updates_sub], - read_timeout_type=read_timeout_type[get_updates_sub], - ), - ) + to_insert = keyword_args[i - insert_index] + + if ( + "post.write_timeout`. Defaults to" in to_insert + and method_name in media_write_timeout_change_methods + ): + effective_insert: list[str] = media_write_timeout_change + elif get_updates and to_insert.lstrip().startswith("read_timeout"): + effective_insert = [to_insert, *get_updates_read_timeout_addition] + else: + effective_insert = [to_insert] + + lines[insert_idx:insert_idx] = effective_insert + insert_idx += len(effective_insert) ADMONITION_INSERTER.insert_admonitions( - obj=typing.cast(collections.abc.Callable, obj), + obj=typing.cast("collections.abc.Callable", obj), docstring_lines=lines, ) @@ -130,7 +142,7 @@ def autodoc_process_docstring( # (where applicable) if what == "class": ADMONITION_INSERTER.insert_admonitions( - obj=typing.cast(type, obj), # since "what" == class, we know it's not just object + obj=typing.cast("type", obj), # since "what" == class, we know it's not just object docstring_lines=lines, ) @@ -148,13 +160,11 @@ def autodoc_process_docstring( if isinstance(obj, telegram.ext.filters.BaseFilter): obj = obj.__class__ - try: + with contextlib.suppress(Exception): source_lines, start_line = inspect.getsourcelines(obj) end_line = start_line + len(source_lines) - file = Path(inspect.getsourcefile(obj)).relative_to(FILE_ROOT) + file = Path("src") / Path(inspect.getsourcefile(obj)).relative_to(FILE_ROOT) LINE_NUMBERS[name] = (file, start_line, end_line) - except Exception: - pass # Since we don't document the `__init__`, we call this manually to have it available for # attributes -- see the note above @@ -162,11 +172,11 @@ def autodoc_process_docstring( autodoc_process_docstring(app, "method", f"{name}.__init__", obj.__init__, options, lines) -def autodoc_process_bases(app, name, obj, option, bases: list): +def autodoc_process_bases(app, name, obj, option, bases: list) -> None: """Here we fine tune how the base class's classes are displayed.""" - for idx, base in enumerate(bases): + for idx, raw_base in enumerate(bases): # let's use a string representation of the object - base = str(base) + base = str(raw_base) # Special case for abstract context managers which are wrongly resoled for some reason if base.startswith("typing.AbstractAsyncContextManager"): @@ -183,14 +193,21 @@ def autodoc_process_bases(app, name, obj, option, bases: list): bases[idx] = ":class:`enum.IntEnum`" continue + if "FloatEnum" in base: + bases[idx] = ":class:`enum.Enum`" + bases.insert(0, ":class:`float`") + continue + # Drop generics (at least for now) if base.endswith("]"): base = base.split("[", maxsplit=1)[0] bases[idx] = f":class:`{base}`" # Now convert `telegram._message.Message` to `telegram.Message` etc - match = re.search(pattern=r"(telegram(\.ext|))\.[_\w\.]+", string=base) - if not match or "_utils" in base: + if ( + not (match := re.search(pattern=r"(telegram(\.ext|))\.[_\w\.]+", string=base)) + or "_utils" in base + ): continue parts = match.group(0).split(".") diff --git a/docs/auxil/tg_const_role.py b/docs/auxil/tg_const_role.py index e2962b21802..1c968f41336 100644 --- a/docs/auxil/tg_const_role.py +++ b/docs/auxil/tg_const_role.py @@ -1,6 +1,6 @@ # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -15,6 +15,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/]. +import datetime as dtm from enum import Enum from docutils.nodes import Element @@ -71,16 +72,22 @@ def process_link( if isinstance(value, tuple) and target in ( "telegram.constants.BOT_API_VERSION_INFO", "telegram.__version_info__", + ): + return str(value), target + if ( + isinstance(value, dtm.datetime) + and value == telegram.constants.ZERO_DATE + and target in ("telegram.constants.ZERO_DATE",) ): return repr(value), target sphinx_logger.warning( - f"%s:%d: WARNING: Did not convert reference %s. :{CONSTANTS_ROLE}: is not supposed" + "%s:%d: WARNING: Did not convert reference %s. :%s: is not supposed" " to be used with this type of target.", refnode.source, refnode.line, refnode.rawsource, + CONSTANTS_ROLE, ) - return title, target except Exception as exc: sphinx_logger.exception( "%s:%d: WARNING: Did not convert reference %s due to an exception.", @@ -90,3 +97,5 @@ def process_link( exc_info=exc, ) return title, target + else: + return title, target diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt deleted file mode 100644 index dd566d95586..00000000000 --- a/docs/requirements-docs.txt +++ /dev/null @@ -1,7 +0,0 @@ -sphinx==7.0.1 -sphinx-pypi-upload -furo==2023.5.20 -git+https://github.com/harshil21/furo-sphinx-search@01efc7be422d7dc02390aab9be68d6f5ce1a5618#egg=furo-sphinx-search -sphinx-paramlinks==0.5.4 -sphinxcontrib-mermaid==0.9.2 -sphinx-copybutton==0.5.2 diff --git a/docs/source/_static/style_admonitions.css b/docs/source/_static/style_admonitions.css index 89c0d4b9e5e..4d86486afe9 100644 --- a/docs/source/_static/style_admonitions.css +++ b/docs/source/_static/style_admonitions.css @@ -61,5 +61,5 @@ } .admonition.returned-in > ul, .admonition.available-in > ul, .admonition.use-in > ul, .admonition.shortcuts > ul { max-height: 200px; - overflow-y: scroll; + overflow-y: auto; } diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst index 1cb32f6be91..c5be34d2b04 100644 --- a/docs/source/changelog.rst +++ b/docs/source/changelog.rst @@ -1 +1,9 @@ -.. include:: ../../CHANGES.rst \ No newline at end of file +.. _ptb-changelog: + +========= +Changelog +========= + +.. chango:: + +.. include:: ../../changes/LEGACY.rst \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index c227f30ac02..fc50def6dd4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -8,12 +8,17 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. from sphinx.application import Sphinx -sys.path.insert(0, os.path.abspath("../..")) +if sys.version_info < (3, 12): + # Due to dependency on chango + raise RuntimeError("This documentation needs at least Python 3.12") + + +sys.path.insert(0, str(Path("../..").resolve().absolute())) # -- General configuration ------------------------------------------------ # General information about the project. project = "python-telegram-bot" -copyright = "2015-2023, Leandro Toledo" +copyright = "2015-2026, Leandro Toledo" author = "Leandro Toledo" # The version info for the project you're documenting, acts as replacement for @@ -21,17 +26,22 @@ # built documents. # # The short X.Y version. -version = "20.3" # telegram.__version__[:3] + +# Import needs to be below the sys.path.insert above +import telegram # noqa: E402 + +version = telegram.__version__ # The full version, including alpha/beta/rc tags. -release = "20.3" # telegram.__version__ +release = telegram.__version__ # If your documentation needs a minimal Sphinx version, state it here. -needs_sphinx = "6.1.3" +needs_sphinx = "8.1.3" # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ + "chango.sphinx_ext", "sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx.ext.intersphinx", @@ -39,12 +49,23 @@ "sphinx.ext.extlinks", "sphinx_paramlinks", "sphinx_copybutton", + "sphinx_inline_tabs", "sphinxcontrib.mermaid", - "sphinx_search.extension", ] +# Temporary. See #4387 +if os.environ.get("READTHEDOCS", "") == "True": + extensions.append("sphinx_build_compatibility.extension") + +# Configuration for the chango sphinx directive +chango_pyproject_toml_path = Path(__file__).parent.parent.parent + # For shorter links to Wiki in docstrings -extlinks = {"wiki": ("https://github.com/python-telegram-bot/python-telegram-bot/wiki/%s", "%s")} +extlinks = { + "wiki": ("https://github.com/python-telegram-bot/python-telegram-bot/wiki/%s", "%s"), + "pr": ("https://github.com/python-telegram-bot/python-telegram-bot/pull/%s", "#%s"), + "issue": ("https://github.com/python-telegram-bot/python-telegram-bot/issues/%s", "#%s"), +} # Use intersphinx to reference the python builtin library docs intersphinx_mapping = { @@ -63,7 +84,9 @@ master_doc = "index" # Global substitutions -rst_prolog = (Path.cwd() / "../substitutions/global.rst").read_text(encoding="utf-8") +rst_prolog = "" +for file in Path.cwd().glob("../substitutions/*.rst"): + rst_prolog += "\n" + file.read_text(encoding="utf-8") # -- Extension settings ------------------------------------------------ napoleon_use_admonition_for_examples = True @@ -72,6 +95,14 @@ # and we document the types anyway autodoc_typehints = "none" +# Show docstring for special members +autodoc_default_options = { + "special-members": True, + # For some reason, __weakref__ can not be ignored by using "inherited-members" in all cases + # so we list it here. + "exclude-members": "__init__, __weakref__", +} + # Fail on warnings & unresolved references etc nitpicky = True @@ -88,10 +119,19 @@ # Anchors are apparently inserted by GitHub dynamically, so let's skip checking them "https://github.com/python-telegram-bot/python-telegram-bot/tree/master/examples#", r"https://github\.com/python-telegram-bot/python-telegram-bot/wiki/[\w\-_,]+\#", + # The LGPL license link regularly causes network errors for some reason + re.escape("https://www.gnu.org/licenses/lgpl-3.0.html"), + # The doc-fixes branch may not always exist - doesn't matter, we only link to it from the + # contributing guide + re.escape("https://docs.python-telegram-bot.org/en/doc-fixes"), + # Apparently has some human-verification check and gives 403 in the sphinx build + re.escape("https://stackoverflow.com/questions/tagged/python-telegram-bot"), ] linkcheck_allowed_redirects = { # Redirects to the default version are okay - r"https://docs\.python-telegram-bot\.org/.*": r"https://docs\.python-telegram-bot\.org/en/[\w\d\.]+/.*", + r"https://docs\.python-telegram-bot\.org/.*": ( + r"https://docs\.python-telegram-bot\.org/en/[\w\d\.]+/.*" + ), # pre-commit.ci always redirects to the latest run re.escape( "https://results.pre-commit.ci/latest/github/python-telegram-bot/python-telegram-bot/master" @@ -126,71 +166,73 @@ "admonition-title-font-size": "0.95rem", "admonition-font-size": "0.92rem", }, - "announcement": "PTB has undergone significant changes in v20. Please read the documentation " - "carefully and also check out the transition guide in the " - 'wiki.', "footer_icons": [ { # Telegram channel logo "name": "Telegram Channel", "url": "https://t.me/pythontelegrambotchannel/", # Following svg is from https://react-icons.github.io/react-icons/search?q=telegram - "html": '' - '', + "html": ( + '' + '' + ), "class": "", }, { # Github logo "name": "GitHub", "url": "https://github.com/python-telegram-bot/python-telegram-bot/", - "html": '' - "", + "html": ( + '' + "" + ), "class": "", }, { # PTB website logo - globe "name": "python-telegram-bot website", "url": "https://python-telegram-bot.org/", - "html": '' - '', + "html": ( + '' + '' + ), "class": "", }, ], @@ -229,7 +271,14 @@ # The base URL which points to the root of the HTML documentation. It is used to indicate the # location of document using The Canonical Link Relation. Default: ''. -html_baseurl = "https://docs.python-telegram-bot.org" +# Set canonical URL from the Read the Docs Domain +html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "") + +# Tell Jinja2 templates the build is running on Read the Docs +html_context = {} +if os.environ.get("READTHEDOCS", "") == "True": + html_context["READTHEDOCS"] = True + # -- Options for LaTeX output --------------------------------------------- @@ -287,17 +336,20 @@ # Due to Sphinx behaviour, these imports only work when imported here, not at top of module. # Not used but must be imported for the linkcode extension to find it -from docs.auxil.link_code import linkcode_resolve -from docs.auxil.sphinx_hooks import ( +from docs.auxil.link_code import linkcode_resolve # noqa: E402, F401 +from docs.auxil.sphinx_hooks import ( # noqa: E402 autodoc_process_bases, autodoc_process_docstring, autodoc_skip_member, ) -from docs.auxil.tg_const_role import CONSTANTS_ROLE, TGConstXRefRole +from docs.auxil.tg_const_role import CONSTANTS_ROLE, TGConstXRefRole # noqa: E402 def setup(app: Sphinx): app.connect("autodoc-skip-member", autodoc_skip_member) app.connect("autodoc-process-bases", autodoc_process_bases) - app.connect("autodoc-process-docstring", autodoc_process_docstring) + # The default priority is 500. We want our function to run before napoleon doc-conversion + # and sphinx-paramlinks do, b/c otherwise the inserted kwargs in the bot methods won't show + # up in the objects.inv file that Sphinx generates (i.e. not in the search). + app.connect("autodoc-process-docstring", autodoc_process_docstring, priority=100) app.add_role_to_domain("py", CONSTANTS_ROLE, TGConstXRefRole()) diff --git a/docs/source/examples.customwebhookbot.rst b/docs/source/examples.customwebhookbot.rst index 95a5a70e560..74722093866 100644 --- a/docs/source/examples.customwebhookbot.rst +++ b/docs/source/examples.customwebhookbot.rst @@ -1,7 +1,43 @@ ``customwebhookbot.py`` ======================= -.. literalinclude:: ../../examples/customwebhookbot.py - :language: python - :linenos: +This example is available for different web frameworks. +You can select your preferred framework by opening one of the tabs above the code example. + +.. hint:: + + The following examples show how different Python web frameworks can be used alongside PTB. + This can be useful for two use cases: + + 1. For extending the functionality of your existing bot to handling updates of external services + 2. For extending the functionality of your exisiting web application to also include chat bot functionality + + How the PTB and web framework components of the examples below are viewed surely depends on which use case one has in mind. + We are fully aware that a combination of PTB with web frameworks will always mean finding a tradeoff between usability and best practices for both PTB and the web framework and these examples are certainly far from optimal solutions. + Please understand them as starting points and use your expertise of the web framework of your choosing to build up on them. + You are of course also very welcome to help improve these examples! + +.. tab:: ``starlette`` + + .. literalinclude:: ../../examples/customwebhookbot/starlettebot.py + :language: python + :linenos: + +.. tab:: ``flask`` + + .. literalinclude:: ../../examples/customwebhookbot/flaskbot.py + :language: python + :linenos: + +.. tab:: ``quart`` + + .. literalinclude:: ../../examples/customwebhookbot/quartbot.py + :language: python + :linenos: + +.. tab:: ``Django`` + + .. literalinclude:: ../../examples/customwebhookbot/djangobot.py + :language: python + :linenos: \ No newline at end of file diff --git a/docs/source/examples.rst b/docs/source/examples.rst index d0f36ed6c57..ce87c73450e 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -38,9 +38,8 @@ class to send timed messages. The user sets a timer by using ``/set`` command with a specific time, for example ``/set 30``. The bot then sets up a job to send a message to that user after 30 seconds. The user can also cancel the timer by sending ``/unset``. To learn more about the -``JobQueue``, read `this wiki -article `__. -Note: To use ``JobQueue``, you must install PTB via ``pip install python-telegram-bot[job-queue]`` +``JobQueue``, read `this wiki article `__. +Note: To use ``JobQueue``, you must install PTB via ``pip install "python-telegram-bot[job-queue]"`` :any:`examples.conversationbot` ------------------------------- @@ -62,7 +61,7 @@ for this one, too! :any:`examples.nestedconversationbot` ------------------------------------- -A even more complex example of a bot that uses the nested +An even more complex example of a bot that uses the nested ``ConversationHandler``\ s. While it’s certainly not that complex that you couldn’t built it without nested ``ConversationHanldler``\ s, it gives a good impression on how to work with them. Of course, there is a @@ -116,7 +115,7 @@ Don’t forget to enable and configure payments with `@BotFather `_. Check out this `guide `__ on Telegram passports in PTB. -Note: To use Telegram Passport, you must install PTB via ``pip install python-telegram-bot[passport]`` +Note: To use Telegram Passport, you must install PTB via ``pip install "python-telegram-bot[passport]"`` :any:`examples.paymentbot` -------------------------- @@ -164,7 +163,7 @@ combination with ``telegram.ext.Application``. This example showcases how PTBs “arbitrary callback data” feature can be used. -Note: To use arbitrary callback data, you must install PTB via ``pip install python-telegram-bot[callback-data]`` +Note: To use arbitrary callback data, you must install PTB via ``pip install "python-telegram-bot[callback-data]"`` Pure API -------- diff --git a/docs/source/inclusions/application_run_tip.rst b/docs/source/inclusions/application_run_tip.rst index f6b1261b26d..91dcaa997da 100644 --- a/docs/source/inclusions/application_run_tip.rst +++ b/docs/source/inclusions/application_run_tip.rst @@ -1,7 +1,9 @@ .. tip:: - When combining ``python-telegram-bot`` with other :mod:`asyncio` based frameworks, using this - method is likely not the best choice, as it blocks the event loop until it receives a stop - signal as described above. - Instead, you can manually call the methods listed below to start and shut down the application - and the :attr:`~telegram.ext.Application.updater`. - Keeping the event loop running and listening for a stop signal is then up to you. \ No newline at end of file + * When combining ``python-telegram-bot`` with other :mod:`asyncio` based frameworks, using this + method is likely not the best choice, as it blocks the event loop until it receives a stop + signal as described above. + Instead, you can manually call the methods listed below to start and shut down the application + and the :attr:`~telegram.ext.Application.updater`. + Keeping the event loop running and listening for a stop signal is then up to you. + * To gracefully stop the execution of this method from within a handler, job or error callback, + use :meth:`~telegram.ext.Application.stop_running`. \ No newline at end of file diff --git a/docs/source/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index 36b871c7b73..7bb89f23dac 100644 --- a/docs/source/inclusions/bot_methods.rst +++ b/docs/source/inclusions/bot_methods.rst @@ -25,6 +25,8 @@ - Used for sending documents * - :meth:`~telegram.Bot.send_game` - Used for sending a game + * - :meth:`~telegram.Bot.send_gift` + - Used for sending a gift * - :meth:`~telegram.Bot.send_invoice` - Used for sending an invoice * - :meth:`~telegram.Bot.send_location` @@ -33,6 +35,10 @@ - Used for sending media grouped together * - :meth:`~telegram.Bot.send_message` - Used for sending text messages + * - :meth:`~telegram.Bot.send_message_draft` + - Used for streaming partial 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` @@ -49,8 +55,12 @@ - Used for sending voice messages * - :meth:`~telegram.Bot.copy_message` - Used for copying the contents of an arbitrary message + * - :meth:`~telegram.Bot.copy_messages` + - Used for copying the contents of an multiple arbitrary messages * - :meth:`~telegram.Bot.forward_message` - Used for forwarding messages + * - :meth:`~telegram.Bot.forward_messages` + - Used for forwarding multiple messages at once .. raw:: html @@ -76,6 +86,10 @@ - Used for answering a shipping query * - :meth:`~telegram.Bot.answer_web_app_query` - Used for answering a web app query + * - :meth:`~telegram.Bot.delete_message` + - Used for deleting messages. + * - :meth:`~telegram.Bot.delete_messages` + - Used for deleting multiple messages as once. * - :meth:`~telegram.Bot.edit_message_caption` - Used for editing captions * - :meth:`~telegram.Bot.edit_message_media` @@ -88,8 +102,8 @@ - Used for editing text messages * - :meth:`~telegram.Bot.stop_poll` - Used for stopping the running poll - * - :meth:`~telegram.Bot.delete_message` - - Used for deleting messages. + * - :meth:`~telegram.Bot.set_message_reaction` + - Used for setting reactions on messages .. raw:: html @@ -105,6 +119,14 @@ :align: left :widths: 1 4 + * - :meth:`~telegram.Bot.approve_chat_join_request` + - Used for approving a chat join request + * - :meth:`~telegram.Bot.decline_chat_join_request` + - Used for declining a chat join request + * - :meth:`~telegram.Bot.approve_suggested_post` + - Used for approving a suggested post + * - :meth:`~telegram.Bot.decline_suggested_post` + - Used for declining a suggested post * - :meth:`~telegram.Bot.ban_chat_member` - Used for banning a member from the chat * - :meth:`~telegram.Bot.unban_chat_member` @@ -129,10 +151,6 @@ - Used for editing a non-primary invite link * - :meth:`~telegram.Bot.revoke_chat_invite_link` - Used for revoking an invite link created by the bot - * - :meth:`~telegram.Bot.approve_chat_join_request` - - Used for approving a chat join request - * - :meth:`~telegram.Bot.decline_chat_join_request` - - Used for declining a chat join request * - :meth:`~telegram.Bot.set_chat_photo` - Used for setting a photo to a chat * - :meth:`~telegram.Bot.delete_chat_photo` @@ -141,6 +159,8 @@ - Used for setting a chat title * - :meth:`~telegram.Bot.set_chat_description` - Used for setting the description of a chat + * - :meth:`~telegram.Bot.set_user_emoji_status` + - Used for setting the users status emoji * - :meth:`~telegram.Bot.pin_chat_message` - Used for pinning a message * - :meth:`~telegram.Bot.unpin_chat_message` @@ -157,6 +177,8 @@ - Used for getting the number of members in a chat * - :meth:`~telegram.Bot.get_chat_member` - Used for getting a member of a chat + * - :meth:`~telegram.Bot.get_user_chat_boosts` + - Used for getting the list of boosts added to a chat * - :meth:`~telegram.Bot.leave_chat` - Used for leaving a chat @@ -165,6 +187,29 @@
+.. raw:: html + +
+ Verification on behalf of an organization + +.. list-table:: + :align: left + :widths: 1 4 + + * - :meth:`~telegram.Bot.verify_chat` + - Used for verifying a chat + * - :meth:`~telegram.Bot.verify_user` + - Used for verifying a user + * - :meth:`~telegram.Bot.remove_chat_verification` + - Used for removing the verification from a chat + * - :meth:`~telegram.Bot.remove_user_verification` + - Used for removing the verification from a user + +.. raw:: html + +
+
+ .. raw:: html
@@ -227,6 +272,8 @@ - Used for setting a sticker set of a chat * - :meth:`~telegram.Bot.delete_chat_sticker_set` - Used for deleting the set sticker set of a chat + * - :meth:`~telegram.Bot.replace_sticker_in_set` + - Used for replacing a sticker in a set * - :meth:`~telegram.Bot.set_sticker_position_in_set` - Used for moving a sticker's position in the set * - :meth:`~telegram.Bot.set_sticker_set_title` @@ -237,7 +284,7 @@ - Used for setting the keywords of a sticker * - :meth:`~telegram.Bot.set_sticker_mask_position` - Used for setting the mask position of a mask sticker - * - :meth:`~telegram.Bot.set_sticker_set_thumb` + * - :meth:`~telegram.Bot.set_sticker_set_thumbnail` - Used for setting the thumbnail of a sticker set * - :meth:`~telegram.Bot.set_custom_emoji_sticker_set_thumbnail` - Used for setting the thumbnail of a custom emoji sticker set @@ -328,6 +375,8 @@ - Used to reopen the general topic * - :meth:`~telegram.Bot.unpin_all_forum_topic_messages` - Used to unpin all messages in a forum topic + * - :meth:`~telegram.Bot.unpin_all_general_forum_topic_messages` + - Used to unpin all messages in the general forum topic .. raw:: html @@ -337,7 +386,7 @@ .. raw:: html
- Miscellaneous + Payments and Stars .. list-table:: :align: left @@ -345,14 +394,105 @@ * - :meth:`~telegram.Bot.create_invoice_link` - Used to generate an HTTP link for an invoice + * - :meth:`~telegram.Bot.edit_user_star_subscription` + - Used for editing a user's star subscription + * - :meth:`~telegram.Bot.get_my_star_balance` + - Used for obtaining the bot's Telegram Stars balance + * - :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 + * - :meth:`~telegram.Bot.gift_premium_subscription` + - Used for gifting Telegram Premium to another user. + +.. raw:: html + +
+
+ +.. raw:: html + +
+ Business Related Methods + +.. list-table:: + :align: left + :widths: 1 4 + + * - :meth:`~telegram.Bot.get_business_connection` + - Used for getting information about the business account. + * - :meth:`~telegram.Bot.get_business_account_gifts` + - Used for getting gifts owned by the business account. + * - :meth:`~telegram.Bot.get_business_account_star_balance` + - Used for getting the amount of Stars owned by the business account. + * - :meth:`~telegram.Bot.read_business_message` + - Used for marking a message as read. + * - :meth:`~telegram.Bot.delete_story` + - Used for deleting business stories posted by the bot. + * - :meth:`~telegram.Bot.delete_business_messages` + - Used for deleting business messages. + * - :meth:`~telegram.Bot.remove_business_account_profile_photo` + - Used for removing the business accounts profile photo + * - :meth:`~telegram.Bot.set_business_account_name` + - Used for setting the business account name. + * - :meth:`~telegram.Bot.set_business_account_username` + - Used for setting the business account username. + * - :meth:`~telegram.Bot.set_business_account_bio` + - Used for setting the business account bio. + * - :meth:`~telegram.Bot.set_business_account_gift_settings` + - Used for setting the business account gift settings. + * - :meth:`~telegram.Bot.set_business_account_profile_photo` + - Used for setting the business accounts profile photo + * - :meth:`~telegram.Bot.post_story` + - Used for posting a story on behalf of business account. + * - :meth:`~telegram.Bot.repost_story` + - Used for reposting an existing story on behalf of business account. + * - :meth:`~telegram.Bot.edit_story` + - Used for editing business stories posted by the bot. + * - :meth:`~telegram.Bot.convert_gift_to_stars` + - Used for converting owned reqular gifts to stars. + * - :meth:`~telegram.Bot.upgrade_gift` + - Used for upgrading owned regular gifts to unique ones. + * - :meth:`~telegram.Bot.transfer_gift` + - Used for transferring owned unique gifts to another user. + * - :meth:`~telegram.Bot.transfer_business_account_stars` + - Used for transfering Stars from the business account balance to the bot's balance. + * - :meth:`~telegram.Bot.send_checklist` + - Used for sending a checklist on behalf of the business account. + * - :meth:`~telegram.Bot.edit_message_checklist` + - Used for editing a checklist on behalf of the business account. + + +.. raw:: html + +
+
+ +.. raw:: html + +
+ Miscellaneous + +.. list-table:: + :align: left + :widths: 1 4 + * - :meth:`~telegram.Bot.close` - Used for closing server instance when switching to another local server * - :meth:`~telegram.Bot.log_out` - Used for logging out from cloud Bot API server * - :meth:`~telegram.Bot.get_file` - Used for getting basic info about a file + * - :meth:`~telegram.Bot.get_available_gifts` + - Used for getting information about gifts available for sending + * - :meth:`~telegram.Bot.get_chat_gifts` + - Used for getting information about gifts owned and hosted by a chat * - :meth:`~telegram.Bot.get_me` - Used for getting basic information about the bot + * - :meth:`~telegram.Bot.get_user_gifts` + - Used for getting information about gifts owned and hosted by a user + * - :meth:`~telegram.Bot.save_prepared_inline_message` + - Used for storing a message to be sent by a user of a Mini App .. raw:: html 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/stability_policy.rst b/docs/source/stability_policy.rst index 3927203577f..621b99b540e 100644 --- a/docs/source/stability_policy.rst +++ b/docs/source/stability_policy.rst @@ -1,3 +1,5 @@ +.. _stability-policy: + Stability Policy ================ @@ -29,7 +31,7 @@ Objects are in general not guaranteed to be pickleable (unless stated otherwise) We may provide a way to convert pickled objects from one version to another, but this is not guaranteed. Functionality that is part of PTBs API but is explicitly documented as not being intended to be used directly by users (e.g. :meth:`telegram.request.BaseRequest.do_request`) may change. -This also applies to functions or attributes marked as final in the sense of `PEP 591 `__. +This also applies to functions or attributes marked as final in the sense of `PEP 591 `__. PTB has dependencies to third-party packages. The versions that PTB uses of these third-party packages may change if that does not affect PTBs public API. diff --git a/docs/source/telegram.acceptedgifttypes.rst b/docs/source/telegram.acceptedgifttypes.rst new file mode 100644 index 00000000000..2926dffd338 --- /dev/null +++ b/docs/source/telegram.acceptedgifttypes.rst @@ -0,0 +1,6 @@ +AcceptedGiftTypes +================= + +.. autoclass:: telegram.AcceptedGiftTypes + :members: + :show-inheritance: diff --git a/docs/source/telegram.affiliateinfo.rst b/docs/source/telegram.affiliateinfo.rst new file mode 100644 index 00000000000..0b2e51863af --- /dev/null +++ b/docs/source/telegram.affiliateinfo.rst @@ -0,0 +1,6 @@ +AffiliateInfo +============= + +.. autoclass:: telegram.AffiliateInfo + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.animation.rst b/docs/source/telegram.animation.rst index 94b5f818721..4e654fad49c 100644 --- a/docs/source/telegram.animation.rst +++ b/docs/source/telegram.animation.rst @@ -6,4 +6,4 @@ Animation .. autoclass:: telegram.Animation :members: :show-inheritance: - :inherited-members: TelegramObject + :inherited-members: TelegramObject, object diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index 3dc01c6d7e2..de76000019d 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -4,8 +4,10 @@ Available Types .. toctree:: :titlesonly: + telegram.acceptedgifttypes telegram.animation telegram.audio + telegram.birthdate telegram.botcommand telegram.botcommandscope telegram.botcommandscopeallchatadministrators @@ -18,9 +20,40 @@ Available Types telegram.botdescription telegram.botname telegram.botshortdescription + telegram.businessbotrights + telegram.businessconnection + telegram.businessintro + telegram.businesslocation + telegram.businessopeninghours + telegram.businessopeninghoursinterval + telegram.businessmessagesdeleted telegram.callbackquery telegram.chat telegram.chatadministratorrights + telegram.chatbackground + telegram.checklist + telegram.checklisttask + telegram.checklisttasksadded + telegram.checklisttasksdone + telegram.copytextbutton + telegram.backgroundtype + telegram.backgroundtypefill + telegram.backgroundtypewallpaper + telegram.backgroundtypepattern + telegram.backgroundtypechattheme + telegram.backgroundfill + telegram.backgroundfillsolid + telegram.backgroundfillgradient + telegram.backgroundfillfreeformgradient + telegram.chatboost + telegram.chatboostadded + telegram.chatboostremoved + telegram.chatboostsource + telegram.chatboostsourcegiftcode + telegram.chatboostsourcegiveaway + telegram.chatboostsourcepremium + telegram.chatboostupdated + telegram.chatfullinfo telegram.chatinvitelink telegram.chatjoinrequest telegram.chatlocation @@ -37,7 +70,10 @@ Available Types telegram.chatshared telegram.contact telegram.dice + telegram.directmessagepricechanged + telegram.directmessagestopic telegram.document + telegram.externalreplyinfo telegram.file telegram.forcereply telegram.forumtopic @@ -47,8 +83,17 @@ Available Types telegram.forumtopicreopened telegram.generalforumtopichidden telegram.generalforumtopicunhidden + telegram.giftbackground + telegram.giftinfo + telegram.giveaway + telegram.giveawaycompleted + telegram.giveawaycreated + telegram.giveawaywinners + telegram.inaccessiblemessage telegram.inlinekeyboardbutton telegram.inlinekeyboardmarkup + telegram.inputchecklist + telegram.inputchecklisttask telegram.inputfile telegram.inputmedia telegram.inputmediaanimation @@ -56,13 +101,25 @@ Available Types telegram.inputmediadocument telegram.inputmediaphoto telegram.inputmediavideo - telegram.inputsticker + telegram.inputpaidmedia + telegram.inputpaidmediaphoto + telegram.inputpaidmediavideo + telegram.inputprofilephoto + telegram.inputprofilephotoanimated + telegram.inputprofilephotostatic + telegram.inputpolloption + telegram.inputstorycontent + telegram.inputstorycontentphoto + telegram.inputstorycontentvideo telegram.keyboardbutton telegram.keyboardbuttonpolltype telegram.keyboardbuttonrequestchat - telegram.keyboardbuttonrequestuser + telegram.keyboardbuttonrequestusers + telegram.linkpreviewoptions telegram.location + telegram.locationaddress telegram.loginurl + telegram.maybeinaccessiblemessage telegram.menubutton telegram.menubuttoncommands telegram.menubuttondefault @@ -71,20 +128,72 @@ Available Types telegram.messageautodeletetimerchanged telegram.messageentity telegram.messageid + telegram.messageorigin + telegram.messageoriginchannel + telegram.messageoriginchat + telegram.messageoriginhiddenuser + telegram.messageoriginuser + telegram.messagereactioncountupdated + telegram.messagereactionupdated + telegram.ownedgift + telegram.ownedgiftregular + telegram.ownedgifts + telegram.ownedgiftunique + telegram.paidmedia + telegram.paidmediainfo + telegram.paidmediaphoto + telegram.paidmediapreview + telegram.paidmediapurchased + telegram.paidmediavideo + telegram.paidmessagepricechanged telegram.photosize telegram.poll telegram.pollanswer telegram.polloption telegram.proximityalerttriggered + telegram.reactioncount + telegram.reactiontype + telegram.reactiontypecustomemoji + telegram.reactiontypeemoji + telegram.reactiontypepaid telegram.replykeyboardmarkup telegram.replykeyboardremove + telegram.replyparameters telegram.sentwebappmessage + telegram.shareduser + telegram.story + telegram.storyarea + telegram.storyareaposition + telegram.storyareatype + telegram.storyareatypelink + telegram.storyareatypelocation + telegram.storyareatypesuggestedreaction + telegram.storyareatypeuniquegift + telegram.storyareatypeweather + telegram.suggestedpostapprovalfailed + telegram.suggestedpostapproved + telegram.suggestedpostdeclined + telegram.suggestedpostinfo + telegram.suggestedpostpaid + telegram.suggestedpostparameters + telegram.suggestedpostprice + telegram.suggestedpostrefunded telegram.switchinlinequerychosenchat telegram.telegramobject + telegram.textquote + telegram.uniquegift + telegram.uniquegiftcolors + telegram.uniquegiftbackdrop + telegram.uniquegiftbackdropcolors + telegram.uniquegiftinfo + telegram.uniquegiftmodel + telegram.uniquegiftsymbol telegram.update telegram.user + telegram.userchatboosts telegram.userprofilephotos - telegram.usershared + telegram.userrating + telegram.usersshared telegram.venue telegram.video telegram.videochatended diff --git a/docs/source/telegram.audio.rst b/docs/source/telegram.audio.rst index 9e501f70141..563de6c0289 100644 --- a/docs/source/telegram.audio.rst +++ b/docs/source/telegram.audio.rst @@ -6,4 +6,4 @@ Audio .. autoclass:: telegram.Audio :members: :show-inheritance: - :inherited-members: TelegramObject + :inherited-members: TelegramObject, object diff --git a/docs/source/telegram.backgroundfill.rst b/docs/source/telegram.backgroundfill.rst new file mode 100644 index 00000000000..0c7c03cb737 --- /dev/null +++ b/docs/source/telegram.backgroundfill.rst @@ -0,0 +1,8 @@ +BackgroundFill +============== + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundFill + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.backgroundfillfreeformgradient.rst b/docs/source/telegram.backgroundfillfreeformgradient.rst new file mode 100644 index 00000000000..663c15e8181 --- /dev/null +++ b/docs/source/telegram.backgroundfillfreeformgradient.rst @@ -0,0 +1,8 @@ +BackgroundFillFreeformGradient +============================== + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundFillFreeformGradient + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.backgroundfillgradient.rst b/docs/source/telegram.backgroundfillgradient.rst new file mode 100644 index 00000000000..313d4bd6468 --- /dev/null +++ b/docs/source/telegram.backgroundfillgradient.rst @@ -0,0 +1,8 @@ +BackgroundFillGradient +====================== + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundFillGradient + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.backgroundfillsolid.rst b/docs/source/telegram.backgroundfillsolid.rst new file mode 100644 index 00000000000..5130de5f988 --- /dev/null +++ b/docs/source/telegram.backgroundfillsolid.rst @@ -0,0 +1,8 @@ +BackgroundFillSolid +=================== + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundFillSolid + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.backgroundtype.rst b/docs/source/telegram.backgroundtype.rst new file mode 100644 index 00000000000..08e9dc0222a --- /dev/null +++ b/docs/source/telegram.backgroundtype.rst @@ -0,0 +1,8 @@ +BackgroundType +============== + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundType + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.backgroundtypechattheme.rst b/docs/source/telegram.backgroundtypechattheme.rst new file mode 100644 index 00000000000..1d26bde2076 --- /dev/null +++ b/docs/source/telegram.backgroundtypechattheme.rst @@ -0,0 +1,8 @@ +BackgroundTypeChatTheme +======================= + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundTypeChatTheme + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.backgroundtypefill.rst b/docs/source/telegram.backgroundtypefill.rst new file mode 100644 index 00000000000..636ff3d7ee2 --- /dev/null +++ b/docs/source/telegram.backgroundtypefill.rst @@ -0,0 +1,8 @@ +BackgroundTypeFill +================== + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundTypeFill + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.backgroundtypepattern.rst b/docs/source/telegram.backgroundtypepattern.rst new file mode 100644 index 00000000000..7b14d52bf46 --- /dev/null +++ b/docs/source/telegram.backgroundtypepattern.rst @@ -0,0 +1,8 @@ +BackgroundTypePattern +===================== + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundTypePattern + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.backgroundtypewallpaper.rst b/docs/source/telegram.backgroundtypewallpaper.rst new file mode 100644 index 00000000000..143c042553b --- /dev/null +++ b/docs/source/telegram.backgroundtypewallpaper.rst @@ -0,0 +1,8 @@ +BackgroundTypeWallpaper +======================= + +.. versionadded:: 21.2 + +.. autoclass:: telegram.BackgroundTypeWallpaper + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.birthdate.rst b/docs/source/telegram.birthdate.rst new file mode 100644 index 00000000000..083de5ebf4a --- /dev/null +++ b/docs/source/telegram.birthdate.rst @@ -0,0 +1,7 @@ +Birthdate +========= + +.. autoclass:: telegram.Birthdate + :members: + :show-inheritance: + diff --git a/docs/source/telegram.bot.rst b/docs/source/telegram.bot.rst index a12f5706f91..93211517ee4 100644 --- a/docs/source/telegram.bot.rst +++ b/docs/source/telegram.bot.rst @@ -4,4 +4,3 @@ Bot .. autoclass:: telegram.Bot :members: :show-inheritance: - :special-members: __reduce__, __deepcopy__ \ No newline at end of file diff --git a/docs/source/telegram.businessbotrights.rst b/docs/source/telegram.businessbotrights.rst new file mode 100644 index 00000000000..d6bdab1a809 --- /dev/null +++ b/docs/source/telegram.businessbotrights.rst @@ -0,0 +1,6 @@ +BusinessBotRights +================= + +.. autoclass:: telegram.BusinessBotRights + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.businessconnection.rst b/docs/source/telegram.businessconnection.rst new file mode 100644 index 00000000000..3ef31c3b25e --- /dev/null +++ b/docs/source/telegram.businessconnection.rst @@ -0,0 +1,6 @@ +BusinessConnection +================== + +.. autoclass:: telegram.BusinessConnection + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.businessintro.rst b/docs/source/telegram.businessintro.rst new file mode 100644 index 00000000000..4870258e5b4 --- /dev/null +++ b/docs/source/telegram.businessintro.rst @@ -0,0 +1,6 @@ +BusinessIntro +================== + +.. autoclass:: telegram.BusinessIntro + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.businesslocation.rst b/docs/source/telegram.businesslocation.rst new file mode 100644 index 00000000000..1a1b8893b65 --- /dev/null +++ b/docs/source/telegram.businesslocation.rst @@ -0,0 +1,6 @@ +BusinessLocation +================== + +.. autoclass:: telegram.BusinessLocation + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.businessmessagesdeleted.rst b/docs/source/telegram.businessmessagesdeleted.rst new file mode 100644 index 00000000000..ba0e88e3cba --- /dev/null +++ b/docs/source/telegram.businessmessagesdeleted.rst @@ -0,0 +1,6 @@ +BusinessMessagesDeleted +======================= + +.. autoclass:: telegram.BusinessMessagesDeleted + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.businessopeninghours.rst b/docs/source/telegram.businessopeninghours.rst new file mode 100644 index 00000000000..cab989c8475 --- /dev/null +++ b/docs/source/telegram.businessopeninghours.rst @@ -0,0 +1,6 @@ +BusinessOpeningHours +==================== + +.. autoclass:: telegram.BusinessOpeningHours + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.businessopeninghoursinterval.rst b/docs/source/telegram.businessopeninghoursinterval.rst new file mode 100644 index 00000000000..241379dbcfb --- /dev/null +++ b/docs/source/telegram.businessopeninghoursinterval.rst @@ -0,0 +1,6 @@ +BusinessOpeningHoursInterval +============================ + +.. autoclass:: telegram.BusinessOpeningHoursInterval + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chat.rst b/docs/source/telegram.chat.rst index 3ef9672472f..df53940c4a7 100644 --- a/docs/source/telegram.chat.rst +++ b/docs/source/telegram.chat.rst @@ -1,6 +1,8 @@ Chat ==== +.. Also lists methods of _ChatBase, but not the ones of TelegramObject .. autoclass:: telegram.Chat :members: :show-inheritance: + :inherited-members: TelegramObject, object diff --git a/docs/source/telegram.chatbackground.rst b/docs/source/telegram.chatbackground.rst new file mode 100644 index 00000000000..6f43b27fb80 --- /dev/null +++ b/docs/source/telegram.chatbackground.rst @@ -0,0 +1,8 @@ +ChatBackground +============== + +.. versionadded:: 21.2 + +.. autoclass:: telegram.ChatBackground + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatboost.rst b/docs/source/telegram.chatboost.rst new file mode 100644 index 00000000000..460240c6ce4 --- /dev/null +++ b/docs/source/telegram.chatboost.rst @@ -0,0 +1,8 @@ +ChatBoost +========= + +.. versionadded:: 20.8 + +.. autoclass:: telegram.ChatBoost + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatboostadded.rst b/docs/source/telegram.chatboostadded.rst new file mode 100644 index 00000000000..b4551e75b84 --- /dev/null +++ b/docs/source/telegram.chatboostadded.rst @@ -0,0 +1,6 @@ +ChatBoostAdded +============== + +.. autoclass:: telegram.ChatBoostAdded + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatboostremoved.rst b/docs/source/telegram.chatboostremoved.rst new file mode 100644 index 00000000000..ff7e1f37fd7 --- /dev/null +++ b/docs/source/telegram.chatboostremoved.rst @@ -0,0 +1,8 @@ +ChatBoostRemoved +================ + +.. versionadded:: 20.8 + +.. autoclass:: telegram.ChatBoostRemoved + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatboostsource.rst b/docs/source/telegram.chatboostsource.rst new file mode 100644 index 00000000000..ab51f95c640 --- /dev/null +++ b/docs/source/telegram.chatboostsource.rst @@ -0,0 +1,8 @@ +ChatBoostSource +=============== + +.. versionadded:: 20.8 + +.. autoclass:: telegram.ChatBoostSource + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatboostsourcegiftcode.rst b/docs/source/telegram.chatboostsourcegiftcode.rst new file mode 100644 index 00000000000..8283d286a8c --- /dev/null +++ b/docs/source/telegram.chatboostsourcegiftcode.rst @@ -0,0 +1,8 @@ +ChatBoostSourceGiftCode +======================= + +.. versionadded:: 20.8 + +.. autoclass:: telegram.ChatBoostSourceGiftCode + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatboostsourcegiveaway.rst b/docs/source/telegram.chatboostsourcegiveaway.rst new file mode 100644 index 00000000000..a11a8a4ba45 --- /dev/null +++ b/docs/source/telegram.chatboostsourcegiveaway.rst @@ -0,0 +1,8 @@ +ChatBoostSourceGiveaway +======================= + +.. versionadded:: 20.8 + +.. autoclass:: telegram.ChatBoostSourceGiveaway + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatboostsourcepremium.rst b/docs/source/telegram.chatboostsourcepremium.rst new file mode 100644 index 00000000000..78f2c5dfd19 --- /dev/null +++ b/docs/source/telegram.chatboostsourcepremium.rst @@ -0,0 +1,8 @@ +ChatBoostSourcePremium +====================== + +.. versionadded:: 20.8 + +.. autoclass:: telegram.ChatBoostSourcePremium + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatboostupdated.rst b/docs/source/telegram.chatboostupdated.rst new file mode 100644 index 00000000000..9fc99a62d93 --- /dev/null +++ b/docs/source/telegram.chatboostupdated.rst @@ -0,0 +1,8 @@ +ChatBoostUpdated +================ + +.. versionadded:: 20.8 + +.. autoclass:: telegram.ChatBoostUpdated + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.chatfullinfo.rst b/docs/source/telegram.chatfullinfo.rst new file mode 100644 index 00000000000..7ba8f3d3828 --- /dev/null +++ b/docs/source/telegram.chatfullinfo.rst @@ -0,0 +1,8 @@ +ChatFullInfo +============ + +.. Also lists methods of _ChatBase, but not the ones of TelegramObject +.. autoclass:: telegram.ChatFullInfo + :members: + :show-inheritance: + :inherited-members: TelegramObject, object \ No newline at end of file diff --git a/docs/source/telegram.checklist.rst b/docs/source/telegram.checklist.rst new file mode 100644 index 00000000000..a01dac43aad --- /dev/null +++ b/docs/source/telegram.checklist.rst @@ -0,0 +1,6 @@ +Checklist +========= + +.. autoclass:: telegram.Checklist + :members: + :show-inheritance: diff --git a/docs/source/telegram.checklisttask.rst b/docs/source/telegram.checklisttask.rst new file mode 100644 index 00000000000..27f44d629de --- /dev/null +++ b/docs/source/telegram.checklisttask.rst @@ -0,0 +1,6 @@ +ChecklistTask +============= + +.. autoclass:: telegram.ChecklistTask + :members: + :show-inheritance: diff --git a/docs/source/telegram.checklisttasksadded.rst b/docs/source/telegram.checklisttasksadded.rst new file mode 100644 index 00000000000..d3c33c02300 --- /dev/null +++ b/docs/source/telegram.checklisttasksadded.rst @@ -0,0 +1,6 @@ +ChecklistTasksAdded +=================== + +.. autoclass:: telegram.ChecklistTasksAdded + :members: + :show-inheritance: diff --git a/docs/source/telegram.checklisttasksdone.rst b/docs/source/telegram.checklisttasksdone.rst new file mode 100644 index 00000000000..aa1e0b83f84 --- /dev/null +++ b/docs/source/telegram.checklisttasksdone.rst @@ -0,0 +1,6 @@ +ChecklistTasksDone +================== + +.. autoclass:: telegram.ChecklistTasksDone + :members: + :show-inheritance: diff --git a/docs/source/telegram.constants.rst b/docs/source/telegram.constants.rst index 1249514650f..618b35246f1 100644 --- a/docs/source/telegram.constants.rst +++ b/docs/source/telegram.constants.rst @@ -4,3 +4,5 @@ telegram.constants Module .. automodule:: telegram.constants :members: :show-inheritance: + :no-undoc-members: + :exclude-members: __format__, __new__, __repr__, __str__ diff --git a/docs/source/telegram.copytextbutton.rst b/docs/source/telegram.copytextbutton.rst new file mode 100644 index 00000000000..7110fbf8b6b --- /dev/null +++ b/docs/source/telegram.copytextbutton.rst @@ -0,0 +1,6 @@ +CopyTextButton +============== + +.. autoclass:: telegram.CopyTextButton + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.directmessagepricechanged.rst b/docs/source/telegram.directmessagepricechanged.rst new file mode 100644 index 00000000000..64356e1a689 --- /dev/null +++ b/docs/source/telegram.directmessagepricechanged.rst @@ -0,0 +1,6 @@ +DirectMessagePriceChanged +========================= + +.. autoclass:: telegram.DirectMessagePriceChanged + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.directmessagestopic.rst b/docs/source/telegram.directmessagestopic.rst new file mode 100644 index 00000000000..9779a021c91 --- /dev/null +++ b/docs/source/telegram.directmessagestopic.rst @@ -0,0 +1,6 @@ +DirectMessagesTopic +=================== + +.. autoclass:: telegram.DirectMessagesTopic + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.document.rst b/docs/source/telegram.document.rst index e59a84ba674..1a337077069 100644 --- a/docs/source/telegram.document.rst +++ b/docs/source/telegram.document.rst @@ -5,4 +5,4 @@ Document .. autoclass:: telegram.Document :members: :show-inheritance: - :inherited-members: TelegramObject + :inherited-members: TelegramObject, object diff --git a/docs/source/telegram.ext.businessconnectionhandler.rst b/docs/source/telegram.ext.businessconnectionhandler.rst new file mode 100644 index 00000000000..0b0509dff2f --- /dev/null +++ b/docs/source/telegram.ext.businessconnectionhandler.rst @@ -0,0 +1,6 @@ +BusinessConnectionHandler +========================= + +.. autoclass:: telegram.ext.BusinessConnectionHandler + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.ext.businessmessagesdeletedhandler.rst b/docs/source/telegram.ext.businessmessagesdeletedhandler.rst new file mode 100644 index 00000000000..840f19325a0 --- /dev/null +++ b/docs/source/telegram.ext.businessmessagesdeletedhandler.rst @@ -0,0 +1,6 @@ +BusinessMessagesDeletedHandler +============================== + +.. autoclass:: telegram.ext.BusinessMessagesDeletedHandler + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.ext.chatboosthandler.rst b/docs/source/telegram.ext.chatboosthandler.rst new file mode 100644 index 00000000000..992972600d4 --- /dev/null +++ b/docs/source/telegram.ext.chatboosthandler.rst @@ -0,0 +1,8 @@ +ChatBoostHandler +================ + +.. versionadded:: 20.8 + +.. autoclass:: telegram.ext.ChatBoostHandler + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.ext.filters.rst b/docs/source/telegram.ext.filters.rst index 24306dca3ae..7762ab01036 100644 --- a/docs/source/telegram.ext.filters.rst +++ b/docs/source/telegram.ext.filters.rst @@ -5,7 +5,7 @@ filters Module The classes in `filters.py` are sorted alphabetically such that :bysource: still is readable .. automodule:: telegram.ext.filters - :inherited-members: BaseFilter, MessageFilter, UpdateFilter + :inherited-members: BaseFilter, MessageFilter, UpdateFilter, object :members: :show-inheritance: :member-order: bysource \ No newline at end of file diff --git a/docs/source/telegram.ext.handlers-tree.rst b/docs/source/telegram.ext.handlers-tree.rst index b918d6a92dd..72e0d824c53 100644 --- a/docs/source/telegram.ext.handlers-tree.rst +++ b/docs/source/telegram.ext.handlers-tree.rst @@ -5,7 +5,10 @@ Handlers :titlesonly: telegram.ext.basehandler + telegram.ext.businessconnectionhandler + telegram.ext.businessmessagesdeletedhandler telegram.ext.callbackqueryhandler + telegram.ext.chatboosthandler telegram.ext.chatjoinrequesthandler telegram.ext.chatmemberhandler telegram.ext.choseninlineresulthandler @@ -14,6 +17,8 @@ Handlers telegram.ext.filters telegram.ext.inlinequeryhandler telegram.ext.messagehandler + telegram.ext.messagereactionhandler + telegram.ext.paidmediapurchasedhandler telegram.ext.pollanswerhandler telegram.ext.pollhandler telegram.ext.precheckoutqueryhandler diff --git a/docs/source/telegram.ext.job.rst b/docs/source/telegram.ext.job.rst index 469f8875f31..0f69756b371 100644 --- a/docs/source/telegram.ext.job.rst +++ b/docs/source/telegram.ext.job.rst @@ -4,4 +4,3 @@ Job .. autoclass:: telegram.ext.Job :members: :show-inheritance: - :special-members: __call__ diff --git a/docs/source/telegram.ext.messagereactionhandler.rst b/docs/source/telegram.ext.messagereactionhandler.rst new file mode 100644 index 00000000000..1aad333ff1c --- /dev/null +++ b/docs/source/telegram.ext.messagereactionhandler.rst @@ -0,0 +1,6 @@ +MessageReactionHandler +====================== + +.. autoclass:: telegram.ext.MessageReactionHandler + :members: + :show-inheritance: diff --git a/docs/source/telegram.ext.paidmediapurchasedhandler.rst b/docs/source/telegram.ext.paidmediapurchasedhandler.rst new file mode 100644 index 00000000000..19bfbeea31e --- /dev/null +++ b/docs/source/telegram.ext.paidmediapurchasedhandler.rst @@ -0,0 +1,6 @@ +PaidMediaPurchasedHandler +========================= + +.. autoclass:: telegram.ext.PaidMediaPurchasedHandler + :members: + :show-inheritance: diff --git a/docs/source/telegram.externalreplyinfo.rst b/docs/source/telegram.externalreplyinfo.rst new file mode 100644 index 00000000000..568bf07ef38 --- /dev/null +++ b/docs/source/telegram.externalreplyinfo.rst @@ -0,0 +1,6 @@ +ExternalReplyInfo +================= + +.. autoclass:: telegram.ExternalReplyInfo + :members: + :show-inheritance: diff --git a/docs/source/telegram.games-tree.rst b/docs/source/telegram.games-tree.rst index 64f399d86a9..97b961a9e85 100644 --- a/docs/source/telegram.games-tree.rst +++ b/docs/source/telegram.games-tree.rst @@ -1,6 +1,21 @@ +.. _games-tree: + Games ----- +Your bot can offer users **HTML5 games** to play solo or to compete against each other in groups and one-on-one chats. Create games via `@BotFather `_ using the ``/newgame`` command. Please note that this kind of power requires responsibility: you will need to accept the terms for each game that your bots will be offering. + +* Games are a new type of content on Telegram, represented by the :class:`telegram.Game` and :class:`telegram.InlineQueryResultGame` objects. +* Once you've created a game via `BotFather `_, you can send games to chats as regular messages using the :meth:`~telegram.Bot.sendGame` method, or use :ref:`inline mode ` with :class:`telegram.InlineQueryResultGame`. +* If you send the game message without any buttons, it will automatically have a 'Play ``GameName``' button. When this button is pressed, your bot gets a :class:`telegram.CallbackQuery` with the ``game_short_name`` of the requested game. You provide the correct URL for this particular user and the app opens the game in the in-app browser. +* You can manually add multiple buttons to your game message. Please note that the first button in the first row **must always** launch the game, using the field ``callback_game`` in :class:`telegram.InlineKeyboardButton`. You can add extra buttons according to taste: e.g., for a description of the rules, or to open the game's official community. +* To make your game more attractive, you can upload a GIF animation that demonstrates the game to the users via `BotFather `_ (see `Lumberjack `_ for example). +* A game message will also display high scores for the current chat. Use :meth:`~telegram.Bot.setGameScore` to post high scores to the chat with the game, add the :paramref:`~telegram.Bot.set_game_score.disable_edit_message` parameter to disable automatic update of the message with the current scoreboard. +* Use :meth:`~telegram.Bot.getGameHighScores` to get data for in-game high score tables. +* You can also add an extra sharing button for users to share their best score to different chats. +* For examples of what can be done using this new stuff, check the `@gamebot `_ and `@gamee `_ bots. + + .. toctree:: :titlesonly: diff --git a/docs/source/telegram.gift.rst b/docs/source/telegram.gift.rst new file mode 100644 index 00000000000..e42cb720ac2 --- /dev/null +++ b/docs/source/telegram.gift.rst @@ -0,0 +1,6 @@ +Gift +==== + +.. autoclass:: telegram.Gift + :members: + :show-inheritance: diff --git a/docs/source/telegram.giftbackground.rst b/docs/source/telegram.giftbackground.rst new file mode 100644 index 00000000000..c2785ff67b0 --- /dev/null +++ b/docs/source/telegram.giftbackground.rst @@ -0,0 +1,7 @@ +GiftBackground +============== + +.. autoclass:: telegram.GiftBackground + :members: + :show-inheritance: + diff --git a/docs/source/telegram.giftinfo.rst b/docs/source/telegram.giftinfo.rst new file mode 100644 index 00000000000..ff5ab6ad352 --- /dev/null +++ b/docs/source/telegram.giftinfo.rst @@ -0,0 +1,7 @@ +GiftInfo +======== + +.. autoclass:: telegram.GiftInfo + :members: + :show-inheritance: + diff --git a/docs/source/telegram.gifts.rst b/docs/source/telegram.gifts.rst new file mode 100644 index 00000000000..649522d0dce --- /dev/null +++ b/docs/source/telegram.gifts.rst @@ -0,0 +1,6 @@ +Gifts +===== + +.. autoclass:: telegram.Gifts + :members: + :show-inheritance: diff --git a/docs/source/telegram.giveaway.rst b/docs/source/telegram.giveaway.rst new file mode 100644 index 00000000000..8d1d854985a --- /dev/null +++ b/docs/source/telegram.giveaway.rst @@ -0,0 +1,6 @@ +Giveaway +======== + +.. autoclass:: telegram.Giveaway + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.giveawaycompleted.rst b/docs/source/telegram.giveawaycompleted.rst new file mode 100644 index 00000000000..c89e9564e85 --- /dev/null +++ b/docs/source/telegram.giveawaycompleted.rst @@ -0,0 +1,6 @@ +GiveawayCompleted +================= + +.. autoclass:: telegram.GiveawayCompleted + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.giveawaycreated.rst b/docs/source/telegram.giveawaycreated.rst new file mode 100644 index 00000000000..f29de887751 --- /dev/null +++ b/docs/source/telegram.giveawaycreated.rst @@ -0,0 +1,6 @@ +GiveawayCreated +=============== + +.. autoclass:: telegram.GiveawayCreated + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.giveawaywinners.rst b/docs/source/telegram.giveawaywinners.rst new file mode 100644 index 00000000000..4be51e8502b --- /dev/null +++ b/docs/source/telegram.giveawaywinners.rst @@ -0,0 +1,6 @@ +GiveawayWinners +=============== + +.. autoclass:: telegram.GiveawayWinners + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inaccessiblemessage.rst b/docs/source/telegram.inaccessiblemessage.rst new file mode 100644 index 00000000000..d65c8c42c71 --- /dev/null +++ b/docs/source/telegram.inaccessiblemessage.rst @@ -0,0 +1,6 @@ +InaccessibleMessage +=================== + +.. autoclass:: telegram.InaccessibleMessage + :members: + :show-inheritance: diff --git a/docs/source/telegram.inline-tree.rst b/docs/source/telegram.inline-tree.rst index 7fa52a94b58..c21b3c33828 100644 --- a/docs/source/telegram.inline-tree.rst +++ b/docs/source/telegram.inline-tree.rst @@ -1,6 +1,14 @@ +.. _inline-tree: + Inline Mode ----------- +The following methods and objects allow your bot to work in `inline mode `_. +Please see Telegrams `Introduction to Inline bots `_ for more details. + +To enable this option, send the ``/setinline`` command to `@BotFather `_ and provide the placeholder text that the user will see in the input field after typing your bot's name. + + .. toctree:: :titlesonly: @@ -34,3 +42,4 @@ Inline Mode telegram.inputvenuemessagecontent telegram.inputcontactmessagecontent telegram.inputinvoicemessagecontent + telegram.preparedinlinemessage diff --git a/docs/source/telegram.inputchecklist.rst b/docs/source/telegram.inputchecklist.rst new file mode 100644 index 00000000000..f83345884a0 --- /dev/null +++ b/docs/source/telegram.inputchecklist.rst @@ -0,0 +1,6 @@ +InputChecklist +============== + +.. autoclass:: telegram.InputChecklist + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputchecklisttask.rst b/docs/source/telegram.inputchecklisttask.rst new file mode 100644 index 00000000000..1cc14095b0c --- /dev/null +++ b/docs/source/telegram.inputchecklisttask.rst @@ -0,0 +1,6 @@ +InputChecklistTask +================== + +.. autoclass:: telegram.InputChecklistTask + :members: + :show-inheritance: 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.inputpolloption.rst b/docs/source/telegram.inputpolloption.rst new file mode 100644 index 00000000000..51a2aab5a3b --- /dev/null +++ b/docs/source/telegram.inputpolloption.rst @@ -0,0 +1,6 @@ +InputPollOption +=============== + +.. autoclass:: telegram.InputPollOption + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputprofilephoto.rst b/docs/source/telegram.inputprofilephoto.rst new file mode 100644 index 00000000000..723f3c92389 --- /dev/null +++ b/docs/source/telegram.inputprofilephoto.rst @@ -0,0 +1,6 @@ +InputProfilePhoto +================= + +.. autoclass:: telegram.InputProfilePhoto + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputprofilephotoanimated.rst b/docs/source/telegram.inputprofilephotoanimated.rst new file mode 100644 index 00000000000..c192d0d8e58 --- /dev/null +++ b/docs/source/telegram.inputprofilephotoanimated.rst @@ -0,0 +1,6 @@ +InputProfilePhotoAnimated +========================= + +.. autoclass:: telegram.InputProfilePhotoAnimated + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputprofilephotostatic.rst b/docs/source/telegram.inputprofilephotostatic.rst new file mode 100644 index 00000000000..49b498c13ba --- /dev/null +++ b/docs/source/telegram.inputprofilephotostatic.rst @@ -0,0 +1,6 @@ +InputProfilePhotoStatic +======================= + +.. autoclass:: telegram.InputProfilePhotoStatic + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputstorycontent.rst b/docs/source/telegram.inputstorycontent.rst new file mode 100644 index 00000000000..3406e8cf253 --- /dev/null +++ b/docs/source/telegram.inputstorycontent.rst @@ -0,0 +1,6 @@ +InputStoryContent +================= + +.. autoclass:: telegram.InputStoryContent + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputstorycontentphoto.rst b/docs/source/telegram.inputstorycontentphoto.rst new file mode 100644 index 00000000000..1adacb2322c --- /dev/null +++ b/docs/source/telegram.inputstorycontentphoto.rst @@ -0,0 +1,6 @@ +InputStoryContentPhoto +====================== + +.. autoclass:: telegram.InputStoryContentPhoto + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputstorycontentvideo.rst b/docs/source/telegram.inputstorycontentvideo.rst new file mode 100644 index 00000000000..27550468e3b --- /dev/null +++ b/docs/source/telegram.inputstorycontentvideo.rst @@ -0,0 +1,6 @@ +InputStoryContentVideo +====================== + +.. autoclass:: telegram.InputStoryContentVideo + :members: + :show-inheritance: diff --git a/docs/source/telegram.keyboardbuttonrequestuser.rst b/docs/source/telegram.keyboardbuttonrequestuser.rst deleted file mode 100644 index f6e4c3608eb..00000000000 --- a/docs/source/telegram.keyboardbuttonrequestuser.rst +++ /dev/null @@ -1,6 +0,0 @@ -KeyboardButtonRequestUser -================================== - -.. autoclass:: telegram.KeyboardButtonRequestUser - :members: - :show-inheritance: diff --git a/docs/source/telegram.keyboardbuttonrequestusers.rst b/docs/source/telegram.keyboardbuttonrequestusers.rst new file mode 100644 index 00000000000..a56e9fb4316 --- /dev/null +++ b/docs/source/telegram.keyboardbuttonrequestusers.rst @@ -0,0 +1,6 @@ +KeyboardButtonRequestUsers +========================== + +.. autoclass:: telegram.KeyboardButtonRequestUsers + :members: + :show-inheritance: diff --git a/docs/source/telegram.linkpreviewoptions.rst b/docs/source/telegram.linkpreviewoptions.rst new file mode 100644 index 00000000000..53b46cdcf80 --- /dev/null +++ b/docs/source/telegram.linkpreviewoptions.rst @@ -0,0 +1,6 @@ +LinkPreviewOptions +================== + +.. autoclass:: telegram.LinkPreviewOptions + :members: + :show-inheritance: diff --git a/docs/source/telegram.locationaddress.rst b/docs/source/telegram.locationaddress.rst new file mode 100644 index 00000000000..f6e3874de9d --- /dev/null +++ b/docs/source/telegram.locationaddress.rst @@ -0,0 +1,6 @@ +LocationAddress +=============== + +.. autoclass:: telegram.LocationAddress + :members: + :show-inheritance: diff --git a/docs/source/telegram.maybeinaccessiblemessage.rst b/docs/source/telegram.maybeinaccessiblemessage.rst new file mode 100644 index 00000000000..920d757c487 --- /dev/null +++ b/docs/source/telegram.maybeinaccessiblemessage.rst @@ -0,0 +1,6 @@ +MaybeInaccessibleMessage +======================== + +.. autoclass:: telegram.MaybeInaccessibleMessage + :members: + :show-inheritance: diff --git a/docs/source/telegram.messageorigin.rst b/docs/source/telegram.messageorigin.rst new file mode 100644 index 00000000000..ed0cf6905e2 --- /dev/null +++ b/docs/source/telegram.messageorigin.rst @@ -0,0 +1,6 @@ +MessageOrigin +============= + +.. autoclass:: telegram.MessageOrigin + :members: + :show-inheritance: diff --git a/docs/source/telegram.messageoriginchannel.rst b/docs/source/telegram.messageoriginchannel.rst new file mode 100644 index 00000000000..bddd957a3f3 --- /dev/null +++ b/docs/source/telegram.messageoriginchannel.rst @@ -0,0 +1,6 @@ +MessageOriginChannel +==================== + +.. autoclass:: telegram.MessageOriginChannel + :members: + :show-inheritance: diff --git a/docs/source/telegram.messageoriginchat.rst b/docs/source/telegram.messageoriginchat.rst new file mode 100644 index 00000000000..928572446aa --- /dev/null +++ b/docs/source/telegram.messageoriginchat.rst @@ -0,0 +1,6 @@ +MessageOriginChat +================= + +.. autoclass:: telegram.MessageOriginChat + :members: + :show-inheritance: diff --git a/docs/source/telegram.messageoriginhiddenuser.rst b/docs/source/telegram.messageoriginhiddenuser.rst new file mode 100644 index 00000000000..7556a94f00c --- /dev/null +++ b/docs/source/telegram.messageoriginhiddenuser.rst @@ -0,0 +1,7 @@ +MessageOriginHiddenUser +======================= + +.. autoclass:: telegram.MessageOriginHiddenUser + :members: + :show-inheritance: + diff --git a/docs/source/telegram.messageoriginuser.rst b/docs/source/telegram.messageoriginuser.rst new file mode 100644 index 00000000000..365bb455d17 --- /dev/null +++ b/docs/source/telegram.messageoriginuser.rst @@ -0,0 +1,6 @@ +MessageOriginUser +================= + +.. autoclass:: telegram.MessageOriginUser + :members: + :show-inheritance: diff --git a/docs/source/telegram.messagereactioncountupdated.rst b/docs/source/telegram.messagereactioncountupdated.rst new file mode 100644 index 00000000000..4a0aead1e81 --- /dev/null +++ b/docs/source/telegram.messagereactioncountupdated.rst @@ -0,0 +1,6 @@ +MessageReactionCountUpdated +=========================== + +.. autoclass:: telegram.MessageReactionCountUpdated + :members: + :show-inheritance: diff --git a/docs/source/telegram.messagereactionupdated.rst b/docs/source/telegram.messagereactionupdated.rst new file mode 100644 index 00000000000..7110fb23fee --- /dev/null +++ b/docs/source/telegram.messagereactionupdated.rst @@ -0,0 +1,6 @@ +MessageReactionUpdated +====================== + +.. autoclass:: telegram.MessageReactionUpdated + :members: + :show-inheritance: diff --git a/docs/source/telegram.ownedgift.rst b/docs/source/telegram.ownedgift.rst new file mode 100644 index 00000000000..0c726895c07 --- /dev/null +++ b/docs/source/telegram.ownedgift.rst @@ -0,0 +1,6 @@ +OwnedGift +========= + +.. autoclass:: telegram.OwnedGift + :members: + :show-inheritance: diff --git a/docs/source/telegram.ownedgiftregular.rst b/docs/source/telegram.ownedgiftregular.rst new file mode 100644 index 00000000000..eb4f3641ed6 --- /dev/null +++ b/docs/source/telegram.ownedgiftregular.rst @@ -0,0 +1,6 @@ +OwnedGiftRegular +================ + +.. autoclass:: telegram.OwnedGiftRegular + :members: + :show-inheritance: diff --git a/docs/source/telegram.ownedgifts.rst b/docs/source/telegram.ownedgifts.rst new file mode 100644 index 00000000000..71a1c51b86f --- /dev/null +++ b/docs/source/telegram.ownedgifts.rst @@ -0,0 +1,6 @@ +OwnedGifts +========== + +.. autoclass:: telegram.OwnedGifts + :members: + :show-inheritance: diff --git a/docs/source/telegram.ownedgiftunique.rst b/docs/source/telegram.ownedgiftunique.rst new file mode 100644 index 00000000000..cc114fecc49 --- /dev/null +++ b/docs/source/telegram.ownedgiftunique.rst @@ -0,0 +1,6 @@ +OwnedGiftUnique +=============== + +.. autoclass:: telegram.OwnedGiftUnique + :members: + :show-inheritance: 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.paidmediapurchased.rst b/docs/source/telegram.paidmediapurchased.rst new file mode 100644 index 00000000000..80568ae405c --- /dev/null +++ b/docs/source/telegram.paidmediapurchased.rst @@ -0,0 +1,6 @@ +PaidMediaPurchased +================== + +.. autoclass:: telegram.PaidMediaPurchased + :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.paidmessagepricechanged.rst b/docs/source/telegram.paidmessagepricechanged.rst new file mode 100644 index 00000000000..3d0e739c456 --- /dev/null +++ b/docs/source/telegram.paidmessagepricechanged.rst @@ -0,0 +1,6 @@ +PaidMessagePriceChanged +======================= + +.. autoclass:: telegram.PaidMessagePriceChanged + :members: + :show-inheritance: diff --git a/docs/source/telegram.passport-tree.rst b/docs/source/telegram.passport-tree.rst index fb4e3b4ffde..079ce948924 100644 --- a/docs/source/telegram.passport-tree.rst +++ b/docs/source/telegram.passport-tree.rst @@ -1,6 +1,9 @@ Passport -------- +Passport is a unified authorization method for services that require personal identification. Users can upload their documents once, then instantly share their data with services that require real-world ID (finance, ICOs, etc.). Please see the `manual `_ for details. + + .. toctree:: :titlesonly: diff --git a/docs/source/telegram.payments-tree.rst b/docs/source/telegram.payments-tree.rst index 67f686ecc4b..94e4fec3c99 100644 --- a/docs/source/telegram.payments-tree.rst +++ b/docs/source/telegram.payments-tree.rst @@ -1,14 +1,36 @@ +.. _payments-tree: + Payments -------- +Your bot can accept payments from Telegram users. Please see the `introduction to payments `_ for more details on the process and how to set up payments for your bot. + + .. toctree:: :titlesonly: + telegram.affiliateinfo telegram.invoice telegram.labeledprice telegram.orderinfo telegram.precheckoutquery + telegram.refundedpayment + telegram.revenuewithdrawalstate + telegram.revenuewithdrawalstatefailed + telegram.revenuewithdrawalstatepending + telegram.revenuewithdrawalstatesucceeded telegram.shippingaddress telegram.shippingoption telegram.shippingquery + telegram.staramount + telegram.startransaction + telegram.startransactions telegram.successfulpayment + telegram.transactionpartner + telegram.transactionpartneraffiliateprogram + telegram.transactionpartnerchat + telegram.transactionpartnerfragment + telegram.transactionpartnerother + telegram.transactionpartnertelegramads + telegram.transactionpartnertelegramapi + telegram.transactionpartneruser diff --git a/docs/source/telegram.photosize.rst b/docs/source/telegram.photosize.rst index d36e6e27fe4..53632ac9bd4 100644 --- a/docs/source/telegram.photosize.rst +++ b/docs/source/telegram.photosize.rst @@ -1,8 +1,8 @@ PhotoSize ========= -.. Also lists methods of _BaseThumbedMedium, but not the ones of TelegramObject +.. Also lists methods of _BaseMedium, but not the ones of TelegramObject .. autoclass:: telegram.PhotoSize :members: :show-inheritance: - :inherited-members: TelegramObject + :inherited-members: TelegramObject, object diff --git a/docs/source/telegram.preparedinlinemessage.rst b/docs/source/telegram.preparedinlinemessage.rst new file mode 100644 index 00000000000..2522f8c58cf --- /dev/null +++ b/docs/source/telegram.preparedinlinemessage.rst @@ -0,0 +1,6 @@ +PreparedInlineMessage +===================== + +.. autoclass:: telegram.PreparedInlineMessage + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.reactioncount.rst b/docs/source/telegram.reactioncount.rst new file mode 100644 index 00000000000..f93a4b760b8 --- /dev/null +++ b/docs/source/telegram.reactioncount.rst @@ -0,0 +1,6 @@ +ReactionCount +============= + +.. autoclass:: telegram.ReactionCount + :members: + :show-inheritance: diff --git a/docs/source/telegram.reactiontype.rst b/docs/source/telegram.reactiontype.rst new file mode 100644 index 00000000000..c726049312a --- /dev/null +++ b/docs/source/telegram.reactiontype.rst @@ -0,0 +1,6 @@ +ReactionType +============ + +.. autoclass:: telegram.ReactionType + :members: + :show-inheritance: diff --git a/docs/source/telegram.reactiontypecustomemoji.rst b/docs/source/telegram.reactiontypecustomemoji.rst new file mode 100644 index 00000000000..e4faf95d9e5 --- /dev/null +++ b/docs/source/telegram.reactiontypecustomemoji.rst @@ -0,0 +1,6 @@ +ReactionTypeCustomEmoji +======================= + +.. autoclass:: telegram.ReactionTypeCustomEmoji + :members: + :show-inheritance: diff --git a/docs/source/telegram.reactiontypeemoji.rst b/docs/source/telegram.reactiontypeemoji.rst new file mode 100644 index 00000000000..cebad3665da --- /dev/null +++ b/docs/source/telegram.reactiontypeemoji.rst @@ -0,0 +1,6 @@ +ReactionTypeEmoji +================= + +.. autoclass:: telegram.ReactionTypeEmoji + :members: + :show-inheritance: diff --git a/docs/source/telegram.reactiontypepaid.rst b/docs/source/telegram.reactiontypepaid.rst new file mode 100644 index 00000000000..f5035a1ba5b --- /dev/null +++ b/docs/source/telegram.reactiontypepaid.rst @@ -0,0 +1,6 @@ +ReactionTypePaid +================ + +.. autoclass:: telegram.ReactionTypePaid + :members: + :show-inheritance: 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.replyparameters.rst b/docs/source/telegram.replyparameters.rst new file mode 100644 index 00000000000..efe32e10441 --- /dev/null +++ b/docs/source/telegram.replyparameters.rst @@ -0,0 +1,6 @@ +ReplyParameters +=============== + +.. autoclass:: telegram.ReplyParameters + :members: + :show-inheritance: diff --git a/docs/source/telegram.revenuewithdrawalstate.rst b/docs/source/telegram.revenuewithdrawalstate.rst new file mode 100644 index 00000000000..a8b0d9ef0ef --- /dev/null +++ b/docs/source/telegram.revenuewithdrawalstate.rst @@ -0,0 +1,6 @@ +RevenueWithdrawalState +====================== + +.. autoclass:: telegram.RevenueWithdrawalState + :members: + :show-inheritance: diff --git a/docs/source/telegram.revenuewithdrawalstatefailed.rst b/docs/source/telegram.revenuewithdrawalstatefailed.rst new file mode 100644 index 00000000000..63379122869 --- /dev/null +++ b/docs/source/telegram.revenuewithdrawalstatefailed.rst @@ -0,0 +1,6 @@ +RevenueWithdrawalStateFailed +============================= + +.. autoclass:: telegram.RevenueWithdrawalStateFailed + :members: + :show-inheritance: diff --git a/docs/source/telegram.revenuewithdrawalstatepending.rst b/docs/source/telegram.revenuewithdrawalstatepending.rst new file mode 100644 index 00000000000..3c2110271c0 --- /dev/null +++ b/docs/source/telegram.revenuewithdrawalstatepending.rst @@ -0,0 +1,6 @@ +RevenueWithdrawalStatePending +============================= + +.. autoclass:: telegram.RevenueWithdrawalStatePending + :members: + :show-inheritance: diff --git a/docs/source/telegram.revenuewithdrawalstatesucceeded.rst b/docs/source/telegram.revenuewithdrawalstatesucceeded.rst new file mode 100644 index 00000000000..40bd6fdb5c7 --- /dev/null +++ b/docs/source/telegram.revenuewithdrawalstatesucceeded.rst @@ -0,0 +1,6 @@ +RevenueWithdrawalStateSucceeded +=============================== + +.. autoclass:: telegram.RevenueWithdrawalStateSucceeded + :members: + :show-inheritance: diff --git a/docs/source/telegram.shareduser.rst b/docs/source/telegram.shareduser.rst new file mode 100644 index 00000000000..52dd3885bc0 --- /dev/null +++ b/docs/source/telegram.shareduser.rst @@ -0,0 +1,7 @@ +SharedUser +========== + +.. autoclass:: telegram.SharedUser + :members: + :show-inheritance: + diff --git a/docs/source/telegram.staramount.rst b/docs/source/telegram.staramount.rst new file mode 100644 index 00000000000..9d5a6e24572 --- /dev/null +++ b/docs/source/telegram.staramount.rst @@ -0,0 +1,6 @@ +StarAmount +========== + +.. autoclass:: telegram.StarAmount + :members: + :show-inheritance: diff --git a/docs/source/telegram.startransaction.rst b/docs/source/telegram.startransaction.rst new file mode 100644 index 00000000000..b8a68c8e99e --- /dev/null +++ b/docs/source/telegram.startransaction.rst @@ -0,0 +1,6 @@ +StarTransaction +=============== + +.. autoclass:: telegram.StarTransaction + :members: + :show-inheritance: diff --git a/docs/source/telegram.startransactions.rst b/docs/source/telegram.startransactions.rst new file mode 100644 index 00000000000..e71439c8c87 --- /dev/null +++ b/docs/source/telegram.startransactions.rst @@ -0,0 +1,7 @@ +StarTransactions +================ + +.. autoclass:: telegram.StarTransactions + :members: + :show-inheritance: + diff --git a/docs/source/telegram.sticker.rst b/docs/source/telegram.sticker.rst index 65b4a0f23c6..459629b7ecc 100644 --- a/docs/source/telegram.sticker.rst +++ b/docs/source/telegram.sticker.rst @@ -6,4 +6,4 @@ Sticker .. autoclass:: telegram.Sticker :members: :show-inheritance: - :inherited-members: TelegramObject + :inherited-members: TelegramObject, object diff --git a/docs/source/telegram.stickers-tree.rst b/docs/source/telegram.stickers-tree.rst index 783b90ec0c7..e45dcacb56b 100644 --- a/docs/source/telegram.stickers-tree.rst +++ b/docs/source/telegram.stickers-tree.rst @@ -1,9 +1,14 @@ Stickers -------- +The following methods and objects allow your bot to handle stickers and sticker sets. + .. toctree:: :titlesonly: + telegram.gift + telegram.gifts + telegram.inputsticker telegram.maskposition telegram.sticker telegram.stickerset diff --git a/docs/source/telegram.story.rst b/docs/source/telegram.story.rst new file mode 100644 index 00000000000..6b3b28d4a64 --- /dev/null +++ b/docs/source/telegram.story.rst @@ -0,0 +1,6 @@ +Story +===== + +.. autoclass:: telegram.Story + :members: + :show-inheritance: diff --git a/docs/source/telegram.storyarea.rst b/docs/source/telegram.storyarea.rst new file mode 100644 index 00000000000..88c028535d6 --- /dev/null +++ b/docs/source/telegram.storyarea.rst @@ -0,0 +1,6 @@ +StoryArea +========= + +.. autoclass:: telegram.StoryArea + :members: + :show-inheritance: diff --git a/docs/source/telegram.storyareaposition.rst b/docs/source/telegram.storyareaposition.rst new file mode 100644 index 00000000000..d14aa66cb2a --- /dev/null +++ b/docs/source/telegram.storyareaposition.rst @@ -0,0 +1,6 @@ +StoryAreaPosition +================= + +.. autoclass:: telegram.StoryAreaPosition + :members: + :show-inheritance: diff --git a/docs/source/telegram.storyareatype.rst b/docs/source/telegram.storyareatype.rst new file mode 100644 index 00000000000..aa4ad3312aa --- /dev/null +++ b/docs/source/telegram.storyareatype.rst @@ -0,0 +1,6 @@ +StoryAreaType +============= + +.. autoclass:: telegram.StoryAreaType + :members: + :show-inheritance: diff --git a/docs/source/telegram.storyareatypelink.rst b/docs/source/telegram.storyareatypelink.rst new file mode 100644 index 00000000000..493eeef5da2 --- /dev/null +++ b/docs/source/telegram.storyareatypelink.rst @@ -0,0 +1,6 @@ +StoryAreaTypeLink +================= + +.. autoclass:: telegram.StoryAreaTypeLink + :members: + :show-inheritance: diff --git a/docs/source/telegram.storyareatypelocation.rst b/docs/source/telegram.storyareatypelocation.rst new file mode 100644 index 00000000000..8f09ee9bf40 --- /dev/null +++ b/docs/source/telegram.storyareatypelocation.rst @@ -0,0 +1,6 @@ +StoryAreaTypeLocation +===================== + +.. autoclass:: telegram.StoryAreaTypeLocation + :members: + :show-inheritance: diff --git a/docs/source/telegram.storyareatypesuggestedreaction.rst b/docs/source/telegram.storyareatypesuggestedreaction.rst new file mode 100644 index 00000000000..e099e992d61 --- /dev/null +++ b/docs/source/telegram.storyareatypesuggestedreaction.rst @@ -0,0 +1,6 @@ +StoryAreaTypeSuggestedReaction +============================== + +.. autoclass:: telegram.StoryAreaTypeSuggestedReaction + :members: + :show-inheritance: diff --git a/docs/source/telegram.storyareatypeuniquegift.rst b/docs/source/telegram.storyareatypeuniquegift.rst new file mode 100644 index 00000000000..c6e7fd9a119 --- /dev/null +++ b/docs/source/telegram.storyareatypeuniquegift.rst @@ -0,0 +1,6 @@ +StoryAreaTypeUniqueGift +======================= + +.. autoclass:: telegram.StoryAreaTypeUniqueGift + :members: + :show-inheritance: diff --git a/docs/source/telegram.storyareatypeweather.rst b/docs/source/telegram.storyareatypeweather.rst new file mode 100644 index 00000000000..a704e7eecfd --- /dev/null +++ b/docs/source/telegram.storyareatypeweather.rst @@ -0,0 +1,6 @@ +StoryAreaTypeWeather +==================== + +.. autoclass:: telegram.StoryAreaTypeWeather + :members: + :show-inheritance: diff --git a/docs/source/telegram.suggestedpostapprovalfailed.rst b/docs/source/telegram.suggestedpostapprovalfailed.rst new file mode 100644 index 00000000000..5b730f18583 --- /dev/null +++ b/docs/source/telegram.suggestedpostapprovalfailed.rst @@ -0,0 +1,6 @@ +SuggestedPostApprovalFailed +=========================== + +.. autoclass:: telegram.SuggestedPostApprovalFailed + :members: + :show-inheritance: diff --git a/docs/source/telegram.suggestedpostapproved.rst b/docs/source/telegram.suggestedpostapproved.rst new file mode 100644 index 00000000000..c9e74a94652 --- /dev/null +++ b/docs/source/telegram.suggestedpostapproved.rst @@ -0,0 +1,6 @@ +SuggestedPostApproved +===================== + +.. autoclass:: telegram.SuggestedPostApproved + :members: + :show-inheritance: diff --git a/docs/source/telegram.suggestedpostdeclined.rst b/docs/source/telegram.suggestedpostdeclined.rst new file mode 100644 index 00000000000..bf9194d074b --- /dev/null +++ b/docs/source/telegram.suggestedpostdeclined.rst @@ -0,0 +1,6 @@ +SuggestedPostDeclined +===================== + +.. autoclass:: telegram.SuggestedPostDeclined + :members: + :show-inheritance: diff --git a/docs/source/telegram.suggestedpostinfo.rst b/docs/source/telegram.suggestedpostinfo.rst new file mode 100644 index 00000000000..a974dda9887 --- /dev/null +++ b/docs/source/telegram.suggestedpostinfo.rst @@ -0,0 +1,6 @@ +SuggestedPostInfo +================= + +.. autoclass:: telegram.SuggestedPostInfo + :members: + :show-inheritance: diff --git a/docs/source/telegram.suggestedpostpaid.rst b/docs/source/telegram.suggestedpostpaid.rst new file mode 100644 index 00000000000..6eb4a57bbda --- /dev/null +++ b/docs/source/telegram.suggestedpostpaid.rst @@ -0,0 +1,6 @@ +SuggestedPostPaid +================= + +.. autoclass:: telegram.SuggestedPostPaid + :members: + :show-inheritance: diff --git a/docs/source/telegram.suggestedpostparameters.rst b/docs/source/telegram.suggestedpostparameters.rst new file mode 100644 index 00000000000..5111d8fdd48 --- /dev/null +++ b/docs/source/telegram.suggestedpostparameters.rst @@ -0,0 +1,6 @@ +SuggestedPostParameters +======================= + +.. autoclass:: telegram.SuggestedPostParameters + :members: + :show-inheritance: diff --git a/docs/source/telegram.suggestedpostprice.rst b/docs/source/telegram.suggestedpostprice.rst new file mode 100644 index 00000000000..f5034e8f047 --- /dev/null +++ b/docs/source/telegram.suggestedpostprice.rst @@ -0,0 +1,6 @@ +SuggestedPostPrice +================== + +.. autoclass:: telegram.SuggestedPostPrice + :members: + :show-inheritance: diff --git a/docs/source/telegram.suggestedpostrefunded.rst b/docs/source/telegram.suggestedpostrefunded.rst new file mode 100644 index 00000000000..2fb5ad1beff --- /dev/null +++ b/docs/source/telegram.suggestedpostrefunded.rst @@ -0,0 +1,6 @@ +SuggestedPostRefunded +===================== + +.. autoclass:: telegram.SuggestedPostRefunded + :members: + :show-inheritance: diff --git a/docs/source/telegram.telegramobject.rst b/docs/source/telegram.telegramobject.rst index 65fb7a9cc5d..7f1e5dbb7fa 100644 --- a/docs/source/telegram.telegramobject.rst +++ b/docs/source/telegram.telegramobject.rst @@ -4,4 +4,3 @@ TelegramObject .. autoclass:: telegram.TelegramObject :members: :show-inheritance: - :special-members: __repr__, __getitem__, __eq__, __hash__, __setstate__, __getstate__, __deepcopy__, __setattr__, __delattr__ diff --git a/docs/source/telegram.textquote.rst b/docs/source/telegram.textquote.rst new file mode 100644 index 00000000000..4e11ff74132 --- /dev/null +++ b/docs/source/telegram.textquote.rst @@ -0,0 +1,6 @@ +TextQuote +========= + +.. autoclass:: telegram.TextQuote + :members: + :show-inheritance: diff --git a/docs/source/telegram.transactionpartner.rst b/docs/source/telegram.transactionpartner.rst new file mode 100644 index 00000000000..1970cfb3f94 --- /dev/null +++ b/docs/source/telegram.transactionpartner.rst @@ -0,0 +1,6 @@ +TransactionPartner +================== + +.. autoclass:: telegram.TransactionPartner + :members: + :show-inheritance: diff --git a/docs/source/telegram.transactionpartneraffiliateprogram.rst b/docs/source/telegram.transactionpartneraffiliateprogram.rst new file mode 100644 index 00000000000..dfcab6ec22b --- /dev/null +++ b/docs/source/telegram.transactionpartneraffiliateprogram.rst @@ -0,0 +1,6 @@ +TransactionPartnerAffiliateProgram +=================================== + +.. autoclass:: telegram.TransactionPartnerAffiliateProgram + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.transactionpartnerchat.rst b/docs/source/telegram.transactionpartnerchat.rst new file mode 100644 index 00000000000..3f278f05d80 --- /dev/null +++ b/docs/source/telegram.transactionpartnerchat.rst @@ -0,0 +1,6 @@ +TransactionPartnerChat +====================== + +.. autoclass:: telegram.TransactionPartnerChat + :members: + :show-inheritance: diff --git a/docs/source/telegram.transactionpartnerfragment.rst b/docs/source/telegram.transactionpartnerfragment.rst new file mode 100644 index 00000000000..dbdad66f2df --- /dev/null +++ b/docs/source/telegram.transactionpartnerfragment.rst @@ -0,0 +1,6 @@ +TransactionPartnerFragment +========================== + +.. autoclass:: telegram.TransactionPartnerFragment + :members: + :show-inheritance: diff --git a/docs/source/telegram.transactionpartnerother.rst b/docs/source/telegram.transactionpartnerother.rst new file mode 100644 index 00000000000..cbc4c41be52 --- /dev/null +++ b/docs/source/telegram.transactionpartnerother.rst @@ -0,0 +1,6 @@ +TransactionPartnerOther +======================= + +.. autoclass:: telegram.TransactionPartnerOther + :members: + :show-inheritance: diff --git a/docs/source/telegram.transactionpartnertelegramads.rst b/docs/source/telegram.transactionpartnertelegramads.rst new file mode 100644 index 00000000000..8304bc84a06 --- /dev/null +++ b/docs/source/telegram.transactionpartnertelegramads.rst @@ -0,0 +1,6 @@ +TransactionPartnerTelegramAds +============================= + +.. autoclass:: telegram.TransactionPartnerTelegramAds + :members: + :show-inheritance: diff --git a/docs/source/telegram.transactionpartnertelegramapi.rst b/docs/source/telegram.transactionpartnertelegramapi.rst new file mode 100644 index 00000000000..619b4a0c89f --- /dev/null +++ b/docs/source/telegram.transactionpartnertelegramapi.rst @@ -0,0 +1,6 @@ +TransactionPartnerTelegramApi +============================= + +.. autoclass:: telegram.TransactionPartnerTelegramApi + :members: + :show-inheritance: diff --git a/docs/source/telegram.transactionpartneruser.rst b/docs/source/telegram.transactionpartneruser.rst new file mode 100644 index 00000000000..7709bd668c4 --- /dev/null +++ b/docs/source/telegram.transactionpartneruser.rst @@ -0,0 +1,6 @@ +TransactionPartnerUser +====================== + +.. autoclass:: telegram.TransactionPartnerUser + :members: + :show-inheritance: diff --git a/docs/source/telegram.uniquegift.rst b/docs/source/telegram.uniquegift.rst new file mode 100644 index 00000000000..0d9d1a12d32 --- /dev/null +++ b/docs/source/telegram.uniquegift.rst @@ -0,0 +1,7 @@ +UniqueGift +========== + +.. autoclass:: telegram.UniqueGift + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftbackdrop.rst b/docs/source/telegram.uniquegiftbackdrop.rst new file mode 100644 index 00000000000..52264731b22 --- /dev/null +++ b/docs/source/telegram.uniquegiftbackdrop.rst @@ -0,0 +1,7 @@ +UniqueGiftBackdrop +================== + +.. autoclass:: telegram.UniqueGiftBackdrop + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftbackdropcolors.rst b/docs/source/telegram.uniquegiftbackdropcolors.rst new file mode 100644 index 00000000000..40fbf609a37 --- /dev/null +++ b/docs/source/telegram.uniquegiftbackdropcolors.rst @@ -0,0 +1,7 @@ +UniqueGiftBackdropColors +======================== + +.. autoclass:: telegram.UniqueGiftBackdropColors + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftcolors.rst b/docs/source/telegram.uniquegiftcolors.rst new file mode 100644 index 00000000000..3e554abd8de --- /dev/null +++ b/docs/source/telegram.uniquegiftcolors.rst @@ -0,0 +1,7 @@ +UniqueGiftColors +================ + +.. autoclass:: telegram.UniqueGiftColors + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftinfo.rst b/docs/source/telegram.uniquegiftinfo.rst new file mode 100644 index 00000000000..5d8ef6402cf --- /dev/null +++ b/docs/source/telegram.uniquegiftinfo.rst @@ -0,0 +1,7 @@ +UniqueGiftInfo +============== + +.. autoclass:: telegram.UniqueGiftInfo + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftmodel.rst b/docs/source/telegram.uniquegiftmodel.rst new file mode 100644 index 00000000000..a0a95a04307 --- /dev/null +++ b/docs/source/telegram.uniquegiftmodel.rst @@ -0,0 +1,7 @@ +UniqueGiftModel +=============== + +.. autoclass:: telegram.UniqueGiftModel + :members: + :show-inheritance: + diff --git a/docs/source/telegram.uniquegiftsymbol.rst b/docs/source/telegram.uniquegiftsymbol.rst new file mode 100644 index 00000000000..8246da5cf17 --- /dev/null +++ b/docs/source/telegram.uniquegiftsymbol.rst @@ -0,0 +1,7 @@ +UniqueGiftSymbol +================ + +.. autoclass:: telegram.UniqueGiftSymbol + :members: + :show-inheritance: + diff --git a/docs/source/telegram.userchatboosts.rst b/docs/source/telegram.userchatboosts.rst new file mode 100644 index 00000000000..78b958f0d31 --- /dev/null +++ b/docs/source/telegram.userchatboosts.rst @@ -0,0 +1,8 @@ +UserChatBoosts +============== + +.. versionadded:: 20.8 + +.. autoclass:: telegram.UserChatBoosts + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.userrating.rst b/docs/source/telegram.userrating.rst new file mode 100644 index 00000000000..f4a8db4a068 --- /dev/null +++ b/docs/source/telegram.userrating.rst @@ -0,0 +1,6 @@ +UserRating +========== + +.. autoclass:: telegram.UserRating + :members: + :show-inheritance: diff --git a/docs/source/telegram.usershared.rst b/docs/source/telegram.usershared.rst deleted file mode 100644 index d42e8b28171..00000000000 --- a/docs/source/telegram.usershared.rst +++ /dev/null @@ -1,6 +0,0 @@ -UserShared -=================== - -.. autoclass:: telegram.UserShared - :members: - :show-inheritance: diff --git a/docs/source/telegram.usersshared.rst b/docs/source/telegram.usersshared.rst new file mode 100644 index 00000000000..5af3457f59e --- /dev/null +++ b/docs/source/telegram.usersshared.rst @@ -0,0 +1,6 @@ +UsersShared +=========== + +.. autoclass:: telegram.UsersShared + :members: + :show-inheritance: diff --git a/docs/source/telegram.video.rst b/docs/source/telegram.video.rst index 34c81eb242a..bcda1026431 100644 --- a/docs/source/telegram.video.rst +++ b/docs/source/telegram.video.rst @@ -6,4 +6,4 @@ Video .. autoclass:: telegram.Video :members: :show-inheritance: - :inherited-members: TelegramObject \ No newline at end of file + :inherited-members: TelegramObject, object \ No newline at end of file diff --git a/docs/source/telegram.videonote.rst b/docs/source/telegram.videonote.rst index 5217acb0479..e7b504fb515 100644 --- a/docs/source/telegram.videonote.rst +++ b/docs/source/telegram.videonote.rst @@ -6,4 +6,4 @@ VideoNote .. autoclass:: telegram.VideoNote :members: :show-inheritance: - :inherited-members: TelegramObject \ No newline at end of file + :inherited-members: TelegramObject, object \ No newline at end of file diff --git a/docs/source/telegram.voice.rst b/docs/source/telegram.voice.rst index b3667b6edcb..b1530967bd2 100644 --- a/docs/source/telegram.voice.rst +++ b/docs/source/telegram.voice.rst @@ -6,4 +6,4 @@ Voice .. autoclass:: telegram.Voice :members: :show-inheritance: - :inherited-members: TelegramObject + :inherited-members: TelegramObject, object diff --git a/docs/substitutions/application.rst b/docs/substitutions/application.rst new file mode 100644 index 00000000000..45643304451 --- /dev/null +++ b/docs/substitutions/application.rst @@ -0,0 +1 @@ +.. |app_run_shutdown| replace:: The app will shut down when :exc:`KeyboardInterrupt` or :exc:`SystemExit` is raised. This also works from within handlers, error handlers and jobs. However, using :meth:`~telegram.ext.Application.stop_running` will give a somewhat cleaner shutdown behavior than manually raising those exceptions. On unix, the app will also shut down on receiving the signals specified by \ No newline at end of file diff --git a/docs/substitutions/global.rst b/docs/substitutions/global.rst index 4d216083360..f1bad363716 100644 --- a/docs/substitutions/global.rst +++ b/docs/substitutions/global.rst @@ -14,12 +14,10 @@ .. |thumbdocstringnopath| replace:: |thumbdocstringbase| |uploadinputnopath| -.. |thumbargumentdeprecation| replace:: As of Bot API 6.6 this argument is deprecated in favor of - -.. |thumbattributedeprecation| replace:: As of Bot API 6.6 this attribute is deprecated in favor of - .. |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| @@ -32,7 +30,7 @@ .. |message_thread_id| replace:: Unique identifier for the target message thread of the forum topic. -.. |message_thread_id_arg| replace:: Unique identifier for the target message thread (topic) of the forum; for forum supergroups only. +.. |message_thread_id_arg| replace:: Unique identifier for the target message thread (topic) of a forum; for forum supergroups and private chats of bots with forum topic mode enabled only. .. |parse_mode| replace:: Mode for parsing entities. See :class:`telegram.constants.ParseMode` and `formatting options `__ for more details. @@ -57,3 +55,55 @@ .. |captionentitiesattr| replace:: Tuple of special entities that appear in the caption, which can be specified instead of ``parse_mode``. .. |datetime_localization| replace:: The default timezone of the bot is used for localization, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is used. + +.. |post_methods_note| replace:: If you implement custom logic that implies that you will **not** be using :class:`~telegram.ext.Application`'s methods :meth:`~telegram.ext.Application.run_polling` or :meth:`~telegram.ext.Application.run_webhook` to run your application (like it's done in `Custom Webhook Bot Example `__), the callback you set in this method **will not be called automatically**. So instead of setting a callback with this method, you have to explicitly ``await`` the function that you want to run at this stage of your application's life (in the `example mentioned above `__, that would be in ``async with application`` context manager). + +.. |removed_thumb_note| replace:: Removed the deprecated argument and attribute ``thumb``. + +.. |removed_thumb_url_note| replace:: Removed the deprecated argument and attribute ``thumb_url`` which made thumbnail_url mandatory. + +.. |removed_thumb_wildcard_note| replace:: Removed the deprecated arguments and attributes ``thumb_*``. + +.. |thumbnail_url_mandatory| replace:: Removal of the deprecated argument ``thumb_url`` made ``thumbnail_url`` mandatory. + +.. |async_context_manager| replace:: Asynchronous context manager which + +.. |reply_parameters| replace:: Description of the message to reply to. + +.. |rtm_aswr_deprecated| replace:: replacing this argument. PTB will automatically convert this argument to that one, but you should update your code to use the new argument. + +.. |keyword_only_arg| replace:: This argument is now a keyword-only argument. + +.. |text_html| replace:: The return value of this property is a best-effort approach. Unfortunately, it can not be guaranteed that sending a message with the returned string will render in the same way as the original message produces the same :attr:`~telegram.Message.entities`/:attr:`~telegram.Message.caption_entities` as the original message. For example, Telegram recommends that entities of type :attr:`~telegram.MessageEntity.BLOCKQUOTE` and :attr:`~telegram.MessageEntity.PRE` *should* start and end on a new line, but does not enforce this and leaves rendering decisions up to the clients. + +.. |text_markdown| replace:: |text_html| Moreover, markdown formatting is inherently less expressive than HTML, so some edge cases may not be coverable at all. For example, markdown formatting can not specify two consecutive block quotes without a blank line in between, but HTML can. + +.. |reply_quote| replace:: If set to :obj:`True`, the reply is sent as an actual reply to this message. If ``reply_to_message_id`` is passed, this parameter will be ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. + +.. |do_quote| replace:: If set to :obj:`True`, the replied message is quoted. For a dict, it must be the output of :meth:`~telegram.Message.build_reply_arguments` to specify exact ``reply_parameters``. If ``reply_to_message_id`` or ``reply_parameters`` are passed, this parameter will be ignored. When passing dict-valued input, ``do_quote`` is mutually exclusive with ``allow_sending_without_reply``. Default: :obj:`True` in group chats and :obj:`False` in private chats. + +.. |non_optional_story_argument| replace:: As of this version, this argument is now required. In accordance with our `stability policy `__, the signature will be kept as optional for now, though they are mandatory and an error will be raised if you don't pass it. + +.. |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. + +.. |tg_stars| replace:: `Telegram Stars `__ + +.. |allow_paid_broadcast| replace:: Pass True to allow up to :tg-const:`telegram.constants.FloodLimit.PAID_MESSAGES_PER_SECOND` messages per second, ignoring `broadcasting limits `__ for a fee of 0.1 Telegram Stars per message. The relevant Stars will be withdrawn from the bot's balance. + +.. |direct_messages_topic_id| replace:: Identifier of the direct messages topic to which the message will be sent; required if the message is sent to a direct messages chat. + +.. |suggested_post_parameters| replace:: An object containing the parameters of the suggested post to send; for direct messages chats only. If the message is sent as a reply to another suggested post, then that suggested post is automatically declined. + +.. |tz-naive-dtms| replace:: For timezone naive :obj:`datetime.datetime` objects, the default timezone of the bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is used. + +.. |org-verify| replace:: `on behalf of the organization `__ + +.. |time-period-input| replace:: :class:`datetime.timedelta` objects are accepted in addition to plain :obj:`int` values. + +.. |time-period-int-deprecated| replace:: In a future major version this attribute will be of type :obj:`datetime.timedelta`. You can opt-in early by setting `PTB_TIMEDELTA=true` or ``PTB_TIMEDELTA=1`` as an environment variable. diff --git a/examples/arbitrarycallbackdatabot.py b/examples/arbitrarycallbackdatabot.py index 392c6772894..4e5fbd877c1 100644 --- a/examples/arbitrarycallbackdatabot.py +++ b/examples/arbitrarycallbackdatabot.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """This example showcases how PTBs "arbitrary callback data" feature can be used. @@ -9,24 +9,12 @@ Note: To use arbitrary callback data, you must install PTB via -`pip install python-telegram-bot[callback-data]` +`pip install "python-telegram-bot[callback-data]"` """ -import logging -from typing import List, Tuple, cast - -from telegram import __version__ as TG_VER -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] +import logging +from typing import cast -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ( Application, @@ -41,12 +29,15 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Sends a message with 5 inline buttons attached.""" - number_list: List[int] = [] + number_list: list[int] = [] await update.message.reply_text("Please choose:", reply_markup=build_keyboard(number_list)) @@ -65,7 +56,7 @@ async def clear(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: await update.effective_message.reply_text("All clear!") -def build_keyboard(current_list: List[int]) -> InlineKeyboardMarkup: +def build_keyboard(current_list: list[int]) -> InlineKeyboardMarkup: """Helper function to build the next inline keyboard.""" return InlineKeyboardMarkup.from_column( [InlineKeyboardButton(str(i), callback_data=(i, current_list)) for i in range(1, 6)] @@ -79,7 +70,7 @@ async def list_button(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non # Get the data from the callback_data. # If you're using a type checker like MyPy, you'll have to use typing.cast # to make the checker get the expected type of the callback_data - number, number_list = cast(Tuple[int, List[int]], query.data) + number, number_list = cast("tuple[int, list[int]]", query.data) # append the number to the list number_list.append(number) diff --git a/examples/chatmemberbot.py b/examples/chatmemberbot.py index e5da9824f29..342385c71cf 100644 --- a/examples/chatmemberbot.py +++ b/examples/chatmemberbot.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """ @@ -12,21 +12,7 @@ """ import logging -from typing import Optional, Tuple -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import Chat, ChatMember, ChatMemberUpdated, Update from telegram.constants import ParseMode from telegram.ext import ( @@ -44,10 +30,13 @@ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) -def extract_status_change(chat_member_update: ChatMemberUpdated) -> Optional[Tuple[bool, bool]]: +def extract_status_change(chat_member_update: ChatMemberUpdated) -> tuple[bool, bool] | None: """Takes a ChatMemberUpdated instance and extracts whether the 'old_chat_member' was a member of the chat and whether the 'new_chat_member' is a member of the chat. Returns None, if the status didn't change. diff --git a/examples/contexttypesbot.py b/examples/contexttypesbot.py index 30dc967538f..cd8df888f8e 100644 --- a/examples/contexttypesbot.py +++ b/examples/contexttypesbot.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """ @@ -12,21 +12,7 @@ import logging from collections import defaultdict -from typing import DefaultDict, Optional, Set -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.constants import ParseMode from telegram.ext import ( @@ -43,6 +29,9 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) @@ -50,7 +39,7 @@ class ChatData: """Custom class for chat_data. Here we store data per message.""" def __init__(self) -> None: - self.clicks_per_message: DefaultDict[int, int] = defaultdict(int) + self.clicks_per_message: defaultdict[int, int] = defaultdict(int) # The [ExtBot, dict, ChatData, dict] is for type checkers like mypy @@ -60,19 +49,19 @@ class CustomContext(CallbackContext[ExtBot, dict, ChatData, dict]): def __init__( self, application: Application, - chat_id: Optional[int] = None, - user_id: Optional[int] = None, + chat_id: int | None = None, + user_id: int | None = None, ): super().__init__(application=application, chat_id=chat_id, user_id=user_id) - self._message_id: Optional[int] = None + self._message_id: int | None = None @property - def bot_user_ids(self) -> Set[int]: + def bot_user_ids(self) -> set[int]: """Custom shortcut to access a value stored in the bot_data dict""" return self.bot_data.setdefault("user_ids", set()) @property - def message_clicks(self) -> Optional[int]: + def message_clicks(self) -> int | None: """Access the number of clicks for the message this context object was built for.""" if self._message_id: return self.chat_data.clicks_per_message[self._message_id] @@ -125,8 +114,7 @@ async def count_click(update: Update, context: CustomContext) -> None: async def print_users(update: Update, context: CustomContext) -> None: """Show which users have been using this bot.""" await update.message.reply_text( - "The following user IDs have used this bot: " - f'{", ".join(map(str, context.bot_user_ids))}' + f"The following user IDs have used this bot: {', '.join(map(str, context.bot_user_ids))}" ) diff --git a/examples/conversationbot.py b/examples/conversationbot.py index b846e36695e..dbe637c1203 100644 --- a/examples/conversationbot.py +++ b/examples/conversationbot.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """ @@ -16,19 +16,6 @@ import logging -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 5): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update from telegram.ext import ( Application, @@ -43,6 +30,9 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) GENDER, PHOTO, LOCATION, BIO = range(4) @@ -155,7 +145,9 @@ def main() -> None: conv_handler = ConversationHandler( entry_points=[CommandHandler("start", start)], states={ - GENDER: [MessageHandler(filters.Regex("^(Boy|Girl|Other)$"), gender)], + # Use case-insensitive regex to accept gender input regardless of letter casing, + # e.g., "boy", "BOY", "Girl", etc., will all be matched + GENDER: [MessageHandler(filters.Regex("(?i)^(Boy|Girl|Other)$"), gender)], PHOTO: [MessageHandler(filters.PHOTO, photo), CommandHandler("skip", skip_photo)], LOCATION: [ MessageHandler(filters.LOCATION, location), diff --git a/examples/conversationbot2.py b/examples/conversationbot2.py index efb809e175a..af29e0198e9 100644 --- a/examples/conversationbot2.py +++ b/examples/conversationbot2.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """ @@ -15,21 +15,7 @@ """ import logging -from typing import Dict -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update from telegram.ext import ( Application, @@ -44,6 +30,9 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) CHOOSING, TYPING_REPLY, TYPING_CHOICE = range(3) @@ -56,7 +45,7 @@ markup = ReplyKeyboardMarkup(reply_keyboard, one_time_keyboard=True) -def facts_to_str(user_data: Dict[str, str]) -> str: +def facts_to_str(user_data: dict[str, str]) -> str: """Helper function for formatting the gathered user info.""" facts = [f"{key} - {value}" for key, value in user_data.items()] return "\n".join(facts).join(["\n", "\n"]) diff --git a/examples/customwebhookbot/djangobot.py b/examples/customwebhookbot/djangobot.py new file mode 100644 index 00000000000..3bbd3dd1640 --- /dev/null +++ b/examples/customwebhookbot/djangobot.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python +# This program is dedicated to the public domain under the CC0 license. +# pylint: disable=import-error,unused-argument +""" +Simple example of a bot that uses a custom webhook setup and handles custom updates. +For the custom webhook setup, the libraries `Django` and `uvicorn` are used. Please +install them as `pip install Django~=4.2.4 uvicorn~=0.23.2`. +Note that any other `asyncio` based web server framework can be used for a custom webhook setup +just as well. + +Usage: +Set bot Token, URL, admin CHAT_ID and PORT after the imports. +You may also need to change the `listen` value in the uvicorn configuration to match your setup. +Press Ctrl-C on the command line or send a signal to the process to stop the bot. +""" + +import asyncio +import html +import json +import logging +from dataclasses import dataclass +from uuid import uuid4 + +import uvicorn +from django.conf import settings +from django.core.asgi import get_asgi_application +from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest +from django.urls import path + +from telegram import Update +from telegram.constants import ParseMode +from telegram.ext import ( + Application, + CallbackContext, + CommandHandler, + ContextTypes, + ExtBot, + TypeHandler, +) + +# Enable logging +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO +) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + +logger = logging.getLogger(__name__) + +# Define configuration constants +URL = "https://domain.tld" +ADMIN_CHAT_ID = 123456 +PORT = 8000 +TOKEN = "123:ABC" # nosec B105 + + +@dataclass +class WebhookUpdate: + """Simple dataclass to wrap a custom update type""" + + user_id: int + payload: str + + +class CustomContext(CallbackContext[ExtBot, dict, dict, dict]): + """ + Custom CallbackContext class that makes `user_data` available for updates of type + `WebhookUpdate`. + """ + + @classmethod + def from_update( + cls, + update: object, + application: "Application", + ) -> "CustomContext": + if isinstance(update, WebhookUpdate): + return cls(application=application, user_id=update.user_id) + return super().from_update(update, application) + + +async def start(update: Update, context: CustomContext) -> None: + """Display a message with instructions on how to use this bot.""" + payload_url = html.escape(f"{URL}/submitpayload?user_id=&payload=") + text = ( + f"To check if the bot is still running, call {URL}/healthcheck.\n\n" + f"To post a custom update, call {payload_url}." + ) + await update.message.reply_html(text=text) + + +async def webhook_update(update: WebhookUpdate, context: CustomContext) -> None: + """Handle custom updates.""" + chat_member = await context.bot.get_chat_member(chat_id=update.user_id, user_id=update.user_id) + payloads = context.user_data.setdefault("payloads", []) + payloads.append(update.payload) + combined_payloads = "\n• ".join(payloads) + text = ( + f"The user {chat_member.user.mention_html()} has sent a new payload. " + f"So far they have sent the following payloads: \n\n• {combined_payloads}" + ) + await context.bot.send_message(chat_id=ADMIN_CHAT_ID, text=text, parse_mode=ParseMode.HTML) + + +async def telegram(request: HttpRequest) -> HttpResponse: + """Handle incoming Telegram updates by putting them into the `update_queue`""" + await ptb_application.update_queue.put( + Update.de_json(data=json.loads(request.body), bot=ptb_application.bot) + ) + return HttpResponse() + + +async def custom_updates(request: HttpRequest) -> HttpResponse: + """ + Handle incoming webhook updates by also putting them into the `update_queue` if + the required parameters were passed correctly. + """ + try: + user_id = int(request.GET["user_id"]) + payload = request.GET["payload"] + except KeyError: + return HttpResponseBadRequest( + "Please pass both `user_id` and `payload` as query parameters.", + ) + except ValueError: + return HttpResponseBadRequest("The `user_id` must be a string!") + + await ptb_application.update_queue.put(WebhookUpdate(user_id=user_id, payload=payload)) + return HttpResponse() + + +async def health(_: HttpRequest) -> HttpResponse: + """For the health endpoint, reply with a simple plain text message.""" + return HttpResponse("The bot is still running fine :)") + + +# Set up PTB application and a web application for handling the incoming requests. + +context_types = ContextTypes(context=CustomContext) +# Here we set updater to None because we want our custom webhook server to handle the updates +# and hence we don't need an Updater instance +ptb_application = ( + Application.builder().token(TOKEN).updater(None).context_types(context_types).build() +) + +# register handlers +ptb_application.add_handler(CommandHandler("start", start)) +ptb_application.add_handler(TypeHandler(type=WebhookUpdate, callback=webhook_update)) + +urlpatterns = [ + path("telegram", telegram, name="Telegram updates"), + path("submitpayload", custom_updates, name="custom updates"), + path("healthcheck", health, name="health check"), +] +settings.configure(ROOT_URLCONF=__name__, SECRET_KEY=uuid4().hex) + + +async def main() -> None: + """Finalize configuration and run the applications.""" + webserver = uvicorn.Server( + config=uvicorn.Config( + app=get_asgi_application(), + port=PORT, + use_colors=False, + host="127.0.0.1", + ) + ) + + # Pass webhook settings to telegram + await ptb_application.bot.set_webhook(url=f"{URL}/telegram", allowed_updates=Update.ALL_TYPES) + + # Run application and webserver together + async with ptb_application: + await ptb_application.start() + await webserver.serve() + await ptb_application.stop() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/customwebhookbot/flaskbot.py b/examples/customwebhookbot/flaskbot.py new file mode 100644 index 00000000000..70a98d6b2f4 --- /dev/null +++ b/examples/customwebhookbot/flaskbot.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python +# This program is dedicated to the public domain under the CC0 license. +# pylint: disable=import-error,unused-argument +""" +Simple example of a bot that uses a custom webhook setup and handles custom updates. +For the custom webhook setup, the libraries `flask`, `asgiref` and `uvicorn` are used. Please +install them as `pip install flask[async]~=2.3.2 uvicorn~=0.23.2 asgiref~=3.7.2`. +Note that any other `asyncio` based web server framework can be used for a custom webhook setup +just as well. + +Usage: +Set bot Token, URL, admin CHAT_ID and PORT after the imports. +You may also need to change the `listen` value in the uvicorn configuration to match your setup. +Press Ctrl-C on the command line or send a signal to the process to stop the bot. +""" + +import asyncio +import html +import logging +from dataclasses import dataclass +from http import HTTPStatus + +import uvicorn +from asgiref.wsgi import WsgiToAsgi +from flask import Flask, Response, abort, make_response, request + +from telegram import Update +from telegram.constants import ParseMode +from telegram.ext import ( + Application, + CallbackContext, + CommandHandler, + ContextTypes, + ExtBot, + TypeHandler, +) + +# Enable logging +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO +) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + +logger = logging.getLogger(__name__) + +# Define configuration constants +URL = "https://domain.tld" +ADMIN_CHAT_ID = 123456 +PORT = 8000 +TOKEN = "123:ABC" # nosec B105 + + +@dataclass +class WebhookUpdate: + """Simple dataclass to wrap a custom update type""" + + user_id: int + payload: str + + +class CustomContext(CallbackContext[ExtBot, dict, dict, dict]): + """ + Custom CallbackContext class that makes `user_data` available for updates of type + `WebhookUpdate`. + """ + + @classmethod + def from_update( + cls, + update: object, + application: "Application", + ) -> "CustomContext": + if isinstance(update, WebhookUpdate): + return cls(application=application, user_id=update.user_id) + return super().from_update(update, application) + + +async def start(update: Update, context: CustomContext) -> None: + """Display a message with instructions on how to use this bot.""" + payload_url = html.escape(f"{URL}/submitpayload?user_id=&payload=") + text = ( + f"To check if the bot is still running, call {URL}/healthcheck.\n\n" + f"To post a custom update, call {payload_url}." + ) + await update.message.reply_html(text=text) + + +async def webhook_update(update: WebhookUpdate, context: CustomContext) -> None: + """Handle custom updates.""" + chat_member = await context.bot.get_chat_member(chat_id=update.user_id, user_id=update.user_id) + payloads = context.user_data.setdefault("payloads", []) + payloads.append(update.payload) + combined_payloads = "\n• ".join(payloads) + text = ( + f"The user {chat_member.user.mention_html()} has sent a new payload. " + f"So far they have sent the following payloads: \n\n• {combined_payloads}" + ) + await context.bot.send_message(chat_id=ADMIN_CHAT_ID, text=text, parse_mode=ParseMode.HTML) + + +async def main() -> None: + """Set up PTB application and a web application for handling the incoming requests.""" + context_types = ContextTypes(context=CustomContext) + # Here we set updater to None because we want our custom webhook server to handle the updates + # and hence we don't need an Updater instance + application = ( + Application.builder().token(TOKEN).updater(None).context_types(context_types).build() + ) + + # register handlers + application.add_handler(CommandHandler("start", start)) + application.add_handler(TypeHandler(type=WebhookUpdate, callback=webhook_update)) + + # Pass webhook settings to telegram + await application.bot.set_webhook(url=f"{URL}/telegram", allowed_updates=Update.ALL_TYPES) + + # Set up webserver + flask_app = Flask(__name__) + + @flask_app.post("/telegram") # type: ignore[untyped-decorator] + async def telegram() -> Response: + """Handle incoming Telegram updates by putting them into the `update_queue`""" + await application.update_queue.put(Update.de_json(data=request.json, bot=application.bot)) + return Response(status=HTTPStatus.OK) + + @flask_app.route("/submitpayload", methods=["GET", "POST"]) # type: ignore[untyped-decorator] + async def custom_updates() -> Response: + """ + Handle incoming webhook updates by also putting them into the `update_queue` if + the required parameters were passed correctly. + """ + try: + user_id = int(request.args["user_id"]) + payload = request.args["payload"] + except KeyError: + abort( + HTTPStatus.BAD_REQUEST, + "Please pass both `user_id` and `payload` as query parameters.", + ) + except ValueError: + abort(HTTPStatus.BAD_REQUEST, "The `user_id` must be a string!") + + await application.update_queue.put(WebhookUpdate(user_id=user_id, payload=payload)) + return Response(status=HTTPStatus.OK) + + @flask_app.get("/healthcheck") # type: ignore[untyped-decorator] + async def health() -> Response: + """For the health endpoint, reply with a simple plain text message.""" + response = make_response("The bot is still running fine :)", HTTPStatus.OK) + response.mimetype = "text/plain" + return response + + webserver = uvicorn.Server( + config=uvicorn.Config( + app=WsgiToAsgi(flask_app), + port=PORT, + use_colors=False, + host="127.0.0.1", + ) + ) + + # Run application and webserver together + async with application: + await application.start() + await webserver.serve() + await application.stop() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/customwebhookbot/quartbot.py b/examples/customwebhookbot/quartbot.py new file mode 100644 index 00000000000..e65d5cc9ff6 --- /dev/null +++ b/examples/customwebhookbot/quartbot.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python +# This program is dedicated to the public domain under the CC0 license. +# pylint: disable=import-error,unused-argument +""" +Simple example of a bot that uses a custom webhook setup and handles custom updates. +For the custom webhook setup, the libraries `quart` and `uvicorn` are used. Please +install them as `pip install quart~=0.18.4 uvicorn~=0.23.2`. +Note that any other `asyncio` based web server framework can be used for a custom webhook setup +just as well. + +Usage: +Set bot Token, URL, admin CHAT_ID and PORT after the imports. +You may also need to change the `listen` value in the uvicorn configuration to match your setup. +Press Ctrl-C on the command line or send a signal to the process to stop the bot. +""" + +import asyncio +import html +import logging +from dataclasses import dataclass +from http import HTTPStatus + +import uvicorn +from quart import Quart, Response, abort, make_response, request + +from telegram import Update +from telegram.constants import ParseMode +from telegram.ext import ( + Application, + CallbackContext, + CommandHandler, + ContextTypes, + ExtBot, + TypeHandler, +) + +# Enable logging +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO +) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + +logger = logging.getLogger(__name__) + +# Define configuration constants +URL = "https://domain.tld" +ADMIN_CHAT_ID = 123456 +PORT = 8000 +TOKEN = "123:ABC" # nosec B105 + + +@dataclass +class WebhookUpdate: + """Simple dataclass to wrap a custom update type""" + + user_id: int + payload: str + + +class CustomContext(CallbackContext[ExtBot, dict, dict, dict]): + """ + Custom CallbackContext class that makes `user_data` available for updates of type + `WebhookUpdate`. + """ + + @classmethod + def from_update( + cls, + update: object, + application: "Application", + ) -> "CustomContext": + if isinstance(update, WebhookUpdate): + return cls(application=application, user_id=update.user_id) + return super().from_update(update, application) + + +async def start(update: Update, context: CustomContext) -> None: + """Display a message with instructions on how to use this bot.""" + payload_url = html.escape(f"{URL}/submitpayload?user_id=&payload=") + text = ( + f"To check if the bot is still running, call {URL}/healthcheck.\n\n" + f"To post a custom update, call {payload_url}." + ) + await update.message.reply_html(text=text) + + +async def webhook_update(update: WebhookUpdate, context: CustomContext) -> None: + """Handle custom updates.""" + chat_member = await context.bot.get_chat_member(chat_id=update.user_id, user_id=update.user_id) + payloads = context.user_data.setdefault("payloads", []) + payloads.append(update.payload) + combined_payloads = "\n• ".join(payloads) + text = ( + f"The user {chat_member.user.mention_html()} has sent a new payload. " + f"So far they have sent the following payloads: \n\n• {combined_payloads}" + ) + await context.bot.send_message(chat_id=ADMIN_CHAT_ID, text=text, parse_mode=ParseMode.HTML) + + +async def main() -> None: + """Set up PTB application and a web application for handling the incoming requests.""" + context_types = ContextTypes(context=CustomContext) + # Here we set updater to None because we want our custom webhook server to handle the updates + # and hence we don't need an Updater instance + application = ( + Application.builder().token(TOKEN).updater(None).context_types(context_types).build() + ) + + # register handlers + application.add_handler(CommandHandler("start", start)) + application.add_handler(TypeHandler(type=WebhookUpdate, callback=webhook_update)) + + # Pass webhook settings to telegram + await application.bot.set_webhook(url=f"{URL}/telegram", allowed_updates=Update.ALL_TYPES) + + # Set up webserver + quart_app = Quart(__name__) + + @quart_app.post("/telegram") # type: ignore[untyped-decorator] + async def telegram() -> Response: + """Handle incoming Telegram updates by putting them into the `update_queue`""" + await application.update_queue.put( + Update.de_json(data=await request.get_json(), bot=application.bot) + ) + return Response(status=HTTPStatus.OK) + + @quart_app.route("/submitpayload", methods=["GET", "POST"]) # type: ignore[untyped-decorator] + async def custom_updates() -> Response: + """ + Handle incoming webhook updates by also putting them into the `update_queue` if + the required parameters were passed correctly. + """ + try: + user_id = int(request.args["user_id"]) + payload = request.args["payload"] + except KeyError: + abort( + HTTPStatus.BAD_REQUEST, + "Please pass both `user_id` and `payload` as query parameters.", + ) + except ValueError: + abort(HTTPStatus.BAD_REQUEST, "The `user_id` must be a string!") + + await application.update_queue.put(WebhookUpdate(user_id=user_id, payload=payload)) + return Response(status=HTTPStatus.OK) + + @quart_app.get("/healthcheck") # type: ignore[untyped-decorator] + async def health() -> Response: + """For the health endpoint, reply with a simple plain text message.""" + response = await make_response("The bot is still running fine :)", HTTPStatus.OK) + response.mimetype = "text/plain" + return response + + webserver = uvicorn.Server( + config=uvicorn.Config( + app=quart_app, + port=PORT, + use_colors=False, + host="127.0.0.1", + ) + ) + + # Run application and webserver together + async with application: + await application.start() + await webserver.serve() + await application.stop() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/customwebhookbot.py b/examples/customwebhookbot/starlettebot.py similarity index 78% rename from examples/customwebhookbot.py rename to examples/customwebhookbot/starlettebot.py index f9539dfe3c7..26ee12fe2ad 100644 --- a/examples/customwebhookbot.py +++ b/examples/customwebhookbot/starlettebot.py @@ -1,18 +1,19 @@ #!/usr/bin/env python # This program is dedicated to the public domain under the CC0 license. -# pylint: disable=import-error,wrong-import-position +# pylint: disable=import-error,unused-argument """ Simple example of a bot that uses a custom webhook setup and handles custom updates. For the custom webhook setup, the libraries `starlette` and `uvicorn` are used. Please install -them as `pip install starlette~=0.20.0 uvicorn~=0.17.0`. +them as `pip install starlette~=0.20.0 uvicorn~=0.23.2`. Note that any other `asyncio` based web server framework can be used for a custom webhook setup just as well. Usage: -Set bot token, url, admin chat_id and port at the start of the `main` function. +Set bot Token, URL, admin CHAT_ID and PORT after the imports. You may also need to change the `listen` value in the uvicorn configuration to match your setup. Press Ctrl-C on the command line or send a signal to the process to stop the bot. """ + import asyncio import html import logging @@ -25,20 +26,6 @@ from starlette.responses import PlainTextResponse, Response from starlette.routing import Route -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) - from telegram import Update from telegram.constants import ParseMode from telegram.ext import ( @@ -54,8 +41,17 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) +# Define configuration constants +URL = "https://domain.tld" +ADMIN_CHAT_ID = 123456 +PORT = 8000 +TOKEN = "123:ABC" # nosec B105 + @dataclass class WebhookUpdate: @@ -84,17 +80,16 @@ def from_update( async def start(update: Update, context: CustomContext) -> None: """Display a message with instructions on how to use this bot.""" - url = context.bot_data["url"] - payload_url = html.escape(f"{url}/submitpayload?user_id=&payload=") + payload_url = html.escape(f"{URL}/submitpayload?user_id=&payload=") text = ( - f"To check if the bot is still running, call {url}/healthcheck.\n\n" + f"To check if the bot is still running, call {URL}/healthcheck.\n\n" f"To post a custom update, call {payload_url}." ) await update.message.reply_html(text=text) async def webhook_update(update: WebhookUpdate, context: CustomContext) -> None: - """Callback that handles the custom updates.""" + """Handle custom updates.""" chat_member = await context.bot.get_chat_member(chat_id=update.user_id, user_id=update.user_id) payloads = context.user_data.setdefault("payloads", []) payloads.append(update.payload) @@ -103,33 +98,24 @@ async def webhook_update(update: WebhookUpdate, context: CustomContext) -> None: f"The user {chat_member.user.mention_html()} has sent a new payload. " f"So far they have sent the following payloads: \n\n• {combined_payloads}" ) - await context.bot.send_message( - chat_id=context.bot_data["admin_chat_id"], text=text, parse_mode=ParseMode.HTML - ) + await context.bot.send_message(chat_id=ADMIN_CHAT_ID, text=text, parse_mode=ParseMode.HTML) async def main() -> None: - """Set up the application and a custom webserver.""" - url = "https://domain.tld" - admin_chat_id = 123456 - port = 8000 - + """Set up PTB application and a web application for handling the incoming requests.""" context_types = ContextTypes(context=CustomContext) # Here we set updater to None because we want our custom webhook server to handle the updates # and hence we don't need an Updater instance application = ( - Application.builder().token("TOKEN").updater(None).context_types(context_types).build() + Application.builder().token(TOKEN).updater(None).context_types(context_types).build() ) - # save the values in `bot_data` such that we may easily access them in the callbacks - application.bot_data["url"] = url - application.bot_data["admin_chat_id"] = admin_chat_id # register handlers application.add_handler(CommandHandler("start", start)) application.add_handler(TypeHandler(type=WebhookUpdate, callback=webhook_update)) # Pass webhook settings to telegram - await application.bot.set_webhook(url=f"{url}/telegram", allowed_updates=Update.ALL_TYPES) + await application.bot.set_webhook(url=f"{URL}/telegram", allowed_updates=Update.ALL_TYPES) # Set up webserver async def telegram(request: Request) -> Response: @@ -175,7 +161,7 @@ async def health(_: Request) -> PlainTextResponse: webserver = uvicorn.Server( config=uvicorn.Config( app=starlette_app, - port=port, + port=PORT, use_colors=False, host="127.0.0.1", ) diff --git a/examples/deeplinking.py b/examples/deeplinking.py index 63941916b36..3ac7f9bd0e5 100644 --- a/examples/deeplinking.py +++ b/examples/deeplinking.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """Bot that explains Telegram's "Deep Linking Parameters" functionality. @@ -20,20 +20,13 @@ import logging -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) -from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update, helpers +from telegram import ( + InlineKeyboardButton, + InlineKeyboardMarkup, + LinkPreviewOptions, + Update, + helpers, +) from telegram.constants import ParseMode from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes, filters @@ -42,6 +35,9 @@ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) # Define constants that will allow us to reuse the deep-linking parameters. @@ -67,8 +63,7 @@ async def deep_linked_level_1(update: Update, context: ContextTypes.DEFAULT_TYPE bot = context.bot url = helpers.create_deep_linked_url(bot.username, SO_COOL) text = ( - "Awesome, you just accessed hidden functionality! " - "Now let's get back to the private chat." + "Awesome, you just accessed hidden functionality! Now let's get back to the private chat." ) keyboard = InlineKeyboardMarkup.from_button( InlineKeyboardButton(text="Continue here!", url=url) @@ -81,7 +76,9 @@ async def deep_linked_level_2(update: Update, context: ContextTypes.DEFAULT_TYPE bot = context.bot url = helpers.create_deep_linked_url(bot.username, USING_ENTITIES) text = f'You can also mask the deep-linked URLs as links: ▶️ CLICK HERE.' - await update.message.reply_text(text, parse_mode=ParseMode.HTML, disable_web_page_preview=True) + await update.message.reply_text( + text, parse_mode=ParseMode.HTML, link_preview_options=LinkPreviewOptions(is_disabled=True) + ) async def deep_linked_level_3(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: diff --git a/examples/echobot.py b/examples/echobot.py index 3c06e799c2a..b2ccdc139f2 100644 --- a/examples/echobot.py +++ b/examples/echobot.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """ @@ -17,19 +17,6 @@ import logging -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import ForceReply, Update from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters @@ -37,6 +24,9 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) diff --git a/examples/errorhandlerbot.py b/examples/errorhandlerbot.py index 0b6620f565a..450b18eb284 100644 --- a/examples/errorhandlerbot.py +++ b/examples/errorhandlerbot.py @@ -1,26 +1,14 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """This is a very simple example on how one could implement a custom error handler.""" + import html import json import logging import traceback -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import Update from telegram.constants import ParseMode from telegram.ext import Application, CommandHandler, ContextTypes @@ -29,6 +17,9 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) # This can be your own ID, or one for a developer group/channel. @@ -50,7 +41,7 @@ async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> N # You might need to add some logic to deal with messages longer than the 4096 character limit. update_str = update.to_dict() if isinstance(update, Update) else str(update) message = ( - f"An exception was raised while handling an update\n" + "An exception was raised while handling an update\n" f"
update = {html.escape(json.dumps(update_str, indent=2, ensure_ascii=False))}"
         "
\n\n" f"
context.chat_data = {html.escape(str(context.chat_data))}
\n\n" diff --git a/examples/inlinebot.py b/examples/inlinebot.py index 8ec2cbc52ed..1805e3b4dd7 100644 --- a/examples/inlinebot.py +++ b/examples/inlinebot.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """ @@ -14,23 +14,11 @@ Press Ctrl-C on the command line or send a signal to the process to stop the bot. """ + import logging from html import escape from uuid import uuid4 -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import InlineQueryResultArticle, InputTextMessageContent, Update from telegram.constants import ParseMode from telegram.ext import Application, CommandHandler, ContextTypes, InlineQueryHandler @@ -39,6 +27,9 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) @@ -95,7 +86,7 @@ def main() -> None: application.add_handler(CommandHandler("start", start)) application.add_handler(CommandHandler("help", help_command)) - # on non command i.e message - echo the message on Telegram + # on inline queries - show corresponding inline results application.add_handler(InlineQueryHandler(inline_query)) # Run the bot until the user presses Ctrl-C diff --git a/examples/inlinekeyboard.py b/examples/inlinekeyboard.py index d21a9c1d938..8fcc53af8a3 100644 --- a/examples/inlinekeyboard.py +++ b/examples/inlinekeyboard.py @@ -1,26 +1,14 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """ Basic example for a bot that uses inline keyboards. For an in-depth explanation, check out https://github.com/python-telegram-bot/python-telegram-bot/wiki/InlineKeyboard-Example. """ -import logging - -from telegram import __version__ as TG_VER -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] +import logging -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes @@ -28,6 +16,9 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) diff --git a/examples/inlinekeyboard2.py b/examples/inlinekeyboard2.py index 2f57b7fe0de..2a766be70e8 100644 --- a/examples/inlinekeyboard2.py +++ b/examples/inlinekeyboard2.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """Simple inline keyboard bot with multiple CallbackQueryHandlers. @@ -14,21 +14,9 @@ Send /start to initiate the conversation. Press Ctrl-C on the command line to stop the bot. """ -import logging - -from telegram import __version__ as TG_VER -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] +import logging -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ( Application, @@ -42,6 +30,9 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) # Stages diff --git a/examples/nestedconversationbot.py b/examples/nestedconversationbot.py index 2a4a7915251..bc940f4cd45 100644 --- a/examples/nestedconversationbot.py +++ b/examples/nestedconversationbot.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """ @@ -15,21 +15,8 @@ """ import logging -from typing import Any, Dict, Tuple +from typing import Any -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ( Application, @@ -45,6 +32,9 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) # State definitions for top level conversation @@ -76,7 +66,7 @@ # Helper -def _name_switcher(level: str) -> Tuple[str, str]: +def _name_switcher(level: str) -> tuple[str, str]: if level == PARENTS: return "Father", "Mother" return "Brother", "Sister" @@ -132,7 +122,7 @@ async def adding_self(update: Update, context: ContextTypes.DEFAULT_TYPE) -> str async def show_data(update: Update, context: ContextTypes.DEFAULT_TYPE) -> str: """Pretty print gathered data.""" - def pretty_print(data: Dict[str, Any], level: str) -> str: + def pretty_print(data: dict[str, Any], level: str) -> str: people = data.get(level) if not people: return "\nNo information yet." @@ -381,8 +371,8 @@ def main() -> None: entry_points=[CommandHandler("start", start)], states={ SHOWING: [CallbackQueryHandler(start, pattern="^" + str(END) + "$")], - SELECTING_ACTION: selection_handlers, - SELECTING_LEVEL: selection_handlers, + SELECTING_ACTION: selection_handlers, # type: ignore[dict-item] + SELECTING_LEVEL: selection_handlers, # type: ignore[dict-item] DESCRIBING_SELF: [description_conv], STOPPING: [CommandHandler("start", start)], }, diff --git a/examples/passportbot.py b/examples/passportbot.py index d0669bd24de..6b012b583c9 100644 --- a/examples/passportbot.py +++ b/examples/passportbot.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """ @@ -12,24 +12,12 @@ Note: To use Telegram Passport, you must install PTB via -`pip install python-telegram-bot[passport]` +`pip install "python-telegram-bot[passport]"` """ + import logging from pathlib import Path -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 5): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import Update from telegram.ext import Application, ContextTypes, MessageHandler, filters @@ -39,6 +27,9 @@ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) @@ -57,9 +48,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", @@ -68,7 +59,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", @@ -76,28 +67,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", @@ -110,10 +101,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/examples/paymentbot.py b/examples/paymentbot.py index 23b077bb606..dfa641cccc8 100644 --- a/examples/paymentbot.py +++ b/examples/paymentbot.py @@ -1,24 +1,11 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. -"""Basic example for a bot that can receive payment from user.""" +"""Basic example for a bot that can receive payments from users.""" import logging -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import LabeledPrice, ShippingOption, Update from telegram.ext import ( Application, @@ -34,46 +21,49 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) +# Insert the token from your payment provider. +# In order to get a provider_token see https://core.telegram.org/bots/payments#getting-a-token PAYMENT_PROVIDER_TOKEN = "PAYMENT_PROVIDER_TOKEN" async def start_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Displays info on how to use the bot.""" + """Provides instructions on how to use the bot.""" msg = ( - "Use /shipping to get an invoice for shipping-payment, or /noshipping for an " + "Use /shipping to receive an invoice with shipping included, or /noshipping for an " "invoice without shipping." ) - await update.message.reply_text(msg) async def start_with_shipping_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Sends an invoice with shipping-payment.""" + """Sends an invoice which triggers a shipping query.""" chat_id = update.message.chat_id title = "Payment Example" - description = "Payment Example using python-telegram-bot" - # select a payload just for you to recognize its the donation from your bot + description = "Example of a payment process using the python-telegram-bot library." + # Unique payload to identify this payment request as being from your bot payload = "Custom-Payload" - # In order to get a provider_token see https://core.telegram.org/bots/payments#getting-a-token + # Set up the currency. + # List of supported currencies: https://core.telegram.org/bots/payments#supported-currencies currency = "USD" - # price in dollars + # Price in dollars price = 1 - # price * 100 so as to include 2 decimal points - # check https://core.telegram.org/bots/payments#supported-currencies for more details + # Convert price to cents from dollars. prices = [LabeledPrice("Test", price * 100)] - - # optionally pass need_name=True, need_phone_number=True, - # need_email=True, need_shipping_address=True, is_flexible=True + # Optional parameters like need_shipping_address and is_flexible trigger extra user prompts + # https://docs.python-telegram-bot.org/en/stable/telegram.bot.html#telegram.Bot.send_invoice await context.bot.send_invoice( chat_id, title, description, payload, - PAYMENT_PROVIDER_TOKEN, currency, prices, + provider_token=PAYMENT_PROVIDER_TOKEN, need_name=True, need_phone_number=True, need_email=True, @@ -85,86 +75,91 @@ async def start_with_shipping_callback(update: Update, context: ContextTypes.DEF async def start_without_shipping_callback( update: Update, context: ContextTypes.DEFAULT_TYPE ) -> None: - """Sends an invoice without shipping-payment.""" + """Sends an invoice without requiring shipping details.""" chat_id = update.message.chat_id title = "Payment Example" - description = "Payment Example using python-telegram-bot" - # select a payload just for you to recognize its the donation from your bot + description = "Example of a payment process using the python-telegram-bot library." + # Unique payload to identify this payment request as being from your bot payload = "Custom-Payload" - # In order to get a provider_token see https://core.telegram.org/bots/payments#getting-a-token currency = "USD" - # price in dollars + # Price in dollars price = 1 - # price * 100 so as to include 2 decimal points + # Convert price to cents from dollars. prices = [LabeledPrice("Test", price * 100)] # optionally pass need_name=True, need_phone_number=True, # need_email=True, need_shipping_address=True, is_flexible=True await context.bot.send_invoice( - chat_id, title, description, payload, PAYMENT_PROVIDER_TOKEN, currency, prices + chat_id, + title, + description, + payload, + currency, + prices, + provider_token=PAYMENT_PROVIDER_TOKEN, ) async def shipping_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Answers the ShippingQuery with ShippingOptions""" + """Handles the ShippingQuery with available shipping options.""" query = update.shipping_query - # check the payload, is this from your bot? + # Verify if the payload matches, ensure it's from your bot if query.invoice_payload != "Custom-Payload": - # answer False pre_checkout_query + # If not, respond with an error await query.answer(ok=False, error_message="Something went wrong...") return - # First option has a single LabeledPrice + # Define available shipping options + # First option with a single price entry options = [ShippingOption("1", "Shipping Option A", [LabeledPrice("A", 100)])] - # second option has an array of LabeledPrice objects + # Second option with multiple price entries price_list = [LabeledPrice("B1", 150), LabeledPrice("B2", 200)] options.append(ShippingOption("2", "Shipping Option B", price_list)) await query.answer(ok=True, shipping_options=options) -# after (optional) shipping, it's the pre-checkout +# After (optional) shipping, process the pre-checkout step async def precheckout_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Answers the PreQecheckoutQuery""" + """Responds to the PreCheckoutQuery as the final confirmation for checkout.""" query = update.pre_checkout_query - # check the payload, is this from your bot? + # Verify if the payload matches, ensure it's from your bot if query.invoice_payload != "Custom-Payload": - # answer False pre_checkout_query + # If not, respond with an error await query.answer(ok=False, error_message="Something went wrong...") else: await query.answer(ok=True) -# finally, after contacting the payment provider... +# Final callback after successful payment async def successful_payment_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Confirms the successful payment.""" - # do something after successfully receiving payment? - await update.message.reply_text("Thank you for your payment!") + """Acknowledges successful payment and thanks the user.""" + await update.message.reply_text("Thank you for your payment.") def main() -> None: - """Run the bot.""" + """Starts the bot and sets up handlers.""" # Create the Application and pass it your bot's token. application = Application.builder().token("TOKEN").build() - # simple start function + # Start command to display usage instructions application.add_handler(CommandHandler("start", start_callback)) - # Add command handler to start the payment invoice + # Command handlers for starting the payment process application.add_handler(CommandHandler("shipping", start_with_shipping_callback)) application.add_handler(CommandHandler("noshipping", start_without_shipping_callback)) - # Optional handler if your product requires shipping + # Handler for shipping query (if product requires shipping) application.add_handler(ShippingQueryHandler(shipping_callback)) - # Pre-checkout handler to final check + # Pre-checkout handler for verifying payment details. application.add_handler(PreCheckoutQueryHandler(precheckout_callback)) - # Success! Notify your user! + # Handler for successful payment. Notify the user that the payment was successful. application.add_handler( MessageHandler(filters.SUCCESSFUL_PAYMENT, successful_payment_callback) ) - # Run the bot until the user presses Ctrl-C + # Start polling for updates until interrupted (CTRL+C) application.run_polling(allowed_updates=Update.ALL_TYPES) diff --git a/examples/persistentconversationbot.py b/examples/persistentconversationbot.py index 22db5f45265..4c5322456bb 100644 --- a/examples/persistentconversationbot.py +++ b/examples/persistentconversationbot.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """ @@ -15,21 +15,7 @@ """ import logging -from typing import Dict -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove, Update from telegram.ext import ( Application, @@ -45,6 +31,9 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) CHOOSING, TYPING_REPLY, TYPING_CHOICE = range(3) @@ -57,7 +46,7 @@ markup = ReplyKeyboardMarkup(reply_keyboard, one_time_keyboard=True) -def facts_to_str(user_data: Dict[str, str]) -> str: +def facts_to_str(user_data: dict[str, str]) -> str: """Helper function for formatting the gathered user info.""" facts = [f"{key} - {value}" for key, value in user_data.items()] return "\n".join(facts).join(["\n", "\n"]) @@ -69,7 +58,7 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: if context.user_data: reply_text += ( f" You already told me your {', '.join(context.user_data.keys())}. Why don't you " - f"tell me something more about yourself? Or change anything I already know." + "tell me something more about yourself? Or change anything I already know." ) else: reply_text += ( diff --git a/examples/pollbot.py b/examples/pollbot.py index f7a49088a99..c4e296e1c1f 100644 --- a/examples/pollbot.py +++ b/examples/pollbot.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """ @@ -7,21 +7,9 @@ poll/quiz the bot generates. The preview command generates a closed poll/quiz, exactly like the one the user sends the bot """ -import logging - -from telegram import __version__ as TG_VER -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] +import logging -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import ( KeyboardButton, KeyboardButtonPollType, @@ -45,6 +33,9 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) diff --git a/examples/rawapibot.py b/examples/rawapibot.py index 03c8a548b88..b668046f835 100644 --- a/examples/rawapibot.py +++ b/examples/rawapibot.py @@ -1,35 +1,26 @@ #!/usr/bin/env python -# pylint: disable=wrong-import-position """Simple Bot to reply to Telegram messages. This is built on the API wrapper, see echobot.py to see the same example built on the telegram.ext bot framework. This program is dedicated to the public domain under the CC0 license. """ + import asyncio import contextlib +import datetime as dtm import logging from typing import NoReturn -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import Bot, Update from telegram.error import Forbidden, NetworkError logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) @@ -58,7 +49,9 @@ async def main() -> NoReturn: async def echo(bot: Bot, update_id: int) -> int: """Echo the message the user sent.""" # Request updates after the last update_id - updates = await bot.get_updates(offset=update_id, timeout=10, allowed_updates=Update.ALL_TYPES) + updates = await bot.get_updates( + offset=update_id, timeout=dtm.timedelta(seconds=10), allowed_updates=Update.ALL_TYPES + ) for update in updates: next_update_id = update.update_id + 1 diff --git a/examples/timerbot.py b/examples/timerbot.py index 3c9e7a742a5..462780b1321 100644 --- a/examples/timerbot.py +++ b/examples/timerbot.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# pylint: disable=unused-argument, wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """ @@ -19,24 +19,11 @@ Note: To use the JobQueue, you must install PTB via -`pip install python-telegram-bot[job-queue]` +`pip install "python-telegram-bot[job-queue]"` """ import logging -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import Update from telegram.ext import Application, CommandHandler, ContextTypes diff --git a/examples/webappbot.py b/examples/webappbot.py index 4a2198012bd..6b095d00726 100644 --- a/examples/webappbot.py +++ b/examples/webappbot.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# pylint: disable=unused-argument,wrong-import-position +# pylint: disable=unused-argument # This program is dedicated to the public domain under the CC0 license. """ @@ -8,22 +8,10 @@ Currently only showcases starting the WebApp via a KeyboardButton, as all other methods would require a bot token. """ + import json import logging -from telegram import __version__ as TG_VER - -try: - from telegram import __version_info__ -except ImportError: - __version_info__ = (0, 0, 0, 0, 0) # type: ignore[assignment] - -if __version_info__ < (20, 0, 0, "alpha", 1): - raise RuntimeError( - f"This example is not compatible with your current PTB version {TG_VER}. To view the " - f"{TG_VER} version of this example, " - f"visit https://docs.python-telegram-bot.org/en/v{TG_VER}/examples.html" - ) from telegram import KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove, Update, WebAppInfo from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters @@ -31,6 +19,9 @@ logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO ) +# set higher logging level for httpx to avoid all GET and POST requests being logged +logging.getLogger("httpx").setLevel(logging.WARNING) + logger = logging.getLogger(__name__) @@ -55,8 +46,10 @@ async def web_app_data(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No # (see webappbot.html) data = json.loads(update.effective_message.web_app_data.data) await update.message.reply_html( - text=f"You selected the color with the HEX value {data['hex']}. The " - f"corresponding RGB value is {tuple(data['rgb'].values())}.", + text=( + f"You selected the color with the HEX value {data['hex']}. The " + f"corresponding RGB value is {tuple(data['rgb'].values())}." + ), reply_markup=ReplyKeyboardRemove(), ) 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 c9b968f597f..d5573d6e9a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,18 +1,288 @@ -[tool.black] -line-length = 99 -target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] +# 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.10" +license = "LGPL-3.0-only" +license-files = ["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.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "httpx >=0.27,<0.29", + "httpcore >=1.0.9; python_version >= '3.14'" # httpx doesn't pin this as of 0.28.1 +] + +[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 +# +# 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 extras, 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,<6.3.0", +] +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,<3.12.0", +] +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,<1.3", +] +socks = [ + "httpx[socks]", +] +webhooks = [ + # tornado is rather stable, but let's not allow the next major release without prior testing + "tornado~=6.5", +] -[tool.isort] # black config -profile = "black" -line_length = 99 +[dependency-groups] +tests = [ + # required for building the wheels for releases + "build", + # For the test suite + "pytest==9.0.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.8.0", + # Used for flaky tests (flaky decorator) + "flaky>=3.8.1", + # used in test_official for parsing tg docs + "beautifulsoup4", + # For testing with timezones. Might not be needed on all systems, but to ensure that unit tests + # run correctly on all systems, we include it here. + "tzdata", + # We've deprecated support pytz, but we still need it for testing that it works with the library. + "pytz", + # Install coverage: + "pytest-cov" +] +docs = [ + "chango~=0.6.0; python_version >= '3.12'", + "sphinx==8.2.3; python_version >= '3.11'", + "furo==2025.9.25", + "sphinx-paramlinks==0.6.0", + "sphinxcontrib-mermaid==1.0.0", + "sphinx-copybutton==0.5.2", + "sphinx-inline-tabs==2023.4.21", + # Temporary. See #4387 + "sphinx-build-compatibility @ git+https://github.com/readthedocs/sphinx-build-compatibility.git@58aabc5f207c6c2421f23d3578adc0b14af57047", + # For python 3.14 support, we need a version of pydantic-core >= 2.35.0, since it upgrades the + # rust toolchain, required for building the project. + # This should ideally be done in `chango`'s dependencies. We can remove this once a new + # stable pydantic version is released. + "pydantic >= 2.12.0a1 ; python_version >= '3.14'" +] +linting = [ + "pre-commit", + "ruff==0.14.14", + "mypy==1.18.2", + "pylint==4.0.4" +] +all = [{ include-group = "tests" }, { include-group = "docs" }, { include-group = "linting"}] +# HATCH +[tool.hatch.version] +# dynamically evaluates the `__version__` variable in that file +source = "code" +path = "src/telegram/_version.py" + +# See also https://github.com/pypa/hatch/issues/1230 for discussion +# the source distribution will include most of the files in the root directory +[tool.hatch.build.targets.sdist] +exclude = [".venv*", "venv*", ".github", "uv.lock"] +# the wheel will only include the src/telegram package +[tool.hatch.build.targets.wheel] +packages = ["src/telegram"] + +# CHANGO +[tool.chango] +sys_path = "changes" +chango_instance = { name= "chango_instance", module = "config" } + +# RUFF: [tool.ruff] line-length = 99 -target-version = "py37" show-fixes = true -ignore = ["PLR2004", "PLR0911", "PLR0912", "PLR0913", "PLR0915"] + +[tool.ruff.lint] +typing-extensions = false +ignore = ["PLR2004", "PLR0911", "PLR0912", "PLR0913", "PLR0915", "PERF203"] select = ["E", "F", "I", "PL", "UP", "RUF", "PTH", "C4", "B", "PIE", "SIM", "RET", "RSE", - "G", "ISC", "PT"] + "G", "ISC", "PT", "ASYNC", "TCH", "SLOT", "PERF", "PYI", "FLY", "AIR", "RUF022", + "RUF023", "Q", "INP", "W", "YTT", "DTZ", "ARG", "T20", "FURB", "DOC", "TRY", + "D100", "D101", "D102", "D103", "D300", "D418", "D419", "S"] +# Add "A (flake8-builtins)" after we drop pylint -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "tests/*.py" = ["B018"] +"tests/**.py" = ["RUF012", "ASYNC230", "DTZ", "ARG", "T201", "ASYNC109", "D", "S", "TRY"] +"src/telegram/**.py" = ["TRY003"] +"src/telegram/ext/_applicationbuilder.py" = ["TRY004"] +"src/telegram/ext/filters.py" = ["D102"] +"docs/**.py" = ["INP001", "ARG", "D", "TRY003", "S"] +"examples/**.py" = ["ARG", "D", "S105", "TRY003"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +# PYLINT: +[tool.pylint."messages control"] +enable = ["useless-suppression"] +disable = ["duplicate-code", "too-many-arguments", "too-many-public-methods", + "too-few-public-methods", "broad-exception-caught", "too-many-instance-attributes", + "fixme", "missing-function-docstring", "missing-class-docstring", "too-many-locals", + "too-many-lines", "too-many-branches", "too-many-statements", "cyclic-import", + "too-many-positional-arguments", +] + +[tool.pylint.main] +# run pylint across multiple cpu cores to speed it up- +# https://pylint.pycqa.org/en/latest/user_guide/run.html?#parallel-execution to know more +jobs = 0 +py-version = "3.10" + +[tool.pylint.classes] +exclude-protected = ["_unfrozen"] + +# PYTEST: +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +addopts = "--no-success-flaky-report -rX" +filterwarnings = [ + "error", + "ignore::DeprecationWarning", + 'ignore:Tasks created via `Application\.create_task` while the application is not running', + "ignore::ResourceWarning", + # TODO: Write so good code that we don't need to ignore ResourceWarnings anymore + # Unfortunately due to https://github.com/pytest-dev/pytest/issues/8343 we can't have this here + # and instead do a trick directly in tests/conftest.py + # ignore::telegram.utils.deprecate.TelegramDeprecationWarning +] +markers = [ + "dev", # If you want to test a specific test, use this + "no_req", + "req", +] +asyncio_mode = "auto" +log_cli_format = "%(funcName)s - Line %(lineno)d - %(message)s" +# log_cli_level = "DEBUG" # uncomment to see DEBUG logs + +# MYPY: +[tool.mypy] +mypy_path = "src" +warn_unused_ignores = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_decorators = true +show_error_codes = true +python_version = "3.10" + +# For some files, it's easier to just disable strict-optional all together instead of +# cluttering the code with `# type: ignore`s or stuff like +# `if self.text is None: raise RuntimeError()` +[[tool.mypy.overrides]] +module = [ + "telegram._callbackquery", + "telegram._file", + "telegram._message", + "telegram._files.file" +] +strict_optional = false + +# type hinting for asyncio in webhookhandler is a bit tricky because it depends on the OS +[[tool.mypy.overrides]] +module = "telegram.ext._utils.webhookhandler" +warn_unused_ignores = false + +# The libs listed below are only used for the `customwebhookbot_*.py` examples +# let's just ignore type checking for them for now +[[tool.mypy.overrides]] +module = [ + "flask.*", + "quart.*", + "starlette.*", + "uvicorn.*", + "asgiref.*", + "django.*", + "apscheduler.*", # not part of `customwebhookbot_*.py` examples +] +ignore_missing_imports = true + +# COVERAGE: +[tool.coverage.run] +branch = true +source = ["src/telegram"] +parallel = true +concurrency = ["thread", "multiprocessing"] +omit = [ + "tests/", + "src/telegram/__main__.py" +] + +[tool.coverage.report] +exclude_also = [ + "@overload", + "@abstractmethod", + "if TYPE_CHECKING:" +] 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.txt b/requirements-dev.txt deleted file mode 100644 index fd6d82cb889..00000000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,10 +0,0 @@ -pre-commit # needed for pre-commit hooks in the git commit command - -# For the test suite -pytest==7.3.1 -pytest-asyncio==0.21.0 # needed because pytest doesn't come with native support for coroutines as tests -pytest-xdist==3.3.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 53d68609681..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.2 # webhooks!ext - -# Cachetools and APS don't have a strict stability policy. -# Let's be cautious for now. -cachetools~=5.3.1 # callback-data!ext -APScheduler~=3.10.1 # 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.txt b/requirements.txt deleted file mode 100644 index 3992f3a1990..00000000000 --- a/requirements.txt +++ /dev/null @@ -1,9 +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, so let's be cautious for now -httpx ~= 0.24.1 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/setup.cfg b/setup.cfg deleted file mode 100644 index 0239640a5d7..00000000000 --- a/setup.cfg +++ /dev/null @@ -1,86 +0,0 @@ -[metadata] -license_files = LICENSE, LICENSE.dual, LICENSE.lesser - -[build_sphinx] -source-dir = docs/source -build-dir = docs/build -all_files = 1 - -[upload_sphinx] -upload-dir = docs/build/html - -[flake8] -max-line-length = 99 -ignore = W503, W605 -extend-ignore = E203 -exclude = setup.py, setup-raw.py docs/source/conf.py - -[pylint.message-control] -disable = duplicate-code,too-many-arguments,too-many-public-methods,too-few-public-methods, - broad-except,too-many-instance-attributes,fixme,missing-function-docstring, - missing-class-docstring,too-many-locals,too-many-lines,too-many-branches, - too-many-statements -enable=useless-suppression ; Warns about unused pylint ignores -exclude-protected=_unfrozen - -[tool:pytest] -testpaths = tests -addopts = --no-success-flaky-report -rsxX -filterwarnings = - error - ignore::DeprecationWarning - ignore:Tasks created via `Application\.create_task` while the application is not running - ignore::ResourceWarning -; TODO: Write so good code that we don't need to ignore ResourceWarnings anymore - -; Unfortunately due to https://github.com/pytest-dev/pytest/issues/8343 we can't have this here -; and instead do a trick directly in tests/conftest.py -; ignore::telegram.utils.deprecate.TelegramDeprecationWarning -markers = - dev: If you want to test a specific test, use this - no_req - req -asyncio_mode = auto - -[coverage:run] -branch = True -source = telegram -parallel = True -concurrency = thread, multiprocessing -omit = - tests/ - telegram/__main__.py - -[coverage:report] -exclude_lines = - pragma: no cover - @overload - if TYPE_CHECKING: - -[mypy] -warn_unused_ignores = True -warn_unused_configs = True -disallow_untyped_defs = True -disallow_incomplete_defs = True -disallow_untyped_decorators = True -show_error_codes = True - -# For some files, it's easier to just disable strict-optional all together instead of -# cluttering the code with `# type: ignore`s or stuff like -# `if self.text is None: raise RuntimeError()` -[mypy-telegram._callbackquery,telegram._file,telegram._message,telegram._files.file] -strict_optional = False - -# type hinting for asyncio in webhookhandler is a bit tricky because it depends on the OS -[mypy-telegram.ext._utils.webhookhandler] -warn_unused_ignores = False - -[mypy-apscheduler.*] -ignore_missing_imports = True - -# uvicorn and starlette are only used for the `customwebhookbot.py` example -# let's just ignore type checking for them for now -[mypy-uvicorn.*] -ignore_missing_imports = True -[mypy-starlette.*] -ignore_missing_imports = True diff --git a/setup.py b/setup.py deleted file mode 100644 index 27e99762652..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 setuptools import find_packages, setup - - -def get_requirements(): - """Build the requirements list for this project""" - requirements_list = [] - - with Path("requirements.txt").open() as reqs: - for install in reqs: - if install.startswith("#"): - continue - requirements_list.append(install.strip()) - - return requirements_list - - -def get_packages_requirements(raw=False): - """Build the package & requirements list for this project""" - reqs = get_requirements() - - exclude = ["tests*"] - if raw: - exclude.append("telegram.ext*") - - packs = find_packages(exclude=exclude) - - return packs, reqs - - -def get_optional_requirements(raw=False): - """Build the optional dependencies""" - requirements = defaultdict(list) - - with Path("requirements-opts.txt").open() as reqs: - for line in reqs: - line = line.strip() - if not line or line.startswith("#"): - continue - dependency, names = line.split("#") - dependency = dependency.strip() - for name in names.split(","): - name = name.strip() - if name.endswith("!ext"): - if raw: - continue - else: - name = name[:-4] - requirements["ext"].append(dependency) - requirements[name].append(dependency) - requirements["all"].append(dependency) - - return requirements - - -def get_setup_kwargs(raw=False): - """Builds a dictionary of kwargs for the setup function""" - packages, requirements = get_packages_requirements(raw=raw) - - raw_ext = "-raw" if raw else "" - readme = Path(f'README{"_RAW" if raw else ""}.rst') - - version_file = Path("telegram/_version.py").read_text() - first_part = version_file.split("# SETUP.PY MARKER")[0] - exec(first_part) - - kwargs = dict( - 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(), - 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.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - ], - python_requires=">=3.7", - ) - - return kwargs - - -def main(): - # 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/telegram/__init__.py b/src/telegram/__init__.py similarity index 62% rename from telegram/__init__.py rename to src/telegram/__init__.py index fb3dcadd6df..f4c9a605f17 100644 --- a/telegram/__init__.py +++ b/src/telegram/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,14 +19,21 @@ """A library that provides a Python interface to the Telegram Bot API""" __author__ = "devs@python-telegram-bot.org" - -__all__ = ( # Keep this alphabetically ordered - "__bot_api_version__", - "__bot_api_version_info__", - "__version__", - "__version_info__", +__all__ = ( + "AcceptedGiftTypes", + "AffiliateInfo", "Animation", "Audio", + "BackgroundFill", + "BackgroundFillFreeformGradient", + "BackgroundFillGradient", + "BackgroundFillSolid", + "BackgroundType", + "BackgroundTypeChatTheme", + "BackgroundTypeFill", + "BackgroundTypePattern", + "BackgroundTypeWallpaper", + "Birthdate", "Bot", "BotCommand", "BotCommandScope", @@ -40,34 +47,57 @@ "BotDescription", "BotName", "BotShortDescription", + "BusinessBotRights", + "BusinessConnection", + "BusinessIntro", + "BusinessLocation", + "BusinessMessagesDeleted", + "BusinessOpeningHours", + "BusinessOpeningHoursInterval", "CallbackGame", "CallbackQuery", "Chat", "ChatAdministratorRights", + "ChatBackground", + "ChatBoost", + "ChatBoostAdded", + "ChatBoostRemoved", + "ChatBoostSource", + "ChatBoostSourceGiftCode", + "ChatBoostSourceGiveaway", + "ChatBoostSourcePremium", + "ChatBoostUpdated", + "ChatFullInfo", "ChatInviteLink", "ChatJoinRequest", "ChatLocation", "ChatMember", - "ChatMemberOwner", "ChatMemberAdministrator", + "ChatMemberBanned", + "ChatMemberLeft", "ChatMemberMember", + "ChatMemberOwner", "ChatMemberRestricted", - "ChatMemberLeft", - "ChatMemberBanned", "ChatMemberUpdated", "ChatPermissions", "ChatPhoto", "ChatShared", + "Checklist", + "ChecklistTask", + "ChecklistTasksAdded", + "ChecklistTasksDone", "ChosenInlineResult", - "constants", "Contact", + "CopyTextButton", "Credentials", "DataCredentials", "Dice", + "DirectMessagePriceChanged", + "DirectMessagesTopic", "Document", "EncryptedCredentials", "EncryptedPassportElement", - "error", + "ExternalReplyInfo", "File", "FileCredentials", "ForceReply", @@ -80,8 +110,16 @@ "GameHighScore", "GeneralForumTopicHidden", "GeneralForumTopicUnhidden", - "helpers", + "Gift", + "GiftBackground", + "GiftInfo", + "Gifts", + "Giveaway", + "GiveawayCompleted", + "GiveawayCreated", + "GiveawayWinners", "IdDocumentData", + "InaccessibleMessage", "InlineKeyboardButton", "InlineKeyboardMarkup", "InlineQuery", @@ -103,10 +141,12 @@ "InlineQueryResultLocation", "InlineQueryResultMpeg4Gif", "InlineQueryResultPhoto", - "InlineQueryResultsButton", "InlineQueryResultVenue", "InlineQueryResultVideo", "InlineQueryResultVoice", + "InlineQueryResultsButton", + "InputChecklist", + "InputChecklistTask", "InputContactMessageContent", "InputFile", "InputInvoiceMessageContent", @@ -118,18 +158,31 @@ "InputMediaPhoto", "InputMediaVideo", "InputMessageContent", + "InputPaidMedia", + "InputPaidMediaPhoto", + "InputPaidMediaVideo", + "InputPollOption", + "InputProfilePhoto", + "InputProfilePhotoAnimated", + "InputProfilePhotoStatic", "InputSticker", + "InputStoryContent", + "InputStoryContentPhoto", + "InputStoryContentVideo", "InputTextMessageContent", "InputVenueMessageContent", "Invoice", "KeyboardButton", "KeyboardButtonPollType", "KeyboardButtonRequestChat", - "KeyboardButtonRequestUser", + "KeyboardButtonRequestUsers", "LabeledPrice", + "LinkPreviewOptions", "Location", + "LocationAddress", "LoginUrl", "MaskPosition", + "MaybeInaccessibleMessage", "MenuButton", "MenuButtonCommands", "MenuButtonDefault", @@ -138,7 +191,25 @@ "MessageAutoDeleteTimerChanged", "MessageEntity", "MessageId", + "MessageOrigin", + "MessageOriginChannel", + "MessageOriginChat", + "MessageOriginHiddenUser", + "MessageOriginUser", + "MessageReactionCountUpdated", + "MessageReactionUpdated", "OrderInfo", + "OwnedGift", + "OwnedGiftRegular", + "OwnedGiftUnique", + "OwnedGifts", + "PaidMedia", + "PaidMediaInfo", + "PaidMediaPhoto", + "PaidMediaPreview", + "PaidMediaPurchased", + "PaidMediaVideo", + "PaidMessagePriceChanged", "PassportData", "PassportElementError", "PassportElementErrorDataField", @@ -157,26 +228,76 @@ "PollAnswer", "PollOption", "PreCheckoutQuery", + "PreparedInlineMessage", "ProximityAlertTriggered", + "ReactionCount", + "ReactionType", + "ReactionTypeCustomEmoji", + "ReactionTypeEmoji", + "ReactionTypePaid", + "RefundedPayment", "ReplyKeyboardMarkup", "ReplyKeyboardRemove", - "request", + "ReplyParameters", "ResidentialAddress", + "RevenueWithdrawalState", + "RevenueWithdrawalStateFailed", + "RevenueWithdrawalStatePending", + "RevenueWithdrawalStateSucceeded", "SecureData", "SecureValue", "SentWebAppMessage", + "SharedUser", "ShippingAddress", "ShippingOption", "ShippingQuery", + "StarAmount", + "StarTransaction", + "StarTransactions", "Sticker", "StickerSet", + "Story", + "StoryArea", + "StoryAreaPosition", + "StoryAreaType", + "StoryAreaTypeLink", + "StoryAreaTypeLocation", + "StoryAreaTypeSuggestedReaction", + "StoryAreaTypeUniqueGift", + "StoryAreaTypeWeather", "SuccessfulPayment", + "SuggestedPostApprovalFailed", + "SuggestedPostApproved", + "SuggestedPostDeclined", + "SuggestedPostInfo", + "SuggestedPostPaid", + "SuggestedPostParameters", + "SuggestedPostPrice", + "SuggestedPostRefunded", "SwitchInlineQueryChosenChat", "TelegramObject", + "TextQuote", + "TransactionPartner", + "TransactionPartnerAffiliateProgram", + "TransactionPartnerChat", + "TransactionPartnerFragment", + "TransactionPartnerOther", + "TransactionPartnerTelegramAds", + "TransactionPartnerTelegramApi", + "TransactionPartnerUser", + "UniqueGift", + "UniqueGiftBackdrop", + "UniqueGiftBackdropColors", + "UniqueGiftColors", + "UniqueGiftInfo", + "UniqueGiftModel", + "UniqueGiftSymbol", "Update", "User", + "UserChatBoosts", "UserProfilePhotos", - "UserShared", + "UserRating", + "UsersShared", "Venue", "Video", "VideoChatEnded", @@ -185,15 +306,37 @@ "VideoChatStarted", "VideoNote", "Voice", - "warnings", "WebAppData", "WebAppInfo", "WebhookInfo", "WriteAccessAllowed", + "__bot_api_version__", + "__bot_api_version_info__", + "__version__", + "__version_info__", + "constants", + "error", + "helpers", + "request", + "warnings", ) +from telegram._inputchecklist import InputChecklist, InputChecklistTask +from telegram._payment.stars.staramount import StarAmount +from telegram._payment.stars.startransactions import StarTransaction, StarTransactions +from telegram._payment.stars.transactionpartner import ( + TransactionPartner, + TransactionPartnerAffiliateProgram, + TransactionPartnerChat, + TransactionPartnerFragment, + TransactionPartnerOther, + TransactionPartnerTelegramAds, + TransactionPartnerTelegramApi, + TransactionPartnerUser, +) from . import _version, constants, error, helpers, request, warnings +from ._birthdate import Birthdate from ._bot import Bot from ._botcommand import BotCommand from ._botcommandscope import ( @@ -208,9 +351,42 @@ ) from ._botdescription import BotDescription, BotShortDescription from ._botname import BotName +from ._business import ( + BusinessBotRights, + BusinessConnection, + BusinessIntro, + BusinessLocation, + BusinessMessagesDeleted, + BusinessOpeningHours, + BusinessOpeningHoursInterval, +) from ._callbackquery import CallbackQuery from ._chat import Chat from ._chatadministratorrights import ChatAdministratorRights +from ._chatbackground import ( + BackgroundFill, + BackgroundFillFreeformGradient, + BackgroundFillGradient, + BackgroundFillSolid, + BackgroundType, + BackgroundTypeChatTheme, + BackgroundTypeFill, + BackgroundTypePattern, + BackgroundTypeWallpaper, + ChatBackground, +) +from ._chatboost import ( + ChatBoost, + ChatBoostAdded, + ChatBoostRemoved, + ChatBoostSource, + ChatBoostSourceGiftCode, + ChatBoostSourceGiveaway, + ChatBoostSourcePremium, + ChatBoostUpdated, + UserChatBoosts, +) +from ._chatfullinfo import ChatFullInfo from ._chatinvitelink import ChatInviteLink from ._chatjoinrequest import ChatJoinRequest from ._chatlocation import ChatLocation @@ -225,8 +401,17 @@ ) from ._chatmemberupdated import ChatMemberUpdated from ._chatpermissions import ChatPermissions +from ._checklists import Checklist, ChecklistTask, ChecklistTasksAdded, ChecklistTasksDone from ._choseninlineresult import ChosenInlineResult +from ._copytextbutton import CopyTextButton from ._dice import Dice +from ._directmessagepricechanged import DirectMessagePriceChanged +from ._directmessagestopic import DirectMessagesTopic +from ._files._inputstorycontent import ( + InputStoryContent, + InputStoryContentPhoto, + InputStoryContentVideo, +) from ._files.animation import Animation from ._files.audio import Audio from ._files.chatphoto import ChatPhoto @@ -241,6 +426,14 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPaidMedia, + InputPaidMediaPhoto, + InputPaidMediaVideo, +) +from ._files.inputprofilephoto import ( + InputProfilePhoto, + InputProfilePhotoAnimated, + InputProfilePhotoStatic, ) from ._files.inputsticker import InputSticker from ._files.location import Location @@ -263,6 +456,8 @@ from ._games.callbackgame import CallbackGame from ._games.game import Game from ._games.gamehighscore import GameHighScore +from ._gifts import AcceptedGiftTypes, Gift, GiftBackground, GiftInfo, Gifts +from ._giveaway import Giveaway, GiveawayCompleted, GiveawayCreated, GiveawayWinners from ._inline.inlinekeyboardbutton import InlineKeyboardButton from ._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from ._inline.inlinequery import InlineQuery @@ -294,15 +489,35 @@ from ._inline.inputmessagecontent import InputMessageContent from ._inline.inputtextmessagecontent import InputTextMessageContent from ._inline.inputvenuemessagecontent import InputVenueMessageContent +from ._inline.preparedinlinemessage import PreparedInlineMessage from ._keyboardbutton import KeyboardButton from ._keyboardbuttonpolltype import KeyboardButtonPollType -from ._keyboardbuttonrequest import KeyboardButtonRequestChat, KeyboardButtonRequestUser +from ._keyboardbuttonrequest import KeyboardButtonRequestChat, KeyboardButtonRequestUsers +from ._linkpreviewoptions import LinkPreviewOptions from ._loginurl import LoginUrl from ._menubutton import MenuButton, MenuButtonCommands, MenuButtonDefault, MenuButtonWebApp -from ._message import Message +from ._message import InaccessibleMessage, MaybeInaccessibleMessage, Message from ._messageautodeletetimerchanged import MessageAutoDeleteTimerChanged from ._messageentity import MessageEntity from ._messageid import MessageId +from ._messageorigin import ( + MessageOrigin, + MessageOriginChannel, + MessageOriginChat, + MessageOriginHiddenUser, + MessageOriginUser, +) +from ._messagereactionupdated import MessageReactionCountUpdated, MessageReactionUpdated +from ._ownedgift import OwnedGift, OwnedGiftRegular, OwnedGifts, OwnedGiftUnique +from ._paidmedia import ( + PaidMedia, + PaidMediaInfo, + PaidMediaPhoto, + PaidMediaPreview, + PaidMediaPurchased, + PaidMediaVideo, +) +from ._paidmessagepricechanged import PaidMessagePriceChanged from ._passport.credentials import ( Credentials, DataCredentials, @@ -331,21 +546,69 @@ 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.affiliateinfo import AffiliateInfo +from ._payment.stars.revenuewithdrawalstate import ( + RevenueWithdrawalState, + RevenueWithdrawalStateFailed, + RevenueWithdrawalStatePending, + RevenueWithdrawalStateSucceeded, +) from ._payment.successfulpayment import SuccessfulPayment -from ._poll import Poll, PollAnswer, PollOption +from ._poll import InputPollOption, Poll, PollAnswer, PollOption from ._proximityalerttriggered import ProximityAlertTriggered +from ._reaction import ( + ReactionCount, + ReactionType, + ReactionTypeCustomEmoji, + ReactionTypeEmoji, + ReactionTypePaid, +) +from ._reply import ExternalReplyInfo, ReplyParameters, TextQuote from ._replykeyboardmarkup import ReplyKeyboardMarkup from ._replykeyboardremove import ReplyKeyboardRemove from ._sentwebappmessage import SentWebAppMessage -from ._shared import ChatShared, UserShared +from ._shared import ChatShared, SharedUser, UsersShared +from ._story import Story +from ._storyarea import ( + LocationAddress, + StoryArea, + StoryAreaPosition, + StoryAreaType, + StoryAreaTypeLink, + StoryAreaTypeLocation, + StoryAreaTypeSuggestedReaction, + StoryAreaTypeUniqueGift, + StoryAreaTypeWeather, +) +from ._suggestedpost import ( + SuggestedPostApprovalFailed, + SuggestedPostApproved, + SuggestedPostDeclined, + SuggestedPostInfo, + SuggestedPostPaid, + SuggestedPostParameters, + SuggestedPostPrice, + SuggestedPostRefunded, +) from ._switchinlinequerychosenchat import SwitchInlineQueryChosenChat from ._telegramobject import TelegramObject +from ._uniquegift import ( + UniqueGift, + UniqueGiftBackdrop, + UniqueGiftBackdropColors, + UniqueGiftColors, + UniqueGiftInfo, + UniqueGiftModel, + UniqueGiftSymbol, +) from ._update import Update from ._user import User from ._userprofilephotos import UserProfilePhotos +from ._userrating import UserRating from ._videochat import ( VideoChatEnded, VideoChatParticipantsInvited, @@ -374,8 +637,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__ +__bot_api_version_info__: constants._BotAPIVersion = constants.BOT_API_VERSION_INFO diff --git a/telegram/__main__.py b/src/telegram/__main__.py similarity index 87% rename from telegram/__main__.py rename to src/telegram/__main__.py index bff34c90bf8..2b324fe8a2c 100644 --- a/telegram/__main__.py +++ b/src/telegram/__main__.py @@ -1,7 +1,7 @@ # !/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,17 +17,17 @@ # 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, D100, S607 import subprocess import sys -from typing import Optional from . import __version__ as telegram_ver from .constants import BOT_API_VERSION -def _git_revision() -> Optional[str]: +def _git_revision() -> str | None: try: - output = subprocess.check_output( # skipcq: BAN-B607 + output = subprocess.check_output( ["git", "describe", "--long", "--tags"], stderr=subprocess.STDOUT ) except (subprocess.SubprocessError, OSError): @@ -35,7 +35,7 @@ def _git_revision() -> Optional[str]: return output.decode().strip() -def print_ver_info() -> None: # skipcq: PY-D0003 +def print_ver_info() -> None: """Prints version information for python-telegram-bot, the Bot API and Python.""" git_revision = _git_revision() print(f"python-telegram-bot {telegram_ver}" + (f" ({git_revision})" if git_revision else "")) @@ -44,7 +44,7 @@ def print_ver_info() -> None: # skipcq: PY-D0003 print(f"Python {sys_version}") -def main() -> None: # skipcq: PY-D0003 +def main() -> None: """Prints version information for python-telegram-bot, the Bot API and Python.""" print_ver_info() diff --git a/src/telegram/_birthdate.py b/src/telegram/_birthdate.py new file mode 100644 index 00000000000..73e6c3c2d32 --- /dev/null +++ b/src/telegram/_birthdate.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 Birthday.""" + +import datetime as dtm + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class Birthdate(TelegramObject): + """ + This object describes the birthdate of a user. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`day`, and :attr:`month` are equal. + + .. versionadded:: 21.1 + + Args: + day (:obj:`int`): Day of the user's birth; 1-31. + month (:obj:`int`): Month of the user's birth; 1-12. + year (:obj:`int`, optional): Year of the user's birth. + + Attributes: + day (:obj:`int`): Day of the user's birth; 1-31. + month (:obj:`int`): Month of the user's birth; 1-12. + year (:obj:`int`): Optional. Year of the user's birth. + + """ + + __slots__ = ("day", "month", "year") + + def __init__( + self, + day: int, + month: int, + year: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + + # Required + self.day: int = day + self.month: int = month + # Optional + self.year: int | None = year + + self._id_attrs = ( + self.day, + self.month, + ) + + self._freeze() + + def to_date(self, year: int | None = None) -> dtm.date: + """Return the birthdate as a date object. + + .. versionchanged:: 21.2 + Now returns a :obj:`datetime.date` object instead of a :obj:`datetime.datetime` object, + as was originally intended. + + Args: + year (:obj:`int`, optional): The year to use. Required, if the :attr:`year` was not + present. + + Returns: + :obj:`datetime.date`: The birthdate as a date object. + """ + if self.year is None and year is None: + raise ValueError( + "The `year` argument is required if the `year` attribute was not present." + ) + + return dtm.date(year or self.year, self.month, self.day) # type: ignore[arg-type] diff --git a/telegram/_bot.py b/src/telegram/_bot.py similarity index 55% rename from telegram/_bot.py rename to src/telegram/_bot.py index a6b46600269..f0a8cfd6aa8 100644 --- a/telegram/_bot.py +++ b/src/telegram/_bot.py @@ -1,8 +1,8 @@ #!/usr/bin/env python -# pylint: disable=no-self-argument, not-callable, no-member, too-many-arguments +# pylint: disable=too-many-arguments # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,27 +18,19 @@ # 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 Bot.""" + import asyncio import contextlib import copy -import functools +import datetime as dtm import pickle -from datetime import datetime +from collections.abc import Callable, Sequence from types import TracebackType from typing import ( TYPE_CHECKING, Any, - AsyncContextManager, - Callable, - Dict, - List, NoReturn, - Optional, - Sequence, - Tuple, - Type, TypeVar, - Union, cast, no_type_check, ) @@ -57,8 +49,10 @@ from telegram._botcommandscope import BotCommandScope from telegram._botdescription import BotDescription, BotShortDescription from telegram._botname import BotName -from telegram._chat import Chat +from telegram._business import BusinessConnection from telegram._chatadministratorrights import ChatAdministratorRights +from telegram._chatboost import UserChatBoosts +from telegram._chatfullinfo import ChatFullInfo from telegram._chatinvitelink import ChatInviteLink from telegram._chatmember import ChatMember from telegram._chatpermissions import ChatPermissions @@ -68,8 +62,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.inputsticker import InputSticker +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 @@ -79,29 +72,44 @@ from telegram._files.voice import Voice from telegram._forumtopic import ForumTopic from telegram._games.gamehighscore import GameHighScore -from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup +from telegram._gifts import AcceptedGiftTypes, Gift, Gifts from telegram._inline.inlinequeryresultsbutton import InlineQueryResultsButton +from telegram._inline.preparedinlinemessage import PreparedInlineMessage +from telegram._inputchecklist import InputChecklist from telegram._menubutton import MenuButton from telegram._message import Message from telegram._messageid import MessageId -from telegram._passport.passportelementerrors import PassportElementError -from telegram._payment.shippingoption import ShippingOption -from telegram._poll import Poll +from telegram._ownedgift import OwnedGifts +from telegram._payment.stars.staramount import StarAmount +from telegram._payment.stars.startransactions import StarTransactions +from telegram._poll import InputPollOption, Poll +from telegram._reaction import ReactionType, ReactionTypeCustomEmoji, ReactionTypeEmoji +from telegram._reply import ReplyParameters from telegram._sentwebappmessage import SentWebAppMessage +from telegram._story import Story from telegram._telegramobject import TelegramObject from telegram._update import Update from telegram._user import User from telegram._userprofilephotos import UserProfilePhotos -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_lpo_and_dwpp, parse_sequence_arg from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue from telegram._utils.files import is_local_file, parse_file_input from telegram._utils.logging import get_logger -from telegram._utils.types import DVInput, FileInput, JSONDict, ODVInput, ReplyMarkup +from telegram._utils.repr import build_repr_with_selected_attrs +from telegram._utils.strings import to_camel_case +from telegram._utils.types import ( + BaseUrl, + CorrectOptionID, + FileInput, + JSONDict, + ODVInput, + TimePeriod, +) from telegram._utils.warnings import warn -from telegram._utils.warnings_transition import warn_about_thumb_return_thumbnail +from telegram._utils.warnings_transition import build_deprecation_warning_message from telegram._webhookinfo import WebhookInfo -from telegram.constants import InlineQueryLimit -from telegram.error import InvalidToken +from telegram.constants import InlineQueryLimit, ReactionEmoji +from telegram.error import EndPointNotFound, InvalidToken from telegram.request import BaseRequest, RequestData from telegram.request._httpxrequest import HTTPXRequest from telegram.request._requestparameter import RequestParameter @@ -109,20 +117,59 @@ if TYPE_CHECKING: from telegram import ( + InlineKeyboardMarkup, InlineQueryResult, InputFile, InputMediaAudio, InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputProfilePhoto, + InputSticker, + InputStoryContent, LabeledPrice, + LinkPreviewOptions, MessageEntity, + PassportElementError, + ShippingOption, + StoryArea, + SuggestedPostParameters, ) + from telegram._utils.types import ReplyMarkup BT = TypeVar("BT", bound="Bot") -class Bot(TelegramObject, AsyncContextManager["Bot"]): +# Even though we document only {token} as supported insertion, we are a bit more flexible +# internally and support additional variants. At the very least, we don't want the insertion +# to be case sensitive. +_SUPPORTED_INSERTIONS = {"token", "TOKEN", "bot_token", "BOT_TOKEN", "bot-token", "BOT-TOKEN"} +_INSERTION_STRINGS = {f"{{{insertion}}}" for insertion in _SUPPORTED_INSERTIONS} + + +class _TokenDict(dict): + __slots__ = ("token",) + + # small helper to make .format_map work without knowing which exact insertion name is used + def __init__(self, token: str): + self.token = token + super().__init__() + + def __missing__(self, key: str) -> str: + if key in _SUPPORTED_INSERTIONS: + return self.token + raise KeyError(f"Base URL string contains unsupported insertion: {key}") + + +def _parse_base_url(value: BaseUrl, token: str) -> str: + if callable(value): + return value(token) + if any(insertion in value for insertion in _INSERTION_STRINGS): + return value.format_map(_TokenDict(token)) + return value + token + + +class Bot(TelegramObject, contextlib.AbstractAsyncContextManager["Bot"]): """This object represents a Telegram Bot. Instances of this class can be used as asyncio context managers, where @@ -142,11 +189,13 @@ class Bot(TelegramObject, AsyncContextManager["Bot"]): finally: await bot.shutdown() + .. seealso:: :meth:`__aenter__` and :meth:`__aexit__`. + Note: * Most bot methods have the argument ``api_kwargs`` which allows passing arbitrary keywords to the Telegram API. This can be used to access new features of the API before they are - incorporated into PTB. However, this is not guaranteed to work, i.e. it will fail for - passing files. + incorporated into PTB. The limitations to this argument are the same as the ones + described in :meth:`do_api_request`. * Bots should not be serialized since if you for e.g. change the bots token, then your serialized instance will not reflect that change. Trying to pickle a bot instance will raise :exc:`pickle.PicklingError`. Trying to deepcopy a bot instance will raise @@ -181,10 +230,46 @@ class Bot(TelegramObject, AsyncContextManager["Bot"]): the file path will be passed in the `file URI scheme `_. + .. versionchanged:: 20.5 + Removed deprecated methods ``set_sticker_set_thumb`` and ``setStickerSetThumb``. + Use :meth:`set_sticker_set_thumbnail` and :meth:`setStickerSetThumbnail` instead. + Args: token (:obj:`str`): Bot's unique authentication token. - base_url (:obj:`str`, optional): Telegram Bot API service URL. + base_url (:obj:`str` | Callable[[:obj:`str`], :obj:`str`], optional): Telegram Bot API + service URL. If the string contains ``{token}``, it will be replaced with the bot's + token. If a callable is passed, it will be called with the bot's token as the only + argument and must return the base URL. Otherwise, the token will be appended to the + string. Defaults to ``"https://api.telegram.org/bot"``. + + Tip: + Customizing the base URL can be used to run a bot against + :wiki:`Local Bot API Server ` or using Telegrams + `test environment \ + `_. + + Example: + ``"https://api.telegram.org/bot{token}/test"`` + + .. versionchanged:: 21.11 + Supports callable input and string formatting. base_file_url (:obj:`str`, optional): Telegram Bot API file URL. + If the string contains ``{token}``, it will be replaced with the bot's + token. If a callable is passed, it will be called with the bot's token as the only + argument and must return the base URL. Otherwise, the token will be appended to the + string. Defaults to ``"https://api.telegram.org/bot"``. + + Tip: + Customizing the base URL can be used to run a bot against + :wiki:`Local Bot API Server ` or using Telegrams + `test environment \ + `_. + + Example: + ``"https://api.telegram.org/file/bot{token}/test"`` + + .. versionchanged:: 21.11 + Supports callable input and string formatting. request (:class:`telegram.request.BaseRequest`, optional): Pre initialized :class:`telegram.request.BaseRequest` instances. Will be used for all bot methods *except* for :meth:`get_updates`. If not passed, an instance of @@ -206,6 +291,9 @@ class Bot(TelegramObject, AsyncContextManager["Bot"]): .. include:: inclusions/bot_methods.rst + .. |removed_thumb_arg| replace:: Removed deprecated argument ``thumb``. Use + ``thumbnail`` instead. + """ # This is a class variable since we want to override the logger name in ExtBot @@ -213,25 +301,26 @@ class Bot(TelegramObject, AsyncContextManager["Bot"]): _LOGGER = get_logger(__name__) __slots__ = ( - "_token", - "_base_url", "_base_file_url", - "_private_key", + "_base_url", + "_bot_initialized", "_bot_user", - "_request", - "_initialized", "_local_mode", + "_private_key", + "_request", + "_requests_initialized", + "_token", ) def __init__( self, token: str, - base_url: str = "https://api.telegram.org/bot", - base_file_url: str = "https://api.telegram.org/file/bot", - request: Optional[BaseRequest] = None, - get_updates_request: Optional[BaseRequest] = None, - private_key: Optional[bytes] = None, - private_key_password: Optional[bytes] = None, + base_url: BaseUrl = "https://api.telegram.org/bot", + base_file_url: BaseUrl = "https://api.telegram.org/file/bot", + request: BaseRequest | None = None, + get_updates_request: BaseRequest | None = None, + private_key: bytes | None = None, + private_key_password: bytes | None = None, local_mode: bool = False, ): super().__init__(api_kwargs=None) @@ -239,19 +328,27 @@ def __init__( raise InvalidToken("You must pass the token you received from https://t.me/Botfather!") self._token: str = token - self._base_url: str = base_url + self._token - self._base_file_url: str = base_file_url + self._token - self._local_mode: bool = local_mode - self._bot_user: Optional[User] = None - self._private_key: Optional[bytes] = None - self._initialized: bool = False + self._base_url: str = _parse_base_url(base_url, self._token) + self._base_file_url: str = _parse_base_url(base_file_url, self._token) + self._LOGGER.debug("Set Bot API URL: %s", self._base_url) + self._LOGGER.debug("Set Bot API File URL: %s", self._base_file_url) - self._request: Tuple[BaseRequest, BaseRequest] = ( - HTTPXRequest() if get_updates_request is None else get_updates_request, + self._local_mode: bool = local_mode + self._bot_user: User | None = None + self._private_key: bytes | None = None + self._requests_initialized: bool = False + self._bot_initialized: bool = False + + self._request: tuple[BaseRequest, BaseRequest] = ( + ( + HTTPXRequest(connection_pool_size=1) + if get_updates_request is None + else get_updates_request + ), HTTPXRequest() if request is None else request, ) - # this section is about issuing a warning when using HTTP/2 and connect to a self hosted + # this section is about issuing a warning when using HTTP/2 and connect to a self-hosted # bot api instance, which currently only supports HTTP/1.1. Checking if a custom base url # is set is the best way to do that. @@ -260,14 +357,14 @@ def __init__( if ( isinstance(self._request[0], HTTPXRequest) and self._request[0].http_version == "2" - and not base_url.startswith("https://api.telegram.org/bot") + and not self.base_url.startswith("https://api.telegram.org/bot") ): warning_string = "get_updates_request" if ( isinstance(self._request[1], HTTPXRequest) and self._request[1].http_version == "2" - and not base_url.startswith("https://api.telegram.org/bot") + and not self.base_url.startswith("https://api.telegram.org/bot") ): if warning_string: warning_string += " and request" @@ -277,8 +374,8 @@ def __init__( if warning_string: self._warn( f"You set the HTTP version for the {warning_string} HTTPXRequest instance to " - f"HTTP/2. The self hosted bot api instances only support HTTP/1.1. You should " - f"either run a HTTP proxy in front of it which supports HTTP/2 or use HTTP/1.1.", + "HTTP/2. The self hosted bot api instances only support HTTP/1.1. You should " + "either run a HTTP proxy in front of it which supports HTTP/2 or use HTTP/1.1.", PTBUserWarning, stacklevel=2, ) @@ -287,7 +384,7 @@ def __init__( if not CRYPTO_INSTALLED: raise RuntimeError( "To use Telegram Passports, PTB must be installed via `pip install " - "python-telegram-bot[passport]`." + '"python-telegram-bot[passport]"`.' ) self._private_key = serialization.load_pem_private_key( private_key, password=private_key_password, backend=default_backend() @@ -295,6 +392,86 @@ def __init__( self._freeze() + async def __aenter__(self: BT) -> BT: + """ + |async_context_manager| :meth:`initializes ` the Bot. + + Returns: + The initialized Bot instance. + + Raises: + :exc:`Exception`: If an exception is raised during initialization, :meth:`shutdown` + is called in this case. + """ + try: + await self.initialize() + except Exception: + await self.shutdown() + raise + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """|async_context_manager| :meth:`shuts down ` the Bot.""" + # Make sure not to return `True` so that exceptions are not suppressed + # https://docs.python.org/3/reference/datamodel.html?#object.__aexit__ + await self.shutdown() + + def __reduce__(self) -> NoReturn: + """Customizes how :func:`copy.deepcopy` processes objects of this type. Bots can not + be pickled and this method will always raise an exception. + + .. versionadded:: 20.0 + + Raises: + :exc:`pickle.PicklingError` + """ + raise pickle.PicklingError("Bot objects cannot be pickled!") + + def __deepcopy__(self, memodict: dict[int, object]) -> NoReturn: + """Customizes how :func:`copy.deepcopy` processes objects of this type. Bots can not + be deepcopied and this method will always raise an exception. + + .. versionadded:: 20.0 + + Raises: + :exc:`TypeError` + """ + raise TypeError("Bot objects cannot be deepcopied!") + + def __eq__(self, other: object) -> bool: + """Defines equality condition for the :class:`telegram.Bot` object. + Two objects of this class are considered to be equal if their attributes + :attr:`bot` are equal. + + Returns: + :obj:`True` if both attributes :attr:`bot` are equal. :obj:`False` otherwise. + """ + if isinstance(other, Bot): + return self.bot == other.bot + return super().__eq__(other) + + def __hash__(self) -> int: + """See :meth:`telegram.TelegramObject.__hash__`""" + if self._bot_user is None: + return super().__hash__() + return hash((self.bot, Bot)) + + def __repr__(self) -> str: + """Give a string representation of the bot in the form ``Bot[token=...]``. + + As this class doesn't implement :meth:`object.__str__`, the default implementation + will be used, which is equivalent to :meth:`__repr__`. + + Returns: + :obj:`str` + """ + return build_repr_with_selected_attrs(self, token=self.token) + @property def token(self) -> str: """:obj:`str`: Bot's unique authentication token. @@ -333,65 +510,119 @@ def local_mode(self) -> bool: # 1. cryptography doesn't have a nice base class, so it would get lengthy # 2. we can't import cryptography if it's not installed @property - def private_key(self) -> Optional[Any]: + def private_key(self) -> Any | None: """Deserialized private key for decryption of telegram passport data. .. versionadded:: 20.0 """ return self._private_key - @classmethod - def _warn( - cls, message: str, category: Type[Warning] = PTBUserWarning, stacklevel: int = 0 - ) -> None: - """Convenience method to issue a warning. This method is here mostly to make it easier - for ExtBot to add 1 level to all warning calls. + @property + def request(self) -> BaseRequest: + """The :class:`~telegram.request.BaseRequest` object used by this bot. + + Warning: + Requests to the Bot API are made by the various methods of this class. This attribute + should *not* be used manually. """ - warn(message=message, category=category, stacklevel=stacklevel + 1) + return self._request[1] - def __reduce__(self) -> NoReturn: - """Customizes how :func:`copy.deepcopy` processes objects of this type. Bots can not - be pickled and this method will always raise an exception. + @property + def bot(self) -> User: + """:class:`telegram.User`: User instance for the bot as returned by :meth:`get_me`. - .. versionadded:: 20.0 + Warning: + This value is the cached return value of :meth:`get_me`. If the bots profile is + changed during runtime, this value won't reflect the changes until :meth:`get_me` is + called again. - Raises: - :exc:`pickle.PicklingError` + .. seealso:: :meth:`initialize` """ - raise pickle.PicklingError("Bot objects cannot be pickled!") + if self._bot_user is None: + raise RuntimeError( + f"{self.__class__.__name__} is not properly initialized. Call " + f"`{self.__class__.__name__}.initialize` before accessing this property." + ) + return self._bot_user - def __deepcopy__(self, memodict: Dict[int, object]) -> NoReturn: - """Customizes how :func:`copy.deepcopy` processes objects of this type. Bots can not - be deepcopied and this method will always raise an exception. + @property + def id(self) -> int: + """:obj:`int`: Unique identifier for this bot. Shortcut for the corresponding attribute of + :attr:`bot`. + """ + return self.bot.id - .. versionadded:: 20.0 + @property + def first_name(self) -> str: + """:obj:`str`: Bot's first name. Shortcut for the corresponding attribute of + :attr:`bot`. + """ + return self.bot.first_name - Raises: - :exc:`TypeError` + @property + def last_name(self) -> str: + """:obj:`str`: Optional. Bot's last name. Shortcut for the corresponding attribute of + :attr:`bot`. """ - raise TypeError("Bot objects cannot be deepcopied!") + return self.bot.last_name # type: ignore - # TODO: After https://youtrack.jetbrains.com/issue/PY-50952 is fixed, we can revisit this and - # consider adding Paramspec from typing_extensions to properly fix this. Currently a workaround - def _log(func: Any): # type: ignore[no-untyped-def] # skipcq: PY-D0003 - @functools.wraps(func) - async def decorator(self: "Bot", *args: Any, **kwargs: Any) -> Any: - # pylint: disable=protected-access - self._LOGGER.debug("Entering: %s", func.__name__) - result = await func(self, *args, **kwargs) # skipcq: PYL-E1102 - self._LOGGER.debug(result) - self._LOGGER.debug("Exiting: %s", func.__name__) - return result + @property + def username(self) -> str: + """:obj:`str`: Bot's username. Shortcut for the corresponding attribute of + :attr:`bot`. + """ + return self.bot.username # type: ignore + + @property + def link(self) -> str: + """:obj:`str`: Convenience property. Returns the t.me link of the bot.""" + return f"https://t.me/{self.username}" + + @property + def can_join_groups(self) -> bool: + """:obj:`bool`: Bot's :attr:`telegram.User.can_join_groups` attribute. Shortcut for the + corresponding attribute of :attr:`bot`. + """ + return self.bot.can_join_groups # type: ignore + + @property + def can_read_all_group_messages(self) -> bool: + """:obj:`bool`: Bot's :attr:`telegram.User.can_read_all_group_messages` attribute. + Shortcut for the corresponding attribute of :attr:`bot`. + """ + return self.bot.can_read_all_group_messages # type: ignore - return decorator + @property + def supports_inline_queries(self) -> bool: + """:obj:`bool`: Bot's :attr:`telegram.User.supports_inline_queries` attribute. + Shortcut for the corresponding attribute of :attr:`bot`. + """ + return self.bot.supports_inline_queries # type: ignore + + @property + def name(self) -> str: + """:obj:`str`: Bot's @username. Shortcut for the corresponding attribute of :attr:`bot`.""" + return f"@{self.username}" + + @classmethod + def _warn( + cls, + message: str | PTBUserWarning, + category: type[Warning] = PTBUserWarning, + stacklevel: int = 0, + ) -> None: + """Convenience method to issue a warning. This method is here mostly to make it easier + for ExtBot to add 1 level to all warning calls. + """ + warn(message=message, category=category, stacklevel=stacklevel + 1) def _parse_file_input( self, - file_input: Union[FileInput, "TelegramObject"], - tg_type: Optional[Type["TelegramObject"]] = None, - filename: Optional[str] = None, + file_input: "FileInput| TelegramObject", + tg_type: type["TelegramObject"] | None = None, + filename: str | None = None, attach: bool = False, - ) -> Union[str, "InputFile", Any]: + ) -> "str| InputFile| Any": return parse_file_input( file_input=file_input, tg_type=tg_type, @@ -400,7 +631,7 @@ def _parse_file_input( local_mode=self._local_mode, ) - def _insert_defaults(self, data: Dict[str, object]) -> None: # skipcq: PYL-R0201 + def _insert_defaults(self, data: dict[str, object]) -> None: """This method is here to make ext.Defaults work. Because we need to be able to tell e.g. `send_message(chat_id, text)` from `send_message(chat_id, text, parse_mode=None)`, the default values for `parse_mode` etc are not `None` but `DEFAULT_NONE`. While this *could* @@ -428,13 +659,16 @@ def _insert_defaults(self, data: Dict[str, object]) -> None: # skipcq: PYL-R020 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: @@ -443,15 +677,15 @@ def _insert_defaults(self, data: Dict[str, object]) -> None: # skipcq: PYL-R020 async def _post( self, endpoint: str, - data: Optional[JSONDict] = None, + data: JSONDict | None = 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, + api_kwargs: JSONDict | None = None, ) -> Any: - # We know that the return type is Union[bool, JSONDict, List[JSONDict]], but it's hard + # We know that the return type is Union[bool, JSONDict, list[JSONDict]], but it's hard # to tell mypy which methods expects which of these return values and `Any` saves us a # lot of `type: ignore` comments if data is None: @@ -484,7 +718,7 @@ async def _do_post( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - ) -> Union[bool, JSONDict, List[JSONDict]]: + ) -> bool | JSONDict | list[JSONDict]: # This also converts datetimes into timestamps. # We don't do this earlier so that _insert_defaults (see above) has a chance to convert # to the default timezone in case this is called by ExtBot @@ -494,7 +728,8 @@ async def _do_post( request = self._request[0] if endpoint == "getUpdates" else self._request[1] - return await request.post( + self._LOGGER.debug("Calling Bot API endpoint `%s` with parameters `%s`", endpoint, data) + result = await request.post( url=f"{self._base_url}/{endpoint}", request_data=request_data, read_timeout=read_timeout, @@ -502,27 +737,38 @@ async def _do_post( connect_timeout=connect_timeout, pool_timeout=pool_timeout, ) + self._LOGGER.debug( + "Call to Bot API endpoint `%s` finished with return value `%s`", endpoint, result + ) + + return result async def _send_message( self, endpoint: str, data: JSONDict, - reply_to_message_id: Optional[int] = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - caption: Optional[str] = None, + message_thread_id: int | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = 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, + api_kwargs: JSONDict | None = None, ) -> Any: """Protected method to send or edit messages of any type. @@ -534,27 +780,41 @@ async def _send_message( using `Any` instead saves us a lot of `type: ignore` comments """ # We don't check if (DEFAULT_)None here, so that _post is able to insert the defaults - # correctly, if necessary - data["disable_notification"] = disable_notification - data["allow_sending_without_reply"] = allow_sending_without_reply - data["protect_content"] = protect_content - data["parse_mode"] = parse_mode - data["disable_web_page_preview"] = disable_web_page_preview - - if reply_to_message_id is not None: - data["reply_to_message_id"] = reply_to_message_id - - if reply_markup is not None: - data["reply_markup"] = reply_markup + # correctly, if necessary: + if allow_sending_without_reply is not DEFAULT_NONE and reply_parameters is not None: + raise ValueError( + "`allow_sending_without_reply` and `reply_parameters` are mutually exclusive." + ) - if message_thread_id is not None: - data["message_thread_id"] = message_thread_id + if reply_to_message_id is not None and reply_parameters is not None: + raise ValueError( + "`reply_to_message_id` and `reply_parameters` are mutually exclusive." + ) - if caption is not None: - data["caption"] = caption + if reply_to_message_id is not None: + reply_parameters = ReplyParameters( + message_id=reply_to_message_id, + allow_sending_without_reply=allow_sending_without_reply, + ) - if caption_entities is not None: - data["caption_entities"] = caption_entities + data.update( + { + "allow_paid_broadcast": allow_paid_broadcast, + "business_connection_id": business_connection_id, + "caption": caption, + "caption_entities": caption_entities, + "direct_messages_topic_id": direct_messages_topic_id, + "disable_notification": disable_notification, + "link_preview_options": link_preview_options, + "message_thread_id": message_thread_id, + "message_effect_id": message_effect_id, + "parse_mode": parse_mode, + "protect_content": protect_content, + "reply_markup": reply_markup, + "reply_parameters": reply_parameters, + "suggested_post_parameters": suggested_post_parameters, + } + ) result = await self._post( endpoint, @@ -580,18 +840,23 @@ async def initialize(self) -> None: .. versionadded:: 20.0 """ - if self._initialized: + if self._requests_initialized and self._bot_initialized: self._LOGGER.debug("This Bot is already initialized.") return - await asyncio.gather(self._request[0].initialize(), self._request[1].initialize()) + # Initialize request objects if not already done + if not self._requests_initialized: + await asyncio.gather(self._request[0].initialize(), self._request[1].initialize()) + self._requests_initialized = True + + # Initialize bot user # Since the bot is to be initialized only once, we can also use it for # verifying the token passed and raising an exception if it's invalid. try: await self.get_me() + self._bot_initialized = True except InvalidToken as exc: raise InvalidToken(f"The token `{self._token}` was rejected by the server.") from exc - self._initialized = True async def shutdown(self) -> None: """Stop & clear resources used by this class. Currently just calls @@ -601,119 +866,107 @@ async def shutdown(self) -> None: .. versionadded:: 20.0 """ - if not self._initialized: + if not self._requests_initialized: self._LOGGER.debug("This Bot is already shut down. Returning.") return await asyncio.gather(self._request[0].shutdown(), self._request[1].shutdown()) - self._initialized = False - - async def __aenter__(self: BT) -> BT: - try: - await self.initialize() - return self - except Exception as exc: - await self.shutdown() - raise exc + self._requests_initialized = False + self._bot_initialized = False - async def __aexit__( + async def do_api_request( self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> None: - # Make sure not to return `True` so that exceptions are not suppressed - # https://docs.python.org/3/reference/datamodel.html?#object.__aexit__ - await self.shutdown() + endpoint: str, + api_kwargs: JSONDict | None = None, + return_type: type[TelegramObject] | None = 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, + ) -> Any: + """Do a request to the Telegram API. - @property - def request(self) -> BaseRequest: - """The :class:`~telegram.request.BaseRequest` object used by this bot. + This method is here to make it easier to use new API methods that are not yet supported + by this library. - Warning: - Requests to the Bot API are made by the various methods of this class. This attribute - should *not* be used manually. - """ - return self._request[1] + Hint: + Since PTB does not know which arguments are passed to this method, some caution is + necessary in terms of PTBs utility functionalities. In particular - @property - def bot(self) -> User: - """:class:`telegram.User`: User instance for the bot as returned by :meth:`get_me`. + * passing objects of any class defined in the :mod:`telegram` module is supported + * when uploading files, a :class:`telegram.InputFile` must be passed as the value for + the corresponding argument. Passing a file path or file-like object will not work. + File paths will work only in combination with :paramref:`~Bot.local_mode`. + * when uploading files, PTB can still correctly determine that + a special write timeout value should be used instead of the default + :paramref:`telegram.request.HTTPXRequest.write_timeout`. + * insertion of default values specified via :class:`telegram.ext.Defaults` will not + work (only relevant for :class:`telegram.ext.ExtBot`). + * The only exception is :class:`telegram.ext.Defaults.tzinfo`, which will be correctly + applied to :class:`datetime.datetime` objects. - Warning: - This value is the cached return value of :meth:`get_me`. If the bots profile is - changed during runtime, this value won't reflect the changes until :meth:`get_me` is - called again. + .. versionadded:: 20.8 - .. seealso:: :meth:`initialize` - """ - if self._bot_user is None: - raise RuntimeError( - f"{self.__class__.__name__} is not properly initialized. Call " - f"`{self.__class__.__name__}.initialize` before accessing this property." - ) - return self._bot_user + Args: + endpoint (:obj:`str`): The API endpoint to use, e.g. ``getMe`` or ``get_me``. + api_kwargs (:obj:`dict`, optional): The keyword arguments to pass to the API call. + If not specified, no arguments are passed. + return_type (:class:`telegram.TelegramObject`, optional): If specified, the result of + the API call will be deserialized into an instance of this class or tuple of + instances of this class. If not specified, the raw result of the API call will be + returned. - @property - def id(self) -> int: # pylint: disable=invalid-name - """:obj:`int`: Unique identifier for this bot. Shortcut for the corresponding attribute of - :attr:`bot`. - """ - return self.bot.id + Returns: + The result of the API call. If :paramref:`return_type` is not specified, this is a + :obj:`dict` or :obj:`bool`, otherwise an instance of :paramref:`return_type` or a + tuple of :paramref:`return_type`. - @property - def first_name(self) -> str: - """:obj:`str`: Bot's first name. Shortcut for the corresponding attribute of - :attr:`bot`. + Raises: + :class:`telegram.error.TelegramError` """ - return self.bot.first_name + if hasattr(self, endpoint): + self._warn( + ( + f"Please use 'Bot.{endpoint}' instead of " + f"'Bot.do_api_request(\"{endpoint}\", ...)'" + ), + stacklevel=2, + ) - @property - def last_name(self) -> str: - """:obj:`str`: Optional. Bot's last name. Shortcut for the corresponding attribute of - :attr:`bot`. - """ - return self.bot.last_name # type: ignore - - @property - def username(self) -> str: - """:obj:`str`: Bot's username. Shortcut for the corresponding attribute of - :attr:`bot`. - """ - return self.bot.username # type: ignore - - @property - def link(self) -> str: - """:obj:`str`: Convenience property. Returns the t.me link of the bot.""" - return f"https://t.me/{self.username}" - - @property - def can_join_groups(self) -> bool: - """:obj:`bool`: Bot's :attr:`telegram.User.can_join_groups` attribute. Shortcut for the - corresponding attribute of :attr:`bot`. - """ - return self.bot.can_join_groups # type: ignore - - @property - def can_read_all_group_messages(self) -> bool: - """:obj:`bool`: Bot's :attr:`telegram.User.can_read_all_group_messages` attribute. - Shortcut for the corresponding attribute of :attr:`bot`. - """ - return self.bot.can_read_all_group_messages # type: ignore - - @property - def supports_inline_queries(self) -> bool: - """:obj:`bool`: Bot's :attr:`telegram.User.supports_inline_queries` attribute. - Shortcut for the corresponding attribute of :attr:`bot`. - """ - return self.bot.supports_inline_queries # type: ignore + camel_case_endpoint = to_camel_case(endpoint) + try: + result = await self._post( + camel_case_endpoint, + api_kwargs=api_kwargs, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + ) + except InvalidToken as exc: + # TG returns 404 Not found for + # 1) malformed tokens + # 2) correct tokens but non-existing method, e.g. api.tg.org/botTOKEN/unkonwnMethod + # 2) is relevant only for Bot.do_api_request, that's why we have special handling for + # that here rather than in BaseRequest._request_wrapper + if self._bot_initialized: + raise EndPointNotFound( + f"Endpoint '{camel_case_endpoint}' not found in Bot API" + ) from exc + + raise InvalidToken( + "Either the bot token was rejected by Telegram or the endpoint " + f"'{camel_case_endpoint}' does not exist." + ) from exc + + if return_type is None or isinstance(result, bool): + return result - @property - def name(self) -> str: - """:obj:`str`: Bot's @username. Shortcut for the corresponding attribute of :attr:`bot`.""" - return f"@{self.username}" + if isinstance(result, list): + return return_type.de_list(result, self) + return return_type.de_json(result, self) - @_log async def get_me( self, *, @@ -721,7 +974,7 @@ async def get_me( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> User: """A simple method for testing your bot's auth token. Requires no parameters. @@ -742,28 +995,34 @@ async def get_me( api_kwargs=api_kwargs, ) self._bot_user = User.de_json(result, self) - return self._bot_user # type: ignore[return-value] + return self._bot_user - @_log async def send_message( self, - chat_id: Union[int, str], + chat_id: int | str, text: str, parse_mode: ODVInput[str] = DEFAULT_NONE, - entities: Optional[Sequence["MessageEntity"]] = None, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, - disable_notification: DVInput[bool] = DEFAULT_NONE, + entities: Sequence["MessageEntity"] | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - message_thread_id: Optional[int] = None, + reply_markup: "ReplyMarkup | None" = None, + message_thread_id: int | None = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + disable_web_page_preview: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send text messages. @@ -779,14 +1038,17 @@ async def send_message( .. versionchanged:: 20.0 |sequenceargs| - disable_web_page_preview (:obj:`bool`, optional): Disables link previews for links in - this message. + link_preview_options (:obj:`LinkPreviewOptions`, optional): Link preview generation + options for the message. Mutually exclusive with + :paramref:`disable_web_page_preview`. + + .. versionadded:: 20.8 + disable_notification (:obj:`bool`, optional): |disable_notification| protect_content (:obj:`bool`, optional): |protect_content| .. versionadded:: 13.10 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): Additional interface options. An object for an inline keyboard, custom reply @@ -794,15 +1056,68 @@ async def send_message( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: 20.8 + business_connection_id (:obj:`str`, optional): |business_id_str| + + .. versionadded:: 21.1 + message_effect_id (:obj:`str`, optional): |message_effect_id| + + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 + + 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + disable_web_page_preview (:obj:`bool`, optional): Disables link previews for links in + this message. Convenience parameter for setting :paramref:`link_preview_options`. + Mutually exclusive with :paramref:`link_preview_options`. + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`link_preview_options` replacing this + argument. PTB will automatically convert this argument to that one, but + for advanced options, please use :paramref:`link_preview_options` directly. + + .. versionchanged:: 21.0 + |keyword_only_arg| Returns: :class:`telegram.Message`: On success, the sent message is returned. Raises: - :class:`telegram.error.TelegramError` + :exc:`ValueError`: If both :paramref:`disable_web_page_preview` and + :paramref:`link_preview_options` are passed. + :class:`telegram.error.TelegramError`: For other errors. """ data: JSONDict = {"chat_id": chat_id, "text": text, "entities": entities} + link_preview_options = parse_lpo_and_dwpp(disable_web_page_preview, link_preview_options) return await self._send_message( "sendMessage", @@ -813,8 +1128,14 @@ async def send_message( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + business_connection_id=business_connection_id, parse_mode=parse_mode, - disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, + reply_parameters=reply_parameters, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -822,17 +1143,16 @@ async def send_message( api_kwargs=api_kwargs, ) - @_log async def delete_message( self, - chat_id: Union[str, int], + chat_id: str | int, message_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to delete a message, including service messages, with the following @@ -881,21 +1201,129 @@ async def delete_message( api_kwargs=api_kwargs, ) - @_log + async def send_message_draft( + self, + chat_id: int, + draft_id: int, + text: str, + message_thread_id: int | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + entities: Sequence["MessageEntity"] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Use this method to stream a partial message to a user while the message is being + generated; supported only for bots with forum topic mode enabled. + + .. versionadded:: 22.6 + + Args: + chat_id (:obj:`int`): Unique identifier for the target private chat. + draft_id (:obj:`int`): Unique identifier of the message draft; must be non-zero. + Changes of drafts with the same identifier are animated. + text (:obj:`str`): Text of the message to be sent, + :tg-const:`telegram.constants.MessageLimit.MIN_TEXT_LENGTH`- + :tg-const:`telegram.constants.MessageLimit.MAX_TEXT_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`): |parse_mode| + entities (Sequence[:class:`telegram.MessageEntity`], optional): Sequence of special + entities that appear in message text, which can be specified instead of + :paramref:`parse_mode`. + + |sequenceargs| + message_thread_id (:obj:`int`, optional): Unique identifier for the target + message thread. + + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "chat_id": chat_id, + "draft_id": draft_id, + "text": text, + "entities": entities, + } + return await self._send_message( + "sendMessageDraft", + data, + message_thread_id=message_thread_id, + parse_mode=parse_mode, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_messages( + self, + chat_id: int | str, + message_ids: Sequence[int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Use this method to delete multiple messages simultaneously. If some of the specified + messages can't be found, they are skipped. + + .. versionadded:: 20.8 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + message_ids (Sequence[:obj:`int`]): A list of + :tg-const:`telegram.constants.BulkRequestLimit.MIN_LIMIT`- + :tg-const:`telegram.constants.BulkRequestLimit.MAX_LIMIT` identifiers of messages + to delete. See :meth:`delete_message` for limitations on which messages can be + deleted. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = {"chat_id": chat_id, "message_ids": message_ids} + return await self._post( + "deleteMessages", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def forward_message( self, - chat_id: Union[int, str], - from_chat_id: Union[str, int], + chat_id: int | str, + from_chat_id: str | int, message_id: int, - disable_notification: DVInput[bool] = DEFAULT_NONE, + disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> Message: """ Use this method to forward messages of any kind. Service messages can't be forwarded. @@ -903,7 +1331,7 @@ async def forward_message( Note: Since the release of Bot API 5.5 it can be impossible to forward messages from some chats. Use the attributes :attr:`telegram.Message.has_protected_content` and - :attr:`telegram.Chat.has_protected_content` to check this. + :attr:`telegram.ChatFullInfo.has_protected_content` to check this. As a workaround, it is still possible to use :meth:`copy_message`. However, this behaviour is undocumented and might be changed by Telegram. @@ -914,6 +1342,10 @@ async def forward_message( original message was sent (or channel username in the format ``@channelusername``). message_id (:obj:`int`): Message identifier in the chat specified in :paramref:`from_chat_id`. + video_start_timestamp (:obj:`int`, optional): New start timestamp for the + forwarded video in the message + + .. versionadded:: 21.11 disable_notification (:obj:`bool`, optional): |disable_notification| protect_content (:obj:`bool`, optional): |protect_content| @@ -921,6 +1353,20 @@ async def forward_message( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): An + object containing the parameters of the suggested post to send; for direct messages + chats only. + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): Identifier of the direct messages + topic to which the message will be forwarded; required if the message is + forwarded to a direct messages chat. + + .. versionadded:: 22.4 + message_effect_id (:obj:`str`, optional): Unique identifier of the message effect to be + added to the message; only available when forwarding to private chats + + .. versionadded:: 22.6 Returns: :class:`telegram.Message`: On success, the sent Message is returned. @@ -932,6 +1378,7 @@ async def forward_message( "chat_id": chat_id, "from_chat_id": from_chat_id, "message_id": message_id, + "video_start_timestamp": video_start_timestamp, } return await self._send_message( @@ -940,35 +1387,113 @@ async def forward_message( disable_notification=disable_notification, protect_content=protect_content, message_thread_id=message_thread_id, + suggested_post_parameters=suggested_post_parameters, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, + message_effect_id=message_effect_id, + ) + + async def forward_messages( + self, + chat_id: int | str, + from_chat_id: str | int, + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> tuple[MessageId, ...]: + """ + Use this method to forward messages of any kind. If some of the specified messages can't be + found or forwarded, they are skipped. Service messages and messages with protected content + can't be forwarded. Album grouping is kept for forwarded messages. + + .. versionadded:: 20.8 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + from_chat_id (:obj:`int` | :obj:`str`): Unique identifier for the chat where the + original message was sent (or channel username in the format ``@channelusername``). + message_ids (Sequence[:obj:`int`]): A list of + :tg-const:`telegram.constants.BulkRequestLimit.MIN_LIMIT`- + :tg-const:`telegram.constants.BulkRequestLimit.MAX_LIMIT` identifiers of messages + in the chat :paramref:`from_chat_id` to forward. The identifiers must be specified + in a strictly increasing order. + disable_notification (:obj:`bool`, optional): |disable_notification| + protect_content (:obj:`bool`, optional): |protect_content| + message_thread_id (:obj:`int`, optional): |message_thread_id_arg| + direct_messages_topic_id (:obj:`int`, optional): Identifier of the direct messages + topic to which the messages will be forwarded; required if the messages are + forwarded to a direct messages chat. + + .. versionadded:: 22.4 + + Returns: + tuple[:class:`telegram.Message`]: On success, a tuple of ``MessageId`` of sent messages + is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "chat_id": chat_id, + "from_chat_id": from_chat_id, + "message_ids": message_ids, + "disable_notification": disable_notification, + "protect_content": protect_content, + "message_thread_id": message_thread_id, + "direct_messages_topic_id": direct_messages_topic_id, + } + + result = await self._post( + "forwardMessages", + data, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) + return MessageId.de_list(result, self) - @_log async def send_photo( self, - chat_id: Union[int, str], - photo: Union[FileInput, "PhotoSize"], - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, + chat_id: int | str, + photo: "FileInput | PhotoSize", + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send photos. @@ -976,8 +1501,8 @@ async def send_photo( Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| - photo (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ - :class:`telegram.PhotoSize`): Photo to send. + photo (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` \ + | :class:`pathlib.Path` | :class:`telegram.PhotoSize`): Photo to send. |fileinput| Lastly you can pass an existing :class:`telegram.PhotoSize` object to send. @@ -1008,8 +1533,7 @@ async def send_photo( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): Additional interface options. An object for an inline keyboard, custom reply @@ -1018,8 +1542,48 @@ async def send_photo( with a spoiler animation. .. versionadded:: 20.0 + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: 20.8 + business_connection_id (:obj:`str`, optional): |business_id_str| + + .. versionadded:: 21.1 + message_effect_id (:obj:`str`, optional): |message_effect_id| + + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + + .. versionadded:: 21.3 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| filename (:obj:`str`, optional): Custom file name for the photo, when uploading a new file. Convenience parameter, useful e.g. when sending files generated by the :obj:`tempfile` module. @@ -1037,6 +1601,7 @@ async def send_photo( "chat_id": chat_id, "photo": self._parse_file_input(photo, PhotoSize, filename=filename), "has_spoiler": has_spoiler, + "show_caption_above_media": show_caption_above_media, } return await self._send_message( @@ -1051,39 +1616,49 @@ async def send_photo( caption=caption, parse_mode=parse_mode, caption_entities=caption_entities, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) - @_log async def send_audio( self, - chat_id: Union[int, str], - audio: Union[FileInput, "Audio"], - duration: Optional[int] = None, - performer: Optional[str] = None, - title: Optional[str] = None, - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, + chat_id: int | str, + audio: "FileInput | Audio", + duration: TimePeriod | None = None, + performer: str | None = None, + title: str | None = None, + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - thumb: Optional[FileInput] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """ Use this method to send audio files, if you want Telegram clients to display them in the @@ -1097,11 +1672,14 @@ async def send_audio( .. seealso:: :wiki:`Working with Files and Media ` + .. versionchanged:: 20.5 + |removed_thumb_arg| + Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| - audio (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ - :class:`telegram.Audio`): Audio file to send. - |fileinput| + audio (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | \ + :obj:`bytes` | :class:`pathlib.Path` | :class:`telegram.Audio`): Audio file to + send. |fileinput| Lastly you can pass an existing :class:`telegram.Audio` object to send. .. versionchanged:: 13.2 @@ -1119,7 +1697,11 @@ async def send_audio( .. versionchanged:: 20.0 |sequenceargs| - duration (:obj:`int`, optional): Duration of sent audio in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of sent audio + in seconds. + + .. versionchanged:: 21.11 + |time-period-input| performer (:obj:`str`, optional): Performer. title (:obj:`str`, optional): Track name. disable_notification (:obj:`bool`, optional): |disable_notification| @@ -1130,30 +1712,53 @@ async def send_audio( .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| 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. - thumb (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ + thumbnail (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ optional): |thumbdocstring| - .. versionchanged:: 13.2 - Accept :obj:`bytes` as input. + .. versionadded:: 20.2 + reply_parameters (:obj:`ReplyParameters`, optional): |reply_parameters| - .. versionchanged:: 20.0 - File paths as input is also accepted for bots *not* running in - :paramref:`~telegram.Bot.local_mode`. + .. versionadded:: 20.8 + business_connection_id (:obj:`str`, optional): |business_id_str| - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. - thumbnail (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ - optional): |thumbdocstring| + .. versionadded:: 21.1 + message_effect_id (:obj:`str`, optional): |message_effect_id| - .. versionadded:: 20.2 + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| filename (:obj:`str`, optional): Custom file name for the audio, when uploading a new file. Convenience parameter, useful e.g. when sending files generated by the :obj:`tempfile` module. @@ -1167,21 +1772,13 @@ async def send_audio( :class:`telegram.error.TelegramError` """ - thumbnail_or_thumb: FileInput = warn_about_thumb_return_thumbnail( - deprecated_arg=thumb, - new_arg=thumbnail, - warn_callback=self._warn, - stacklevel=3, - ) data: JSONDict = { "chat_id": chat_id, "audio": self._parse_file_input(audio, Audio, filename=filename), "duration": duration, "performer": performer, "title": title, - "thumbnail": self._parse_file_input(thumbnail_or_thumb, attach=True) - if thumbnail_or_thumb - else None, + "thumbnail": self._parse_file_input(thumbnail, attach=True) if thumbnail else None, } return await self._send_message( @@ -1196,37 +1793,47 @@ async def send_audio( caption=caption, parse_mode=parse_mode, caption_entities=caption_entities, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) - @_log async def send_document( self, - chat_id: Union[int, str], - document: Union[FileInput, "Document"], - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, + chat_id: int | str, + document: "FileInput | Document", + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - thumb: Optional[FileInput] = None, - disable_content_type_detection: Optional[bool] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + disable_content_type_detection: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """ Use this method to send general files. @@ -1237,10 +1844,13 @@ async def send_document( .. seealso:: :wiki:`Working with Files and Media ` + .. versionchanged:: 20.5 + |removed_thumb_arg| + Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| - document (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ - :class:`telegram.Document`): File to send. + document (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | \ + :obj:`bytes` | :class:`pathlib.Path` | :class:`telegram.Document`): File to send. |fileinput| Lastly you can pass an existing :class:`telegram.Document` object to send. @@ -1272,30 +1882,53 @@ async def send_document( .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| 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. - thumb (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ + thumbnail (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ optional): |thumbdocstring| - .. versionchanged:: 13.2 - Accept :obj:`bytes` as input. + .. versionadded:: 20.2 + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| - .. versionchanged:: 20.0 - File paths as input is also accepted for bots *not* running in - :paramref:`~telegram.Bot.local_mode`. + .. versionadded:: 20.8 + business_connection_id (:obj:`str`, optional): |business_id_str| - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. - thumbnail (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ - optional): |thumbdocstring| + .. versionadded:: 21.1 + message_effect_id (:obj:`str`, optional): |message_effect_id| - .. versionadded:: 20.2 + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| filename (:obj:`str`, optional): Custom file name for the document, when uploading a new file. Convenience parameter, useful e.g. when sending files generated by the :obj:`tempfile` module. @@ -1307,20 +1940,11 @@ async def send_document( :class:`telegram.error.TelegramError` """ - thumbnail_or_thumb: FileInput = warn_about_thumb_return_thumbnail( - deprecated_arg=thumb, - new_arg=thumbnail, - warn_callback=self._warn, - stacklevel=3, - ) - data: JSONDict = { "chat_id": chat_id, "document": self._parse_file_input(document, Document, filename=filename), "disable_content_type_detection": disable_content_type_detection, - "thumbnail": self._parse_file_input(thumbnail_or_thumb, attach=True) - if thumbnail_or_thumb - else None, + "thumbnail": self._parse_file_input(thumbnail, attach=True) if thumbnail else None, } return await self._send_message( @@ -1335,31 +1959,42 @@ async def send_document( caption=caption, parse_mode=parse_mode, caption_entities=caption_entities, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) - @_log async def send_sticker( self, - chat_id: Union[int, str], - sticker: Union[FileInput, "Sticker"], - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + chat_id: int | str, + sticker: "FileInput | Sticker", + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - emoji: Optional[str] = None, + message_thread_id: int | None = None, + emoji: str | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """ Use this method to send static ``.WEBP``, animated ``.TGS``, or video ``.WEBM`` stickers. @@ -1368,10 +2003,10 @@ async def send_sticker( Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| - sticker (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ - :class:`telegram.Sticker`): Sticker to send. - |fileinput| Video stickers can only be sent by a ``file_id``. Animated stickers - can't be sent via an HTTP URL. + sticker (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | \ + :obj:`bytes` | :class:`pathlib.Path` | :class:`telegram.Sticker`): Sticker to send. + |fileinput| Video stickers can only be sent by a ``file_id``. Video and animated + stickers can't be sent via an HTTP URL. Lastly you can pass an existing :class:`telegram.Sticker` object to send. @@ -1393,12 +2028,49 @@ async def send_sticker( .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| 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. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: 20.8 + business_connection_id (:obj:`str`, optional): |business_id_str| + + .. versionadded:: 21.1 + message_effect_id (:obj:`str`, optional): |message_effect_id| + + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 + + 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| Returns: :class:`telegram.Message`: On success, the sent Message is returned. @@ -1421,41 +2093,54 @@ async def send_sticker( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) - @_log async def send_video( self, - chat_id: Union[int, str], - video: Union[FileInput, "Video"], - duration: Optional[int] = None, - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - width: Optional[int] = None, - height: Optional[int] = None, + chat_id: int | str, + video: "FileInput | Video", + duration: TimePeriod | None = None, + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + width: int | None = None, + height: int | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - supports_streaming: Optional[bool] = None, - thumb: Optional[FileInput] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + supports_streaming: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + cover: "FileInput | None" = None, + start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send video files, Telegram clients support mp4 videos (other formats may be sent as Document). @@ -1465,16 +2150,19 @@ async def send_video( changed in the future. Note: - :paramref:`thumb` will be ignored for small video files, for which Telegram can + :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. .. seealso:: :wiki:`Working with Files and Media ` + .. versionchanged:: 20.5 + |removed_thumb_arg| + Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| - video (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ - :class:`telegram.Video`): Video file to send. + video (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` \ + | :class:`pathlib.Path` | :class:`telegram.Video`): Video file to send. |fileinput| Lastly you can pass an existing :class:`telegram.Video` object to send. @@ -1484,9 +2172,20 @@ async def send_video( .. versionchanged:: 20.0 File paths as input is also accepted for bots *not* running in :paramref:`~telegram.Bot.local_mode`. - duration (:obj:`int`, optional): Duration of sent video in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of sent video + in seconds. + + .. versionchanged:: 21.11 + |time-period-input| width (:obj:`int`, optional): Video width. height (:obj:`int`, optional): Video height. + cover (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ + optional): Cover for the video in the message. |fileinputnopath| + + .. versionadded:: 21.11 + start_timestamp (:obj:`int`, optional): Start timestamp for the video in the message. + + .. versionadded:: 21.11 caption (:obj:`str`, optional): Video caption (may also be used when resending videos by file_id), 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -1506,24 +2205,10 @@ async def send_video( .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| 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. - thumb (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ - optional): |thumbdocstring| - - .. versionchanged:: 13.2 - Accept :obj:`bytes` as input. - - .. versionchanged:: 20.0 - File paths as input is also accepted for bots *not* running in - :paramref:`~telegram.Bot.local_mode`. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. has_spoiler (:obj:`bool`, optional): Pass :obj:`True` if the video needs to be covered with a spoiler animation. @@ -1532,8 +2217,48 @@ async def send_video( optional): |thumbdocstring| .. versionadded:: 20.2 + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: 20.8 + business_connection_id (:obj:`str`, optional): |business_id_str| + + .. versionadded:: 21.1 + message_effect_id (:obj:`str`, optional): |message_effect_id| + + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + + .. versionadded:: 21.3 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| filename (:obj:`str`, optional): Custom file name for the video, when uploading a new file. Convenience parameter, useful e.g. when sending files generated by the :obj:`tempfile` module. @@ -1547,12 +2272,6 @@ async def send_video( :class:`telegram.error.TelegramError` """ - thumbnail_or_thumb: FileInput = warn_about_thumb_return_thumbnail( - deprecated_arg=thumb, - new_arg=thumbnail, - warn_callback=self._warn, - stacklevel=3, - ) data: JSONDict = { "chat_id": chat_id, "video": self._parse_file_input(video, Video, filename=filename), @@ -1560,10 +2279,11 @@ async def send_video( "width": width, "height": height, "supports_streaming": supports_streaming, - "thumbnail": self._parse_file_input(thumbnail_or_thumb, attach=True) - if thumbnail_or_thumb - else None, + "cover": self._parse_file_input(cover, attach=True) if cover else None, + "start_timestamp": start_timestamp, + "thumbnail": self._parse_file_input(thumbnail, attach=True) if thumbnail else None, "has_spoiler": has_spoiler, + "show_caption_above_media": show_caption_above_media, } return await self._send_message( @@ -1578,51 +2298,65 @@ async def send_video( caption=caption, parse_mode=parse_mode, caption_entities=caption_entities, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) - @_log async def send_video_note( self, - chat_id: Union[int, str], - video_note: Union[FileInput, "VideoNote"], - duration: Optional[int] = None, - length: Optional[int] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - thumb: Optional[FileInput] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + chat_id: int | str, + video_note: "FileInput | VideoNote", + duration: TimePeriod | None = None, + length: int | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """ As of v.4.0, Telegram clients support rounded square mp4 videos of up to 1 minute long. Use this method to send video messages. Note: - :paramref:`thumb` will be ignored for small video files, for which Telegram can + :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. .. seealso:: :wiki:`Working with Files and Media ` + .. versionchanged:: 20.5 + |removed_thumb_arg| + Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| - video_note (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ - :class:`telegram.VideoNote`): Video note to send. + video_note (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | \ + :obj:`bytes` | :class:`pathlib.Path` | :class:`telegram.VideoNote`): Video note + to send. Pass a file_id as String to send a video note that exists on the Telegram servers (recommended) or upload a new video using multipart/form-data. |uploadinput| @@ -1635,7 +2369,11 @@ async def send_video_note( .. versionchanged:: 20.0 File paths as input is also accepted for bots *not* running in :paramref:`~telegram.Bot.local_mode`. - duration (:obj:`int`, optional): Duration of sent video in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of sent video + in seconds. + + .. versionchanged:: 21.11 + |time-period-input| length (:obj:`int`, optional): Video width and height, i.e. diameter of the video message. disable_notification (:obj:`bool`, optional): |disable_notification| @@ -1646,30 +2384,53 @@ async def send_video_note( .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| 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. - thumb (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ + thumbnail (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ optional): |thumbdocstring| - .. versionchanged:: 13.2 - Accept :obj:`bytes` as input. + .. versionadded:: 20.2 + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| - .. versionchanged:: 20.0 - File paths as input is also accepted for bots *not* running in - :paramref:`~telegram.Bot.local_mode`. + .. versionadded:: 20.8 + business_connection_id (:obj:`str`, optional): |business_id_str| - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. - thumbnail (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ - optional): |thumbdocstring| + .. versionadded:: 21.1 + message_effect_id (:obj:`str`, optional): |message_effect_id| - .. versionadded:: 20.2 + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| filename (:obj:`str`, optional): Custom file name for the video note, when uploading a new file. Convenience parameter, useful e.g. when sending files generated by the :obj:`tempfile` module. @@ -1683,20 +2444,12 @@ async def send_video_note( :class:`telegram.error.TelegramError` """ - thumbnail_or_thumb: FileInput = warn_about_thumb_return_thumbnail( - deprecated_arg=thumb, - new_arg=thumbnail, - warn_callback=self._warn, - stacklevel=3, - ) data: JSONDict = { "chat_id": chat_id, "video_note": self._parse_file_input(video_note, VideoNote, filename=filename), "duration": duration, "length": length, - "thumbnail": self._parse_file_input(thumbnail_or_thumb, attach=True) - if thumbnail_or_thumb - else None, + "thumbnail": self._parse_file_input(thumbnail, attach=True) if thumbnail else None, } return await self._send_message( @@ -1708,40 +2461,51 @@ async def send_video_note( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) - @_log async def send_animation( self, - chat_id: Union[int, str], - animation: Union[FileInput, "Animation"], - duration: Optional[int] = None, - width: Optional[int] = None, - height: Optional[int] = None, - thumb: Optional[FileInput] = None, - caption: Optional[str] = None, + chat_id: int | str, + animation: "FileInput | Animation", + duration: TimePeriod | None = None, + width: int | None = None, + height: int | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """ Use this method to send animation files (GIF or H.264/MPEG-4 AVC video without sound). @@ -1750,36 +2514,31 @@ async def send_animation( changed in the future. Note: - :paramref:`thumb` will be ignored for small files, for which Telegram can easily + :paramref:`thumbnail` will be ignored for small files, for which Telegram can easily generate thumbnails. However, this behaviour is undocumented and might be changed by Telegram. .. seealso:: :wiki:`Working with Files and Media ` + .. versionchanged:: 20.5 + |removed_thumb_arg| + Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| - animation (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ - :class:`telegram.Animation`): Animation to send. - |fileinput| + animation (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | \ + :obj:`bytes` | :class:`pathlib.Path` | :class:`telegram.Animation`): Animation to + send. |fileinput| Lastly you can pass an existing :class:`telegram.Animation` object to send. .. versionchanged:: 13.2 Accept :obj:`bytes` as input. - duration (:obj:`int`, optional): Duration of sent animation in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of sent + animation in seconds. + + .. versionchanged:: 21.11 + |time-period-input| width (:obj:`int`, optional): Animation width. height (:obj:`int`, optional): Animation height. - thumb (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ - optional): |thumbdocstring| - - .. versionchanged:: 13.2 - Accept :obj:`bytes` as input. - - .. versionchanged:: 20.0 - File paths as input is also accepted for bots *not* running in - :paramref:`~telegram.Bot.local_mode`. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. caption (:obj:`str`, optional): Animation caption (may also be used when resending animations by file_id), 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after @@ -1798,8 +2557,6 @@ async def send_animation( .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): Additional interface options. An object for an inline keyboard, custom reply @@ -1812,8 +2569,48 @@ async def send_animation( optional): |thumbdocstring| .. versionadded:: 20.2 + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: 20.8 + business_connection_id (:obj:`str`, optional): |business_id_str| + + .. versionadded:: 21.1 + message_effect_id (:obj:`str`, optional): |message_effect_id| + + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + + .. versionadded:: 21.3 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| filename (:obj:`str`, optional): Custom file name for the animation, when uploading a new file. Convenience parameter, useful e.g. when sending files generated by the :obj:`tempfile` module. @@ -1827,22 +2624,15 @@ async def send_animation( :class:`telegram.error.TelegramError` """ - thumbnail_or_thumb: FileInput = warn_about_thumb_return_thumbnail( - deprecated_arg=thumb, - new_arg=thumbnail, - warn_callback=self._warn, - stacklevel=3, - ) data: JSONDict = { "chat_id": chat_id, "animation": self._parse_file_input(animation, Animation, filename=filename), "duration": duration, "width": width, "height": height, - "thumbnail": self._parse_file_input(thumbnail_or_thumb, attach=True) - if thumbnail_or_thumb - else None, + "thumbnail": self._parse_file_input(thumbnail, attach=True) if thumbnail else None, "has_spoiler": has_spoiler, + "show_caption_above_media": show_caption_above_media, } return await self._send_message( @@ -1857,42 +2647,54 @@ async def send_animation( caption=caption, parse_mode=parse_mode, caption_entities=caption_entities, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) - @_log async def send_voice( self, - chat_id: Union[int, str], - voice: Union[FileInput, "Voice"], - duration: Optional[int] = None, - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, + chat_id: int | str, + voice: "FileInput | Voice", + duration: TimePeriod | None = None, + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Message: """ Use this method to send audio files, if you want Telegram clients to display the file as a playable voice message. For this to work, your audio must be in an ``.ogg`` file - encoded with OPUS (other formats may be sent as Audio or Document). Bots can currently - send voice messages of up to :tg-const:`telegram.constants.FileSizeLimit.FILESIZE_UPLOAD` - in size, this limit may be changed in the future. + encoded with OPUS , or in .MP3 format, or in .M4A format (other formats may be sent as + :class:`~telegram.Audio` or :class:`~telegram.Document`). Bots can currently send voice + messages of up to :tg-const:`telegram.constants.FileSizeLimit.FILESIZE_UPLOAD` in size, + this limit may be changed in the future. Note: To use this method, the file must have the type :mimetype:`audio/ogg` and be no more @@ -1905,8 +2707,8 @@ async def send_voice( Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| - voice (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ - :class:`telegram.Voice`): Voice file to send. + voice (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` \ + | :class:`pathlib.Path` | :class:`telegram.Voice`): Voice file to send. |fileinput| Lastly you can pass an existing :class:`telegram.Voice` object to send. @@ -1925,7 +2727,11 @@ async def send_voice( .. versionchanged:: 20.0 |sequenceargs| - duration (:obj:`int`, optional): Duration of the voice message in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of the voice + message in seconds. + + .. versionchanged:: 21.11 + |time-period-input| disable_notification (:obj:`bool`, optional): |disable_notification| protect_content (:obj:`bool`, optional): |protect_content| @@ -1934,14 +2740,49 @@ async def send_voice( .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| 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. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: 20.8 + business_connection_id (:obj:`str`, optional): |business_id_str| + + .. versionadded:: 21.1 + message_effect_id (:obj:`str`, optional): |message_effect_id| + + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| filename (:obj:`str`, optional): Custom file name for the voice, when uploading a new file. Convenience parameter, useful e.g. when sending files generated by the :obj:`tempfile` module. @@ -1973,35 +2814,45 @@ async def send_voice( caption=caption, parse_mode=parse_mode, caption_entities=caption_entities, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) - @_log async def send_media_group( self, - chat_id: Union[int, str], + chat_id: int | str, media: Sequence[ - Union["InputMediaAudio", "InputMediaDocument", "InputMediaPhoto", "InputMediaVideo"] + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" ], disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - caption: Optional[str] = None, + api_kwargs: JSONDict | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - ) -> Tuple[Message, ...]: + caption_entities: Sequence["MessageEntity"] | None = None, + ) -> tuple[Message, ...]: """Use this method to send a group of photos, videos, documents or audios as an album. Documents and audio files can be only grouped in an album with messages of the same type. @@ -2034,10 +2885,44 @@ async def send_media_group( .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: 20.8 + business_connection_id (:obj:`str`, optional): |business_id_str| + + .. versionadded:: 21.1 + message_effect_id (:obj:`str`, optional): |message_effect_id| + + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + direct_messages_topic_id (:obj:`int`, optional): Identifier of the direct messages + topic to which the messages will be sent; required if the messages are sent to a + direct messages chat. + + .. versionadded:: 22.4 + 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| caption (:obj:`str`, optional): Caption that will be added to the first element of :paramref:`media`, so that it will be used as caption for the whole media group. @@ -2058,7 +2943,7 @@ async def send_media_group( .. versionadded:: 20.0 Returns: - Tuple[:class:`telegram.Message`]: An array of the sent Messages. + tuple[:class:`telegram.Message`]: An array of the sent Messages. Raises: :class:`telegram.error.TelegramError` @@ -2087,14 +2972,33 @@ async def send_media_group( media = list(media) media[0] = item_to_get_caption + if allow_sending_without_reply is not DEFAULT_NONE and reply_parameters is not None: + raise ValueError( + "`allow_sending_without_reply` and `reply_parameters` are mutually exclusive." + ) + + if reply_to_message_id is not None and reply_parameters is not None: + raise ValueError( + "`reply_to_message_id` and `reply_parameters` are mutually exclusive." + ) + + if reply_to_message_id is not None: + reply_parameters = ReplyParameters( + message_id=reply_to_message_id, + allow_sending_without_reply=allow_sending_without_reply, + ) + data: JSONDict = { "chat_id": chat_id, "media": media, "disable_notification": disable_notification, - "allow_sending_without_reply": allow_sending_without_reply, "protect_content": protect_content, "message_thread_id": message_thread_id, - "reply_to_message_id": reply_to_message_id, + "reply_parameters": reply_parameters, + "business_connection_id": business_connection_id, + "message_effect_id": message_effect_id, + "allow_paid_broadcast": allow_paid_broadcast, + "direct_messages_topic_id": direct_messages_topic_id, } result = await self._post( @@ -2109,29 +3013,34 @@ async def send_media_group( return Message.de_list(result, self) - @_log async def send_location( self, - chat_id: Union[int, str], - latitude: Optional[float] = None, - longitude: Optional[float] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - live_period: Optional[int] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + chat_id: int | str, + latitude: float | None = None, + longitude: float | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + live_period: TimePeriod | None = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - location: Optional[Location] = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + location: "Location | None" = 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, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send point on the map. @@ -2146,10 +3055,16 @@ async def send_location( horizontal_accuracy (:obj:`int`, optional): The radius of uncertainty for the location, measured in meters; 0-:tg-const:`telegram.constants.LocationLimit.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`, optional): Period in seconds for which the location will be + live_period (:obj:`int` | :class:`datetime.timedelta`, optional): Period in seconds for + which the location will be updated, should be between :tg-const:`telegram.constants.LocationLimit.MIN_LIVE_PERIOD` and - :tg-const:`telegram.constants.LocationLimit.MAX_LIVE_PERIOD`. + :tg-const:`telegram.constants.LocationLimit.MAX_LIVE_PERIOD`, or + :tg-const:`telegram.constants.LocationLimit.LIVE_PERIOD_FOREVER` for live + locations that can be edited indefinitely. + + .. versionchanged:: 21.11 + |time-period-input| heading (:obj:`int`, optional): For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.constants.LocationLimit.MIN_HEADING` and @@ -2167,14 +3082,49 @@ async def send_location( .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| 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. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: 20.8 + business_connection_id (:obj:`str`, optional): |business_id_str| + + .. versionadded:: 21.1 + message_effect_id (:obj:`str`, optional): |message_effect_id| + + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| location (:class:`telegram.Location`, optional): The location to send. Returns: @@ -2217,33 +3167,40 @@ async def send_location( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) - @_log async def edit_message_live_location( self, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + latitude: float | None = None, + longitude: float | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, + live_period: TimePeriod | None = None, + business_connection_id: str | None = None, *, - location: Optional[Location] = None, + location: "Location | None" = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Use this method to edit live location messages sent by the bot or via the bot (for inline bots). A location can be edited until its :attr:`telegram.Location.live_period` expires or editing is explicitly disabled by a call to :meth:`stop_message_live_location`. @@ -2274,6 +3231,22 @@ async def edit_message_live_location( if specified. reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for a new inline keyboard. + live_period (:obj:`int` | :class:`datetime.timedelta`, optional): New period in seconds + during which the location + can be updated, starting from the message send date. If + :tg-const:`telegram.constants.LocationLimit.LIVE_PERIOD_FOREVER` is specified, + then the location can be updated forever. Otherwise, the new value must not exceed + the current ``live_period`` by more than a day, and the live location expiration + date must remain within the next 90 days. If not specified, then ``live_period`` + remains unchanged + + .. versionadded:: 21.2. + + .. versionchanged:: 21.11 + |time-period-input| + business_connection_id (:obj:`str`, optional): |business_id_str_edit| + + .. versionadded:: 21.4 Keyword Args: location (:class:`telegram.Location`, optional): The location to send. @@ -2306,12 +3279,14 @@ async def edit_message_live_location( "horizontal_accuracy": horizontal_accuracy, "heading": heading, "proximity_alert_radius": proximity_alert_radius, + "live_period": live_period, } return await self._send_message( "editMessageLiveLocation", data, reply_markup=reply_markup, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2319,20 +3294,20 @@ async def edit_message_live_location( api_kwargs=api_kwargs, ) - @_log async def stop_message_live_location( self, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + business_connection_id: str | None = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Use this method to stop updating a live location message sent by the bot or via the bot (for inline bots) before :paramref:`~telegram.Location.live_period` expires. @@ -2345,6 +3320,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 @@ -2360,6 +3338,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, @@ -2367,31 +3346,36 @@ async def stop_message_live_location( api_kwargs=api_kwargs, ) - @_log async def send_venue( self, - chat_id: Union[int, str], - latitude: Optional[float] = None, - longitude: Optional[float] = None, - title: Optional[str] = None, - address: Optional[str] = None, - foursquare_id: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - foursquare_type: Optional[str] = None, - google_place_id: Optional[str] = None, - google_place_type: Optional[str] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + chat_id: int | str, + latitude: float | None = None, + longitude: float | None = None, + title: str | None = None, + address: str | None = None, + foursquare_id: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - venue: Optional[Venue] = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + venue: "Venue | None" = 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, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send information about a venue. @@ -2416,7 +3400,7 @@ async def send_venue( google_place_id (:obj:`str`, optional): Google Places identifier of the venue. google_place_type (:obj:`str`, optional): Google Places type of the venue. (See `supported types \ - `_.) + `_.) disable_notification (:obj:`bool`, optional): |disable_notification| protect_content (:obj:`bool`, optional): |protect_content| @@ -2425,14 +3409,49 @@ async def send_venue( .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| 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. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: 20.8 + business_connection_id (:obj:`str`, optional): |business_id_str| + + .. versionadded:: 21.1 + message_effect_id (:obj:`str`, optional): |message_effect_id| + + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| venue (:class:`telegram.Venue`, optional): The venue to send. Returns: @@ -2486,34 +3505,45 @@ async def send_venue( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) - @_log async def send_contact( self, - chat_id: Union[int, str], - phone_number: Optional[str] = None, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - vcard: Optional[str] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + chat_id: int | str, + phone_number: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + vcard: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - contact: Optional[Contact] = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + contact: "Contact | None" = 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, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send phone contacts. @@ -2537,14 +3567,49 @@ async def send_contact( .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| 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. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: 20.8 + business_connection_id (:obj:`str`, optional): |business_id_str| + + .. versionadded:: 21.1 + message_effect_id (:obj:`str`, optional): |message_effect_id| + + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| contact (:class:`telegram.Contact`, optional): The contact to send. Returns: @@ -2589,35 +3654,44 @@ async def send_contact( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) - @_log async def send_game( self, - chat_id: Union[int, str], + chat_id: int, game_short_name: str, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "InlineKeyboardMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = 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, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send a game. Args: - chat_id (:obj:`int` | :obj:`str`): Unique identifier for the target chat. + chat_id (:obj:`int`): Unique identifier for the target chat. game_short_name (:obj:`str`): Short name of the game, serves as the unique identifier for the game. Set up your games via `@BotFather `_. disable_notification (:obj:`bool`, optional): |disable_notification| @@ -2628,11 +3702,41 @@ async def send_game( .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for a new inline keyboard. If empty, one "Play game_title" button will be shown. If not empty, the first button must launch the game. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: 20.8 + business_connection_id (:obj:`str`, optional): |business_id_str| + + .. versionadded:: 21.1 + message_effect_id (:obj:`str`, optional): |message_effect_id| + + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + + 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| Returns: :class:`telegram.Message`: On success, the sent Message is returned. @@ -2652,25 +3756,29 @@ async def send_game( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, ) - @_log async def send_chat_action( self, - chat_id: Union[str, int], + chat_id: str | int, action: str, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + business_connection_id: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method when you need to tell the user that something is happening on the bot's @@ -2686,6 +3794,9 @@ async def send_chat_action( message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 + business_connection_id (:obj:`str`, optional): |business_id_str| + + .. versionadded:: 21.1 Returns: :obj:`bool`: On success, :obj:`True` is returned. @@ -2698,6 +3809,7 @@ async def send_chat_action( "chat_id": chat_id, "action": action, "message_thread_id": message_thread_id, + "business_connection_id": business_connection_id, } return await self._post( "sendChatAction", @@ -2709,14 +3821,13 @@ async def send_chat_action( api_kwargs=api_kwargs, ) - def _effective_inline_results( # skipcq: PYL-R0201 + def _effective_inline_results( self, - results: Union[ - Sequence["InlineQueryResult"], Callable[[int], Optional[Sequence["InlineQueryResult"]]] - ], - next_offset: Optional[str] = None, - current_offset: Optional[str] = None, - ) -> Tuple[Sequence["InlineQueryResult"], Optional[str]]: + results: Sequence["InlineQueryResult"] + | (Callable[[int], Sequence["InlineQueryResult"] | None]), + next_offset: str | None = None, + current_offset: str | None = None, + ) -> tuple[Sequence["InlineQueryResult"], str | None]: """ Builds the effective results from the results input. We make this a stand-alone method so tg.ext.ExtBot can wrap it. @@ -2739,7 +3850,7 @@ def _effective_inline_results( # skipcq: PYL-R0201 if callable(results): callable_output = results(current_offset_int) if not callable_output: - effective_results: Sequence["InlineQueryResult"] = [] + effective_results: Sequence[InlineQueryResult] = [] else: effective_results = callable_output # the callback *might* return more results on the next call, so we increment @@ -2751,8 +3862,7 @@ def _effective_inline_results( # skipcq: PYL-R0201 next_offset_int = current_offset_int + 1 next_offset = str(next_offset_int) effective_results = results[ - current_offset_int - * InlineQueryLimit.RESULTS : next_offset_int + current_offset_int * InlineQueryLimit.RESULTS : next_offset_int * InlineQueryLimit.RESULTS ] else: @@ -2790,45 +3900,35 @@ def _insert_defaults_for_ilq_results(self, res: "InlineQueryResult") -> "InlineQ res.input_message_content.parse_mode = DefaultValue.get_value( res.input_message_content.parse_mode ) - if hasattr(res.input_message_content, "disable_web_page_preview"): + if hasattr(res.input_message_content, "link_preview_options"): if not copied: res = copy.copy(res) with res._unfrozen(): res.input_message_content = copy.copy(res.input_message_content) with res.input_message_content._unfrozen(): - res.input_message_content.disable_web_page_preview = DefaultValue.get_value( - res.input_message_content.disable_web_page_preview + res.input_message_content.link_preview_options = DefaultValue.get_value( + res.input_message_content.link_preview_options ) return res - @_log async def answer_inline_query( self, inline_query_id: str, - results: Union[ - Sequence["InlineQueryResult"], Callable[[int], Optional[Sequence["InlineQueryResult"]]] - ], - cache_time: Optional[int] = None, - is_personal: Optional[bool] = None, - next_offset: Optional[str] = None, - # Deprecated params since bot api 6.7 - # <---- - switch_pm_text: Optional[str] = None, - switch_pm_parameter: Optional[str] = None, - # ---> - # New params since bot api 6.7 - # <---- - button: Optional[InlineQueryResultsButton] = None, - # ---> + results: Sequence["InlineQueryResult"] + | (Callable[[int], Sequence["InlineQueryResult"] | None]), + cache_time: TimePeriod | None = None, + is_personal: bool | None = None, + next_offset: str | None = None, + button: InlineQueryResultsButton | None = None, *, - current_offset: Optional[str] = None, + current_offset: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to send answers to an inline query. No more than @@ -2842,19 +3942,24 @@ async def answer_inline_query( .. seealso:: :wiki:`Working with Files and Media ` - .. |api6_7_depr| replace:: Since Bot API 6.7, this argument is deprecated in favour of - :paramref:`button`. + + .. versionchanged:: 20.5 + Removed deprecated arguments ``switch_pm_text`` and ``switch_pm_parameter``. Args: inline_query_id (:obj:`str`): Unique identifier for the answered query. - results (List[:class:`telegram.InlineQueryResult`] | Callable): A list of results for + results (list[:class:`telegram.InlineQueryResult`] | Callable): A list of results for the inline query. In case :paramref:`current_offset` is passed, :paramref:`results` may also be a callable that accepts the current page index starting from 0. It must return either a list of :class:`telegram.InlineQueryResult` instances or :obj:`None` if there are no more results. - cache_time (:obj:`int`, optional): The maximum amount of time in seconds that the + cache_time (:obj:`int` | :class:`datetime.timedelta`, optional): The maximum amount of + time in seconds that the result of the inline query may be cached on the server. Defaults to ``300``. + + .. versionchanged:: 21.11 + |time-period-input| is_personal (:obj:`bool`, optional): Pass :obj:`True`, if results may be cached on the server side only for the user that sent the query. By default, results may be returned to any user who sends the same query. @@ -2862,20 +3967,6 @@ async def answer_inline_query( next query with the same text to receive more results. Pass an empty string if there are no more results or if you don't support pagination. Offset length can't exceed :tg-const:`telegram.InlineQuery.MAX_OFFSET_LENGTH` bytes. - switch_pm_text (:obj:`str`, optional): If passed, clients will display a button with - specified text that switches the user to a private chat with the bot and sends the - bot a start message with the parameter :paramref:`switch_pm_parameter`. - - .. deprecated:: 20.3 - |api6_7_depr| - switch_pm_parameter (:obj:`str`, optional): Deep-linking parameter for the - :guilabel:`/start` message sent to the bot when user presses the switch button. - :tg-const:`telegram.InlineQuery.MIN_SWITCH_PM_TEXT_LENGTH`- - :tg-const:`telegram.InlineQuery.MAX_SWITCH_PM_TEXT_LENGTH` characters, - only ``A-Z``, ``a-z``, ``0-9``, ``_`` and ``-`` are allowed. - - .. deprecated:: 20.3 - |api6_7_depr| button (:class:`telegram.InlineQueryResultsButton`, optional): A button to be shown above the inline query results. @@ -2894,26 +3985,6 @@ async def answer_inline_query( :class:`telegram.error.TelegramError` """ - if (switch_pm_text or switch_pm_parameter) and button: - raise TypeError( - "Since Bot API 6.7, the parameter `button is mutually exclusive to the deprecated " - "parameters `switch_pm_text` and `switch_pm_parameter`. Please use the new " - "parameter `button`." - ) - - if switch_pm_text and switch_pm_parameter: - self._warn( - "Since Bot API 6.7, the parameters `switch_pm_text` and `switch_pm_parameter` are " - "deprecated in favour of the new parameter `button`. Please use the new parameter " - "`button` instead.", - category=PTBDeprecationWarning, - stacklevel=3, - ) - button = InlineQueryResultsButton( - text=switch_pm_text, - start_parameter=switch_pm_parameter, - ) - effective_results, next_offset = self._effective_inline_results( results=results, next_offset=next_offset, current_offset=current_offset ) @@ -2942,19 +4013,77 @@ async def answer_inline_query( api_kwargs=api_kwargs, ) - @_log + async def save_prepared_inline_message( + self, + user_id: int, + result: "InlineQueryResult", + allow_user_chats: bool | None = None, + allow_bot_chats: bool | None = None, + allow_group_chats: bool | None = None, + allow_channel_chats: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> PreparedInlineMessage: + """Stores a message that can be sent by a user of a Mini App. + + .. versionadded:: 21.8 + + Args: + user_id (:obj:`int`): Unique identifier of the target user that can use the prepared + message. + result (:class:`telegram.InlineQueryResult`): The result to store. + allow_user_chats (:obj:`bool`, optional): Pass :obj:`True` if the message can be sent + to private chats with users + allow_bot_chats (:obj:`bool`, optional): Pass :obj:`True` if the message can be sent + to private chats with bots + allow_group_chats (:obj:`bool`, optional): Pass :obj:`True` if the message can be sent + to group and supergroup chats + allow_channel_chats (:obj:`bool`, optional): Pass :obj:`True` if the message can be + sent to channels + + Returns: + :class:`telegram.PreparedInlineMessage`: On success, the prepared message is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "user_id": user_id, + "result": result, + "allow_user_chats": allow_user_chats, + "allow_bot_chats": allow_bot_chats, + "allow_group_chats": allow_group_chats, + "allow_channel_chats": allow_channel_chats, + } + return PreparedInlineMessage.de_json( + await self._post( + "savePreparedInlineMessage", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ), + self, + ) + async def get_user_profile_photos( self, - user_id: Union[str, int], - offset: Optional[int] = None, - limit: Optional[int] = None, + user_id: int, + offset: int | None = None, + limit: int | None = 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, - ) -> UserProfilePhotos: + api_kwargs: JSONDict | None = None, + ) -> "UserProfilePhotos": """Use this method to get a list of profile pictures for a user. Args: @@ -2985,20 +4114,28 @@ async def get_user_profile_photos( api_kwargs=api_kwargs, ) - return UserProfilePhotos.de_json(result, self) # type: ignore[return-value] + return UserProfilePhotos.de_json(result, self) - @_log async def get_file( self, - file_id: Union[ - str, Animation, Audio, ChatPhoto, Document, PhotoSize, Sticker, Video, VideoNote, Voice - ], + file_id: ( + str + | Animation + | Audio + | ChatPhoto + | Document + | PhotoSize + | Sticker + | Video + | VideoNote + | Voice + ), *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> File: """ Use this method to get basic info about a file and prepare it for downloading. For the @@ -3047,25 +4184,24 @@ async def get_file( api_kwargs=api_kwargs, ) - file_path = cast(dict, result).get("file_path") + file_path = cast("dict", result).get("file_path") if file_path and not is_local_file(file_path): result["file_path"] = f"{self._base_file_url}/{file_path}" - return File.de_json(result, self) # type: ignore[return-value] + return File.de_json(result, self) - @_log async def ban_chat_member( self, - chat_id: Union[str, int], - user_id: Union[str, int], - until_date: Optional[Union[int, datetime]] = None, - revoke_messages: Optional[bool] = None, + chat_id: str | int, + user_id: int, + until_date: int | dtm.datetime | None = None, + revoke_messages: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to ban a user from a group, supergroup or a channel. In the case of @@ -3083,9 +4219,7 @@ async def ban_chat_member( be unbanned, unix time. If user is banned for more than 366 days or less than 30 seconds from the current time they are considered to be banned forever. Applied for supergroups and channels only. - For timezone naive :obj:`datetime.datetime` objects, the default timezone of the - bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is - used. + |tz-naive-dtms| revoke_messages (:obj:`bool`, optional): Pass :obj:`True` to delete all messages from the chat for the user that is being removed. If :obj:`False`, the user will be able to see messages in the group that were sent before the user was removed. @@ -3117,17 +4251,16 @@ async def ban_chat_member( api_kwargs=api_kwargs, ) - @_log async def ban_chat_sender_chat( self, - chat_id: Union[str, int], + chat_id: str | int, sender_chat_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to ban a channel chat in a supergroup or a channel. Until the chat is @@ -3161,18 +4294,17 @@ async def ban_chat_sender_chat( api_kwargs=api_kwargs, ) - @_log async def unban_chat_member( self, - chat_id: Union[str, int], - user_id: Union[str, int], - only_if_banned: Optional[bool] = None, + chat_id: str | int, + user_id: int, + only_if_banned: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to unban a previously kicked user in a supergroup or channel. @@ -3206,17 +4338,16 @@ async def unban_chat_member( api_kwargs=api_kwargs, ) - @_log async def unban_chat_sender_chat( self, - chat_id: Union[str, int], + chat_id: str | int, sender_chat_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to unban a previously banned channel in a supergroup or channel. The bot must be an administrator for this to work and must have the @@ -3247,20 +4378,19 @@ async def unban_chat_sender_chat( api_kwargs=api_kwargs, ) - @_log async def answer_callback_query( self, callback_query_id: str, - text: Optional[str] = None, - show_alert: Optional[bool] = None, - url: Optional[str] = None, - cache_time: Optional[int] = None, + text: str | None = None, + show_alert: bool | None = None, + url: str | None = None, + cache_time: TimePeriod | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to send answers to callback queries sent from inline keyboards. The answer @@ -3285,9 +4415,13 @@ async def answer_callback_query( opens your game - note that this will only work if the query comes from a callback game button. Otherwise, you may use links like t.me/your_bot?start=XXXX that open your bot with a parameter. - cache_time (:obj:`int`, optional): The maximum amount of time in seconds that the + cache_time (:obj:`int` | :class:`datetime.timedelta`, optional): The maximum amount of + time in seconds that the result of the callback query may be cached client-side. Defaults to 0. + .. versionchanged:: 21.11 + |time-period-input| + Returns: :obj:`bool` On success, :obj:`True` is returned. @@ -3313,29 +4447,31 @@ async def answer_callback_query( api_kwargs=api_kwargs, ) - @_log async def edit_message_text( self, text: str, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[InlineKeyboardMarkup] = None, - entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + business_connection_id: str | None = None, *, + disable_web_page_preview: bool | None = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """ Use this method to edit text and game messages. Note: - |editreplymarkup|. + * |editreplymarkup| + * |bcid_edit_time| .. seealso:: :attr:`telegram.Game.text` @@ -3357,17 +4493,41 @@ async def edit_message_text( .. versionchanged:: 20.0 |sequenceargs| - disable_web_page_preview (:obj:`bool`, optional): Disables link previews for links in - this message. + + link_preview_options (:obj:`LinkPreviewOptions`, optional): Link preview generation + options for the message. Mutually exclusive with + :paramref:`disable_web_page_preview`. + + .. versionadded:: 20.8 + 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 + this message. Convenience parameter for setting :paramref:`link_preview_options`. + Mutually exclusive with :paramref:`link_preview_options`. + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`link_preview_options` replacing this + argument. PTB will automatically convert this argument to that one, but + for advanced options, please use :paramref:`link_preview_options` directly. + + .. versionchanged:: 21.0 + |keyword_only_arg| + Returns: :class:`telegram.Message`: On success, if edited message is not an inline message, the edited message is returned, otherwise :obj:`True` is returned. Raises: - :class:`telegram.error.TelegramError` + :exc:`ValueError`: If both :paramref:`disable_web_page_preview` and + :paramref:`link_preview_options` are passed. + :class:`telegram.error.TelegramError`: For other errors. """ data: JSONDict = { @@ -3378,12 +4538,15 @@ async def edit_message_text( "entities": entities, } + link_preview_options = parse_lpo_and_dwpp(disable_web_page_preview, link_preview_options) + return await self._send_message( "editMessageText", data, reply_markup=reply_markup, parse_mode=parse_mode, - disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -3391,28 +4554,30 @@ async def edit_message_text( api_kwargs=api_kwargs, ) - @_log async def edit_message_caption( self, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + caption: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + business_connection_id: str | None = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """ 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 @@ -3432,6 +4597,12 @@ async def edit_message_caption( |sequenceargs| reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for an inline keyboard. + 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 @@ -3445,6 +4616,7 @@ async def edit_message_caption( "chat_id": chat_id, "message_id": message_id, "inline_message_id": inline_message_id, + "show_caption_above_media": show_caption_above_media, } return await self._send_message( @@ -3454,6 +4626,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, @@ -3461,30 +4634,32 @@ async def edit_message_caption( api_kwargs=api_kwargs, ) - @_log async def edit_message_media( self, media: "InputMedia", - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + business_connection_id: str | None = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """ - Use this method to edit animation, audio, document, photo, or video messages. If a message + Use this method to edit animation, audio, document, photo, or video messages, or to add + media to text messages. If a message is part of a message album, then it can be edited only to an audio for audio albums, only to a document for document albums and to a photo or a video otherwise. When an inline message is edited, a new file can't be uploaded; use a previously uploaded file via its :attr:`~telegram.File.file_id` or specify a URL. Note: - |editreplymarkup| + * |editreplymarkup| + * |bcid_edit_time| .. seealso:: :wiki:`Working with Files and Media ` @@ -3499,6 +4674,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 @@ -3518,6 +4696,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, @@ -3525,26 +4704,27 @@ async def edit_message_media( api_kwargs=api_kwargs, ) - @_log async def edit_message_reply_markup( self, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + business_connection_id: str | None = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """ Use this method to edit only the reply markup of messages sent by the bot or via the bot (for inline bots). Note: - |editreplymarkup| + * |editreplymarkup| + * |bcid_edit_time| Args: chat_id (:obj:`int` | :obj:`str`, optional): Required if inline_message_id is not @@ -3555,6 +4735,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 @@ -3574,6 +4757,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, @@ -3581,20 +4765,19 @@ async def edit_message_reply_markup( api_kwargs=api_kwargs, ) - @_log async def get_updates( self, - offset: Optional[int] = None, - limit: Optional[int] = None, - timeout: Optional[int] = None, - allowed_updates: Optional[Sequence[str]] = None, + offset: int | None = None, + limit: int | None = None, + timeout: TimePeriod | None = None, + allowed_updates: Sequence[str] | None = None, *, - read_timeout: float = 2, + 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, - ) -> Tuple[Update, ...]: + api_kwargs: JSONDict | None = None, + ) -> tuple[Update, ...]: """Use this method to receive incoming updates using long polling. Note: @@ -3621,24 +4804,28 @@ async def get_updates( between :tg-const:`telegram.constants.PollingLimit.MIN_LIMIT`- :tg-const:`telegram.constants.PollingLimit.MAX_LIMIT` are accepted. Defaults to ``100``. - timeout (:obj:`int`, optional): Timeout in seconds for long polling. Defaults to ``0``, - i.e. usual short polling. Should be positive, short polling should be used for - testing purposes only. + timeout (:obj:`int` | :class:`datetime.timedelta`, optional): Timeout in seconds for + long polling. Defaults to ``0``, i.e. usual short polling. Should be positive, + short polling should be used for testing purposes only. + + .. versionchanged:: v22.2 + |time-period-input| allowed_updates (Sequence[:obj:`str`]), optional): A sequence the types of updates you want your bot to receive. For example, specify ["message", "edited_channel_post", "callback_query"] to only receive updates of these types. See :class:`telegram.Update` for a complete list of available update types. Specify an empty sequence to receive all updates except - :attr:`telegram.Update.chat_member` (default). If not specified, the previous - setting will be used. Please note that this parameter doesn't affect updates - created before the call to the get_updates, so unwanted updates may be received for - a short period of time. + :attr:`telegram.Update.chat_member`, :attr:`telegram.Update.message_reaction` and + :attr:`telegram.Update.message_reaction_count` (default). If not specified, the + previous setting will be used. Please note that this parameter doesn't affect + updates created before the call to the get_updates, so unwanted updates may be + received for a short period of time. .. versionchanged:: 20.0 |sequenceargs| Returns: - Tuple[:class:`telegram.Update`] + tuple[:class:`telegram.Update`] Raises: :class:`telegram.error.TelegramError` @@ -3651,17 +4838,29 @@ async def get_updates( "allowed_updates": allowed_updates, } + # The "or 0" is needed for the case where read_timeout is None. + if not isinstance(read_timeout, DefaultValue): + arg_read_timeout: float = read_timeout or 0 + else: + arg_read_timeout = self._request[0].read_timeout or 0 + + read_timeout = ( + (arg_read_timeout + timeout.total_seconds()) + if isinstance(timeout, dtm.timedelta) + else (arg_read_timeout + timeout if timeout else arg_read_timeout) + ) + # Ideally we'd use an aggressive read timeout for the polling. However, # * Short polling should return within 2 seconds. # * Long polling poses a different problem: the connection might have been dropped while # waiting for the server to return and there's no way of knowing the connection had been # dropped in real time. result = cast( - List[JSONDict], + "list[JSONDict]", await self._post( "getUpdates", data, - read_timeout=read_timeout + timeout if timeout else read_timeout, + read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, @@ -3674,30 +4873,41 @@ 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 - @_log async def set_webhook( self, url: str, - certificate: Optional[FileInput] = None, - max_connections: Optional[int] = None, - allowed_updates: Optional[Sequence[str]] = None, - ip_address: Optional[str] = None, - drop_pending_updates: Optional[bool] = None, - secret_token: Optional[str] = None, + certificate: "FileInput | None" = None, + max_connections: int | None = None, + allowed_updates: Sequence[str] | None = None, + ip_address: str | None = None, + drop_pending_updates: bool | None = None, + secret_token: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to specify a url and receive incoming updates via an outgoing webhook. Whenever there is an update for the bot, Telegram will send an HTTPS POST request to the - specified url, containing An Update. In case of an unsuccessful request, - Telegram will give up after a reasonable amount of attempts. + specified url, containing An Update. In case of an unsuccessful request + (a request with response + `HTTP status code `_different + from ``2XY``), + Telegram will repeat the request and give up after a reasonable amount of attempts. If you'd like to make sure that the Webhook was set by you, you can specify secret data in the parameter :paramref:`secret_token`. If specified, the request will contain a header @@ -3715,18 +4925,6 @@ async def set_webhook( If you're having any trouble setting up webhooks, please check out this `guide to Webhooks`_. - Note: - 1. You will not be able to receive updates using :meth:`get_updates` for long as an - outgoing webhook is set up. - 2. To use a self-signed certificate, you need to upload your public key certificate - using certificate parameter. Please upload as InputFile, sending a String will not - work. - 3. Ports currently supported for Webhooks: - :attr:`telegram.constants.SUPPORTED_WEBHOOK_PORTS`. - - If you're having any trouble setting up webhooks, please check out this `guide to - Webhooks`_. - .. seealso:: :meth:`telegram.ext.Application.run_webhook`, :meth:`telegram.ext.Updater.start_webhook` @@ -3754,10 +4952,13 @@ async def set_webhook( "edited_channel_post", "callback_query"] to only receive updates of these types. See :class:`telegram.Update` for a complete list of available update types. Specify an empty sequence to receive all updates except - :attr:`telegram.Update.chat_member` (default). If not specified, the previous - setting will be used. Please note that this parameter doesn't affect updates - created before the call to the set_webhook, so unwanted updates may be received for - a short period of time. + :attr:`telegram.Update.chat_member`, + :attr:`telegram.Update.message_reaction` + and :attr:`telegram.Update.message_reaction_count` (default). If not + specified, the previous setting will be used. Please note that this + parameter doesn't affect + updates created before the call to the set_webhook, so unwanted update + may be received for a short period of time. .. versionchanged:: 20.0 |sequenceargs| @@ -3801,16 +5002,15 @@ async def set_webhook( api_kwargs=api_kwargs, ) - @_log async def delete_webhook( self, - drop_pending_updates: Optional[bool] = None, + drop_pending_updates: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to remove webhook integration if you decide to switch back to @@ -3839,16 +5039,15 @@ async def delete_webhook( api_kwargs=api_kwargs, ) - @_log async def leave_chat( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method for your bot to leave a group, supergroup or channel. @@ -3874,26 +5073,28 @@ async def leave_chat( api_kwargs=api_kwargs, ) - @_log async def get_chat( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Chat: + api_kwargs: JSONDict | None = None, + ) -> ChatFullInfo: """ Use this method to get up to date information about the chat (current name of the user for one-on-one conversations, current username of a user, group or channel, etc.). + .. versionchanged:: 21.2 + In accordance to Bot API 7.3, this method now returns a :class:`telegram.ChatFullInfo`. + Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| Returns: - :class:`telegram.Chat` + :class:`telegram.ChatFullInfo` Raises: :class:`telegram.error.TelegramError` @@ -3911,19 +5112,18 @@ async def get_chat( api_kwargs=api_kwargs, ) - return Chat.de_json(result, self) # type: ignore[return-value] + return ChatFullInfo.de_json(result, self) - @_log async def get_chat_administrators( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Tuple[ChatMember, ...]: + api_kwargs: JSONDict | None = None, + ) -> tuple[ChatMember, ...]: """ Use this method to get a list of administrators in a chat. @@ -3934,7 +5134,7 @@ async def get_chat_administrators( chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| Returns: - Tuple[:class:`telegram.ChatMember`]: On success, returns a tuple of ``ChatMember`` + tuple[:class:`telegram.ChatMember`]: On success, returns a tuple of ``ChatMember`` objects that contains information about all chat administrators except other bots. If the chat is a group or a supergroup and no administrators were appointed, only the creator will be returned. @@ -3955,16 +5155,15 @@ async def get_chat_administrators( ) return ChatMember.de_list(result, self) - @_log async def get_chat_member_count( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> int: """Use this method to get the number of members in a chat. @@ -3991,17 +5190,16 @@ async def get_chat_member_count( api_kwargs=api_kwargs, ) - @_log async def get_chat_member( self, - chat_id: Union[str, int], - user_id: Union[str, int], + chat_id: str | int, + user_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> ChatMember: """Use this method to get information about a member of a chat. The method is only guaranteed to work for other users if the bot is an administrator in the chat. @@ -4027,24 +5225,23 @@ async def get_chat_member( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - return ChatMember.de_json(result, self) # type: ignore[return-value] + return ChatMember.de_json(result, self) - @_log async def set_chat_sticker_set( self, - chat_id: Union[str, int], + chat_id: str | int, sticker_set_name: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to set a new group sticker set for a supergroup. The bot must be an administrator in the chat for this to work and must have the appropriate - admin rights. Use the field :attr:`telegram.Chat.can_set_sticker_set` optionally returned - in :meth:`get_chat` requests to check if the bot can use this method. + admin rights. Use the field :attr:`telegram.ChatFullInfo.can_set_sticker_set` optionally + returned in :meth:`get_chat` requests to check if the bot can use this method. Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_group| @@ -4065,20 +5262,19 @@ async def set_chat_sticker_set( api_kwargs=api_kwargs, ) - @_log async def delete_chat_sticker_set( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to delete a group sticker set from a supergroup. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. - Use the field :attr:`telegram.Chat.can_set_sticker_set` optionally returned in + Use the field :attr:`telegram.ChatFullInfo.can_set_sticker_set` optionally returned in :meth:`get_chat` requests to check if the bot can use this method. Args: @@ -4098,7 +5294,6 @@ async def delete_chat_sticker_set( api_kwargs=api_kwargs, ) - @_log async def get_webhook_info( self, *, @@ -4106,7 +5301,7 @@ async def get_webhook_info( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> WebhookInfo: """Use this method to get current webhook status. Requires no parameters. @@ -4125,25 +5320,24 @@ async def get_webhook_info( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - return WebhookInfo.de_json(result, self) # type: ignore[return-value] + return WebhookInfo.de_json(result, self) - @_log async def set_game_score( self, - user_id: Union[int, str], + user_id: int, score: int, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - force: Optional[bool] = None, - disable_edit_message: Optional[bool] = None, + chat_id: int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + force: bool | None = None, + disable_edit_message: bool | None = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """ Use this method to set the score of the specified user in a game message. @@ -4156,7 +5350,7 @@ async def set_game_score( decrease. This can be useful when fixing mistakes or banning cheaters. disable_edit_message (:obj:`bool`, optional): Pass :obj:`True`, if the game message should not be automatically edited to include the current scoreboard. - chat_id (:obj:`int` | :obj:`str`, optional): Required if :paramref:`inline_message_id` + chat_id (:obj:`int`, optional): Required if :paramref:`inline_message_id` is not specified. Unique identifier for the target chat. message_id (:obj:`int`, optional): Required if :paramref:`inline_message_id` is not specified. Identifier of the sent message. @@ -4192,20 +5386,19 @@ async def set_game_score( api_kwargs=api_kwargs, ) - @_log async def get_game_high_scores( self, - user_id: Union[int, str], - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, + user_id: int, + chat_id: int | None = None, + message_id: int | None = None, + inline_message_id: str | None = 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, - ) -> Tuple[GameHighScore, ...]: + api_kwargs: JSONDict | None = None, + ) -> tuple[GameHighScore, ...]: """ Use this method to get data for high score tables. Will return the score of the specified user and several of their neighbors in a game. @@ -4220,7 +5413,7 @@ async def get_game_high_scores( Args: user_id (:obj:`int`): Target user id. - chat_id (:obj:`int` | :obj:`str`, optional): Required if :paramref:`inline_message_id` + chat_id (:obj:`int`, optional): Required if :paramref:`inline_message_id` is not specified. Unique identifier for the target chat. message_id (:obj:`int`, optional): Required if :paramref:`inline_message_id` is not specified. Identifier of the sent message. @@ -4228,7 +5421,7 @@ async def get_game_high_scores( :paramref:`message_id` are not specified. Identifier of the inline message. Returns: - Tuple[:class:`telegram.GameHighScore`] + tuple[:class:`telegram.GameHighScore`] Raises: :class:`telegram.error.TelegramError` @@ -4253,43 +5446,47 @@ async def get_game_high_scores( return GameHighScore.de_list(result, self) - @_log async def send_invoice( self, - chat_id: Union[int, str], + chat_id: int | str, title: str, description: str, payload: str, - provider_token: str, currency: str, prices: Sequence["LabeledPrice"], - start_parameter: Optional[str] = None, - photo_url: Optional[str] = None, - photo_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - need_name: Optional[bool] = None, - need_phone_number: Optional[bool] = None, - need_email: Optional[bool] = None, - need_shipping_address: Optional[bool] = None, - is_flexible: Optional[bool] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - provider_data: Optional[Union[str, object]] = None, - send_phone_number_to_provider: Optional[bool] = None, - send_email_to_provider: Optional[bool] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - max_tip_amount: Optional[int] = None, - suggested_tip_amounts: Optional[Sequence[int]] = None, + provider_token: str | None = None, + start_parameter: str | None = None, + photo_url: str | None = None, + photo_size: int | None = None, + photo_width: int | None = None, + photo_height: int | None = None, + need_name: bool | None = None, + need_phone_number: bool | None = None, + need_email: bool | None = None, + need_shipping_address: bool | None = None, + is_flexible: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "InlineKeyboardMarkup | None" = None, + provider_data: str | object | None = None, + send_phone_number_to_provider: bool | None = None, + send_email_to_provider: bool | None = None, + max_tip_amount: int | None = None, + suggested_tip_amounts: Sequence[int] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = 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, + api_kwargs: JSONDict | None = None, ) -> Message: """Use this method to send invoices. @@ -4311,27 +5508,35 @@ async def send_invoice( payload (:obj:`str`): Bot-defined invoice payload. :tg-const:`telegram.Invoice.MIN_PAYLOAD_LENGTH`- :tg-const:`telegram.Invoice.MAX_PAYLOAD_LENGTH` bytes. This will not be - displayed to the user, use for your internal processes. - provider_token (:obj:`str`): Payments provider token, obtained via - `@BotFather `_. + displayed to the user, use it for your internal processes. + provider_token (:obj:`str`, optional): Payments provider token, obtained via + `@BotFather `_. Pass an empty string for payments in + |tg_stars|. + + .. versionchanged:: 21.11 + Bot API 7.4 made this parameter is optional and this is now reflected in the + function signature. + currency (:obj:`str`): Three-letter ISO 4217 currency code, see `more on currencies - `_. - prices (Sequence[:class:`telegram.LabeledPrice`)]: Price breakdown, a sequence + `_. Pass ``XTR`` for + payment in |tg_stars|. + prices (Sequence[:class:`telegram.LabeledPrice`]): Price breakdown, a sequence of components (e.g. product price, tax, discount, delivery cost, delivery tax, - bonus, etc.). + bonus, etc.). Must contain exactly one item for payment in |tg_stars|. .. 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``. + *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`. @@ -4360,19 +5565,20 @@ async def send_invoice( photo_width (:obj:`int`, optional): Photo width. photo_height (:obj:`int`, optional): Photo height. need_name (:obj:`bool`, optional): Pass :obj:`True`, if you require the user's full - name to complete the order. + name to complete the order. Ignored for payments in |tg_stars|. need_phone_number (:obj:`bool`, optional): Pass :obj:`True`, if you require the user's - phone number to complete the order. + phone number to complete the order. Ignored for payments in |tg_stars|. need_email (:obj:`bool`, optional): Pass :obj:`True`, if you require the user's email - to complete the order. + to complete the order. Ignored for payments in |tg_stars|. need_shipping_address (:obj:`bool`, optional): Pass :obj:`True`, if you require the - user's shipping address to complete the order. + user's shipping address to complete the order. Ignored for payments in + |tg_stars|. send_phone_number_to_provider (:obj:`bool`, optional): Pass :obj:`True`, if user's - phone number should be sent to provider. + phone number should be sent to provider. Ignored for payments in |tg_stars|. send_email_to_provider (:obj:`bool`, optional): Pass :obj:`True`, if user's email - address should be sent to provider. + address should be sent to provider. Ignored for payments in |tg_stars|. is_flexible (:obj:`bool`, optional): Pass :obj:`True`, if the final price depends on - the shipping method. + the shipping method. Ignored for payments in |tg_stars|. disable_notification (:obj:`bool`, optional): |disable_notification| protect_content (:obj:`bool`, optional): |protect_content| @@ -4381,11 +5587,45 @@ async def send_invoice( .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for an inline keyboard. If empty, one 'Pay total price' button will be shown. If not empty, the first button must be a Pay button. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: 20.8 + message_effect_id (:obj:`str`, optional): |message_effect_id| + + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 + + 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| Returns: :class:`telegram.Message`: On success, the sent Message is returned. @@ -4428,26 +5668,30 @@ async def send_invoice( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) - @_log - async def answer_shipping_query( # pylint: disable=invalid-name + async def answer_shipping_query( self, shipping_query_id: str, ok: bool, - shipping_options: Optional[Sequence[ShippingOption]] = None, - error_message: Optional[str] = None, + shipping_options: Sequence["ShippingOption"] | None = None, + error_message: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ If you sent an invoice requesting a shipping address and the parameter @@ -4494,18 +5738,17 @@ async def answer_shipping_query( # pylint: disable=invalid-name api_kwargs=api_kwargs, ) - @_log - async def answer_pre_checkout_query( # pylint: disable=invalid-name + async def answer_pre_checkout_query( self, pre_checkout_query_id: str, ok: bool, - error_message: Optional[str] = None, + error_message: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Once the user has confirmed their payment and shipping details, the Bot API sends the final @@ -4551,7 +5794,6 @@ async def answer_pre_checkout_query( # pylint: disable=invalid-name api_kwargs=api_kwargs, ) - @_log async def answer_web_app_query( self, web_app_query_id: str, @@ -4561,7 +5803,7 @@ async def answer_web_app_query( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> SentWebAppMessage: """Use this method to set the result of an interaction with a Web App and send a corresponding message on behalf of the user to the chat from which the query originated. @@ -4596,22 +5838,21 @@ async def answer_web_app_query( api_kwargs=api_kwargs, ) - return SentWebAppMessage.de_json(api_result, self) # type: ignore[return-value] + return SentWebAppMessage.de_json(api_result, self) - @_log async def restrict_chat_member( self, - chat_id: Union[str, int], - user_id: Union[str, int], + chat_id: str | int, + user_id: int, permissions: ChatPermissions, - until_date: Optional[Union[int, datetime]] = None, - use_independent_chat_permissions: Optional[bool] = None, + until_date: int | dtm.datetime | None = None, + use_independent_chat_permissions: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to restrict a user in a supergroup. The bot must be an administrator in @@ -4627,9 +5868,7 @@ async def restrict_chat_member( will be lifted for the user, unix time. If user is restricted for more than 366 days or less than 30 seconds from the current time, they are considered to be restricted forever. - For timezone naive :obj:`datetime.datetime` objects, the default timezone of the - bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is - used. + |tz-naive-dtms| permissions (:class:`telegram.ChatPermissions`): An object for new user permissions. use_independent_chat_permissions (:obj:`bool`, optional): Pass :obj:`True` if chat @@ -4672,29 +5911,32 @@ async def restrict_chat_member( api_kwargs=api_kwargs, ) - @_log async def promote_chat_member( self, - chat_id: Union[str, int], - user_id: Union[str, int], - can_change_info: Optional[bool] = None, - can_post_messages: Optional[bool] = None, - can_edit_messages: Optional[bool] = None, - can_delete_messages: Optional[bool] = None, - can_invite_users: Optional[bool] = None, - can_restrict_members: Optional[bool] = None, - can_pin_messages: Optional[bool] = None, - can_promote_members: Optional[bool] = None, - is_anonymous: Optional[bool] = None, - can_manage_chat: Optional[bool] = None, - can_manage_video_chats: Optional[bool] = None, - can_manage_topics: Optional[bool] = None, + chat_id: str | int, + user_id: int, + can_change_info: bool | None = None, + can_post_messages: bool | None = None, + can_edit_messages: bool | None = None, + can_delete_messages: bool | None = None, + can_invite_users: bool | None = None, + can_restrict_members: bool | None = None, + can_pin_messages: bool | None = None, + can_promote_members: bool | None = None, + is_anonymous: bool | None = None, + can_manage_chat: bool | None = None, + can_manage_video_chats: bool | None = None, + can_manage_topics: bool | None = None, + can_post_stories: bool | None = None, + can_edit_stories: bool | None = None, + can_delete_stories: bool | None = None, + can_manage_direct_messages: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to promote or demote a user in a supergroup or a channel. The bot must be @@ -4711,9 +5953,9 @@ async def promote_chat_member( is_anonymous (:obj:`bool`, optional): Pass :obj:`True`, if the administrator's presence in the chat is hidden. can_manage_chat (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can - access the chat event log, chat statistics, message statistics in channels, see - channel members, see anonymous administrators in supergroups and ignore slow mode. - Implied by any other administrator privilege. + access the chat event log, get boost list, see hidden supergroup and channel + members, report spam messages and ignore slow mode. Implied by any other + administrator privilege. .. versionadded:: 13.4 @@ -4725,25 +5967,45 @@ async def promote_chat_member( can_change_info (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can change chat title, photo and other settings. can_post_messages (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can - create channel posts, channels only. + post messages in the channel, or access channel statistics; for channels only. can_edit_messages (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can - edit messages of other users and can pin messages, channels only. + edit messages of other users and can pin messages, for channels only. can_delete_messages (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can delete messages of other users. can_invite_users (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can invite new users to the chat. can_restrict_members (:obj:`bool`, optional): Pass :obj:`True`, if the administrator - can restrict, ban or unban chat members. + can restrict, ban or unban chat members, or access supergroup statistics. For + backward compatibility, defaults to :obj:`True` for promotions of channel + administrators. can_pin_messages (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can - pin messages, supergroups only. + pin messages, for supergroups only. can_promote_members (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can add new administrators with a subset of their own privileges or demote administrators that they have promoted, directly or indirectly (promoted by administrators that were appointed by the user). can_manage_topics (:obj:`bool`, optional): Pass :obj:`True`, if the user is - allowed to create, rename, close, and reopen forum topics; supergroups only. + allowed to create, rename, close, and reopen forum topics; for supergroups only. .. versionadded:: 20.0 + can_post_stories (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can + post stories to the chat. + + .. versionadded:: 20.6 + can_edit_stories (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can + edit stories posted by other users, post stories to the chat page, pin chat + stories, and access the chat's story archive + + .. versionadded:: 20.6 + can_delete_stories (:obj:`bool`, optional): Pass :obj:`True`, if the administrator can + delete stories posted by other users. + + .. versionadded:: 20.6 + can_manage_direct_messages (:obj:`bool`, optional): Pass :obj:`True`, if the + administrator can manage direct messages within the channel and decline suggested + posts; for channels only + + .. versionadded:: 22.4 Returns: :obj:`bool`: On success, :obj:`True` is returned. @@ -4767,6 +6029,10 @@ async def promote_chat_member( "can_manage_chat": can_manage_chat, "can_manage_video_chats": can_manage_video_chats, "can_manage_topics": can_manage_topics, + "can_post_stories": can_post_stories, + "can_edit_stories": can_edit_stories, + "can_delete_stories": can_delete_stories, + "can_manage_direct_messages": can_manage_direct_messages, } return await self._post( @@ -4779,18 +6045,17 @@ async def promote_chat_member( api_kwargs=api_kwargs, ) - @_log async def set_chat_permissions( self, - chat_id: Union[str, int], + chat_id: str | int, permissions: ChatPermissions, - use_independent_chat_permissions: Optional[bool] = None, + use_independent_chat_permissions: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to set default chat permissions for all members. The bot must be an @@ -4838,18 +6103,17 @@ async def set_chat_permissions( api_kwargs=api_kwargs, ) - @_log async def set_chat_administrator_custom_title( self, - chat_id: Union[int, str], - user_id: Union[int, str], + chat_id: int | str, + user_id: int, custom_title: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to set a custom title for administrators promoted by the bot in a @@ -4881,16 +6145,15 @@ async def set_chat_administrator_custom_title( api_kwargs=api_kwargs, ) - @_log async def export_chat_invite_link( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> str: """ Use this method to generate a new primary invite link for a chat; any previously generated @@ -4925,35 +6188,40 @@ async def export_chat_invite_link( api_kwargs=api_kwargs, ) - @_log async def create_chat_invite_link( self, - chat_id: Union[str, int], - expire_date: Optional[Union[int, datetime]] = None, - member_limit: Optional[int] = None, - name: Optional[str] = None, - creates_join_request: Optional[bool] = None, + chat_id: str | int, + expire_date: int | dtm.datetime | None = None, + member_limit: int | None = None, + name: str | None = None, + creates_join_request: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> ChatInviteLink: """ Use this method to create an additional invite link for a chat. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. The link can be revoked using the method :meth:`revoke_chat_invite_link`. + Note: + When joining *public* groups via an invite link, Telegram clients may display the + usual "Join" button, effectively ignoring the invite link. In particular, the parameter + :paramref:`creates_join_request` has no effect in this case. + However, this behavior is undocument and may be subject to change. + See `this GitHub thread `_ + for some discussion. + .. versionadded:: 13.4 Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| expire_date (:obj:`int` | :obj:`datetime.datetime`, optional): Date when the link will expire. Integer input will be interpreted as Unix timestamp. - For timezone naive :obj:`datetime.datetime` objects, the default timezone of the - bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is - used. + |tz-naive-dtms| member_limit (:obj:`int`, optional): Maximum number of users that can be members of the chat simultaneously after joining the chat via this invite link; :tg-const:`telegram.constants.ChatInviteLinkLimit.MIN_MEMBER_LIMIT`- @@ -4993,23 +6261,22 @@ async def create_chat_invite_link( api_kwargs=api_kwargs, ) - return ChatInviteLink.de_json(result, self) # type: ignore[return-value] + return ChatInviteLink.de_json(result, self) - @_log async def edit_chat_invite_link( self, - chat_id: Union[str, int], - invite_link: Union[str, "ChatInviteLink"], - expire_date: Optional[Union[int, datetime]] = None, - member_limit: Optional[int] = None, - name: Optional[str] = None, - creates_join_request: Optional[bool] = None, + chat_id: str | int, + invite_link: "str | ChatInviteLink", + expire_date: int | dtm.datetime | None = None, + member_limit: int | None = None, + name: str | None = None, + creates_join_request: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> ChatInviteLink: """ Use this method to edit a non-primary invite link created by the bot. The bot must be an @@ -5025,15 +6292,13 @@ async def edit_chat_invite_link( Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| - invite_link (:obj:`str` | :obj:`telegram.ChatInviteLink`): The invite link to edit. + invite_link (:obj:`str` | :class:`telegram.ChatInviteLink`): The invite link to edit. .. versionchanged:: 20.0 - Now also accepts :obj:`telegram.ChatInviteLink` instances. + Now also accepts :class:`telegram.ChatInviteLink` instances. expire_date (:obj:`int` | :obj:`datetime.datetime`, optional): Date when the link will expire. - For timezone naive :obj:`datetime.datetime` objects, the default timezone of the - bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is - used. + |tz-naive-dtms| member_limit (:obj:`int`, optional): Maximum number of users that can be members of the chat simultaneously after joining the chat via this invite link; :tg-const:`telegram.constants.ChatInviteLinkLimit.MIN_MEMBER_LIMIT`- @@ -5075,19 +6340,18 @@ async def edit_chat_invite_link( api_kwargs=api_kwargs, ) - return ChatInviteLink.de_json(result, self) # type: ignore[return-value] + return ChatInviteLink.de_json(result, self) - @_log async def revoke_chat_invite_link( self, - chat_id: Union[str, int], - invite_link: Union[str, "ChatInviteLink"], + chat_id: str | int, + invite_link: "str | ChatInviteLink", *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> ChatInviteLink: """ Use this method to revoke an invite link created by the bot. If the primary link is @@ -5098,10 +6362,10 @@ async def revoke_chat_invite_link( Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| - invite_link (:obj:`str` | :obj:`telegram.ChatInviteLink`): The invite link to revoke. + invite_link (:obj:`str` | :class:`telegram.ChatInviteLink`): The invite link to revoke. .. versionchanged:: 20.0 - Now also accepts :obj:`telegram.ChatInviteLink` instances. + Now also accepts :class:`telegram.ChatInviteLink` instances. Returns: :class:`telegram.ChatInviteLink` @@ -5123,19 +6387,18 @@ async def revoke_chat_invite_link( api_kwargs=api_kwargs, ) - return ChatInviteLink.de_json(result, self) # type: ignore[return-value] + return ChatInviteLink.de_json(result, self) - @_log async def approve_chat_join_request( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to approve a chat join request. @@ -5166,17 +6429,16 @@ async def approve_chat_join_request( api_kwargs=api_kwargs, ) - @_log async def decline_chat_join_request( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to decline a chat join request. @@ -5207,17 +6469,16 @@ async def decline_chat_join_request( api_kwargs=api_kwargs, ) - @_log async def set_chat_photo( self, - chat_id: Union[str, int], + chat_id: str | int, photo: FileInput, *, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to set a new profile photo for the chat. @@ -5254,16 +6515,15 @@ async def set_chat_photo( api_kwargs=api_kwargs, ) - @_log async def delete_chat_photo( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to delete a chat photo. Photos can't be changed for private chats. The bot @@ -5291,17 +6551,16 @@ async def delete_chat_photo( api_kwargs=api_kwargs, ) - @_log async def set_chat_title( self, - chat_id: Union[str, int], + chat_id: str | int, title: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change the title of a chat. Titles can't be changed for private chats. @@ -5332,17 +6591,16 @@ async def set_chat_title( api_kwargs=api_kwargs, ) - @_log async def set_chat_description( self, - chat_id: Union[str, int], - description: Optional[str] = None, + chat_id: str | int, + description: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change the description of a group, a supergroup or a channel. The bot @@ -5374,18 +6632,68 @@ async def set_chat_description( api_kwargs=api_kwargs, ) - @_log + async def set_user_emoji_status( + self, + user_id: int, + emoji_status_custom_emoji_id: str | None = None, + emoji_status_expiration_date: int | dtm.datetime | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Changes the emoji status for a given user that previously allowed the bot to manage + their emoji status via the Mini App method + `requestEmojiStatusAccess `_ + . + + .. versionadded:: 21.8 + + Args: + user_id (:obj:`int`): Unique identifier of the target user + emoji_status_custom_emoji_id (:obj:`str`, optional): Custom emoji identifier of the + emoji status to set. Pass an empty string to remove the status. + emoji_status_expiration_date (:obj:`int` | :obj:`datetime.datetime`, optional): + Expiration date of the emoji status, if any, as unix timestamp or + :class:`datetime.datetime` object. + |tz-naive-dtms| + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "user_id": user_id, + "emoji_status_custom_emoji_id": emoji_status_custom_emoji_id, + "emoji_status_expiration_date": emoji_status_expiration_date, + } + return await self._post( + "setUserEmojiStatus", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def pin_chat_message( self, - chat_id: Union[str, int], + chat_id: str | int, message_id: int, disable_notification: ODVInput[bool] = DEFAULT_NONE, + business_connection_id: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to add a message to the list of pinned messages in a chat. If the @@ -5400,6 +6708,10 @@ async def pin_chat_message( disable_notification (:obj:`bool`, optional): Pass :obj:`True`, if it is not necessary to send a notification to all chat members about the new pinned message. Notifications are always disabled in channels and private chats. + business_connection_id (:obj:`str`, optional): Unique identifier of the business + connection on behalf of which the message will be pinned. + + .. versionadded:: 21.5 Returns: :obj:`bool`: On success, :obj:`True` is returned. @@ -5412,6 +6724,7 @@ async def pin_chat_message( "chat_id": chat_id, "message_id": message_id, "disable_notification": disable_notification, + "business_connection_id": business_connection_id, } return await self._post( @@ -5424,17 +6737,17 @@ async def pin_chat_message( api_kwargs=api_kwargs, ) - @_log async def unpin_chat_message( self, - chat_id: Union[str, int], - message_id: Optional[int] = None, + chat_id: str | int, + message_id: int | None = None, + business_connection_id: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to remove a message from the list of pinned messages in a chat. If the @@ -5445,8 +6758,13 @@ async def unpin_chat_message( Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| - message_id (:obj:`int`, optional): Identifier of a message to unpin. If not specified, + message_id (:obj:`int`, optional): Identifier of the message to unpin. Required if + :paramref:`business_connection_id` is specified. If not specified, the most recent pinned message (by sending date) will be unpinned. + business_connection_id (:obj:`str`, optional): Unique identifier of the business + connection on behalf of which the message will be unpinned. + + .. versionadded:: 21.5 Returns: :obj:`bool`: On success, :obj:`True` is returned. @@ -5455,7 +6773,11 @@ async def unpin_chat_message( :class:`telegram.error.TelegramError` """ - data: JSONDict = {"chat_id": chat_id, "message_id": message_id} + data: JSONDict = { + "chat_id": chat_id, + "message_id": message_id, + "business_connection_id": business_connection_id, + } return await self._post( "unpinChatMessage", @@ -5467,16 +6789,15 @@ async def unpin_chat_message( api_kwargs=api_kwargs, ) - @_log async def unpin_all_chat_messages( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to clear the list of pinned messages in a chat. If the @@ -5506,7 +6827,6 @@ async def unpin_all_chat_messages( api_kwargs=api_kwargs, ) - @_log async def get_sticker_set( self, name: str, @@ -5515,7 +6835,7 @@ async def get_sticker_set( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> StickerSet: """Use this method to get a sticker set. @@ -5539,9 +6859,8 @@ async def get_sticker_set( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - return StickerSet.de_json(result, self) # type: ignore[return-value] + return StickerSet.de_json(result, self) - @_log async def get_custom_emoji_stickers( self, custom_emoji_ids: Sequence[str], @@ -5550,9 +6869,8 @@ async def get_custom_emoji_stickers( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Tuple[Sticker, ...]: - # skipcq: FLK-D207 + api_kwargs: JSONDict | None = None, + ) -> tuple[Sticker, ...]: """ Use this method to get information about emoji stickers by their identifiers. @@ -5568,7 +6886,7 @@ async def get_custom_emoji_stickers( |sequenceargs| Returns: - Tuple[:class:`telegram.Sticker`] + tuple[:class:`telegram.Sticker`] Raises: :class:`telegram.error.TelegramError` @@ -5586,34 +6904,31 @@ async def get_custom_emoji_stickers( ) return Sticker.de_list(result, self) - @_log async def upload_sticker_file( self, - user_id: Union[str, int], - png_sticker: Optional[ - FileInput - ] = None, # Deprecated since bot api 6.6. Optional for compatiblity. - # New parameters since bot api 6.6: - # <--- - sticker: Optional[FileInput] = None, # Actually required, but optional for compatibility. - sticker_format: Optional[str] = None, # Actually required, but optional for compatibility. - # ---> + user_id: int, + sticker: FileInput, + sticker_format: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> File: """ Use this method to upload a file with a sticker for later use in the :meth:`create_new_sticker_set` and :meth:`add_sticker_to_set` methods (can be used multiple times). + .. versionchanged:: 20.5 + Removed deprecated parameter ``png_sticker``. + Args: user_id (:obj:`int`): User identifier of sticker file owner. - sticker (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path`): - A file with the sticker in the ``".WEBP"``, ``".PNG"``, ``".TGS"`` or ``".WEBM"`` + sticker (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | \ + :obj:`bytes` | :class:`pathlib.Path`): A file with the sticker in the + ``".WEBP"``, ``".PNG"``, ``".TGS"`` or ``".WEBM"`` format. See `here `_ for technical requirements . |uploadinput| @@ -5626,65 +6941,17 @@ async def upload_sticker_file( .. versionadded:: 20.2 - png_sticker (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path`, \ - optional): - **PNG** image with the sticker, must be up to 512 kilobytes in size, - dimensions must not exceed 512px, and either width or height must be exactly 512px. - |uploadinput| - - .. versionchanged:: 13.2 - Accept :obj:`bytes` as input. - - .. versionchanged:: 20.0 - File paths as input is also accepted for bots *not* running in - :paramref:`~telegram.Bot.local_mode`. - - .. deprecated:: 20.2 - Since Bot API 6.6, this parameter has been deprecated in favor of - :paramref:`sticker` and :paramref:`sticker_format`. - Returns: :class:`telegram.File`: On success, the uploaded File is returned. Raises: - :exc:`TypeError`: Raised when: 1) ``sticker`` and ``sticker_format`` are passed - together with ``png_sticker``. 2) If neither the new parameters nor - the deprecated parameters are passed. - - :class:`telegram.error.TelegramError`: For other errors. + :class:`telegram.error.TelegramError` """ - if not png_sticker and not all((sticker, sticker_format)): - raise TypeError( - "Since Bot API 6.6, the parameters `sticker` and `sticker_format` " - "are required, please pass them as well." - ) - - if png_sticker and any((sticker, sticker_format)): - raise TypeError( - "Since Bot API 6.6, the parameters `sticker` and `sticker_format` " - "are mutually exclusive with the deprecated parameter " - "`png_sticker`. Please use the new parameters " - "`sticker` and `sticker_format` instead." - ) - # If we had allowed this, the created sticker set would have used the newer parameters - # only, which would have been confusing. - - if png_sticker: - self._warn( - "Since Bot API 6.6, the parameter `png_sticker` for " - "`upload_sticker_file` is deprecated. Please use the new parameters " - "`sticker` and `sticker_format` instead.", - stacklevel=3, - category=PTBDeprecationWarning, - ) - data: JSONDict = { "user_id": user_id, - "sticker": self._parse_file_input(sticker), # type: ignore[arg-type] + "sticker": self._parse_file_input(sticker), "sticker_format": sticker_format, - # Deprecated param since bot api 6.6 - "png_sticker": self._parse_file_input(png_sticker), # type: ignore[arg-type] } result = await self._post( "uploadStickerFile", @@ -5695,210 +6962,101 @@ async def upload_sticker_file( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - return File.de_json(result, self) # type: ignore[return-value] + return File.de_json(result, self) - @_log - async def create_new_sticker_set( + async def add_sticker_to_set( self, - user_id: Union[str, int], + user_id: int, name: str, - title: str, - # Deprecated params since bot api 6.6 - # <---- - emojis: Optional[str] = None, # Was made optional for compatibility purposes - png_sticker: Optional[FileInput] = None, - mask_position: Optional[MaskPosition] = None, - tgs_sticker: Optional[FileInput] = None, - webm_sticker: Optional[FileInput] = None, - # ----> - sticker_type: Optional[str] = None, - # New params since bot api 6.6 - # <---- - stickers: Optional[ - Sequence[InputSticker] - ] = None, # Actually a required param. Optional for compat. - sticker_format: Optional[str] = None, # Actually a required param. Optional for compat. - needs_repainting: Optional[bool] = None, - # ----> - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + sticker: "InputSticker", + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, ) -> bool: """ - Use this method to create new sticker set owned by a user. - The bot will be able to edit the created sticker set thus created. - - .. versionchanged:: 20.0 - The parameter ``contains_masks`` has been removed. Use :paramref:`sticker_type` - instead. + Use this method to add a new sticker to a set created by the bot. The format of the added + sticker must match the format of the other stickers in the set. Emoji sticker sets can have + up to :tg-const:`telegram.constants.StickerSetLimit.MAX_EMOJI_STICKERS` stickers. Other + sticker sets can have up to + :tg-const:`telegram.constants.StickerSetLimit.MAX_STATIC_STICKERS` stickers. .. versionchanged:: 20.2 - Since Bot API 6.6, the parameters :paramref:`stickers` and :paramref:`sticker_format` - replace the parameters :paramref:`png_sticker`, :paramref:`tgs_sticker`, - :paramref:`webm_sticker`, :paramref:`emojis`, and :paramref:`mask_position`. + Since Bot API 6.6, the parameter :paramref:`sticker` replace the parameters + ``png_sticker``, ``tgs_sticker``, ``webm_sticker``, ``emojis``, and ``mask_position``. - .. |api6_6_depr| replace:: Since Bot API 6.6, this argument is deprecated in favour of - :paramref:`stickers` and :paramref:`sticker_format`. + .. versionchanged:: 20.5 + Removed deprecated parameters ``png_sticker``, ``tgs_sticker``, ``webm_sticker``, + ``emojis``, and ``mask_position``. Args: user_id (:obj:`int`): User identifier of created sticker set owner. - name (:obj:`str`): Short name of sticker set, to be used in t.me/addstickers/ URLs - (e.g., animals). Can contain only english letters, digits and underscores. - Must begin with a letter, can't contain consecutive underscores and - must end in "_by_". is case insensitive. - :tg-const:`telegram.constants.StickerLimit.MIN_NAME_AND_TITLE`- - :tg-const:`telegram.constants.StickerLimit.MAX_NAME_AND_TITLE` characters. - title (:obj:`str`): Sticker set title, - :tg-const:`telegram.constants.StickerLimit.MIN_NAME_AND_TITLE`- - :tg-const:`telegram.constants.StickerLimit.MAX_NAME_AND_TITLE` characters. - - stickers (Sequence[:class:`telegram.InputSticker`]): A sequence of - :tg-const:`telegram.constants.StickerSetLimit.MIN_INITIAL_STICKERS`- - :tg-const:`telegram.constants.StickerSetLimit.MAX_INITIAL_STICKERS` initial - stickers to be added to the sticker set. - - .. versionadded:: 20.2 - - sticker_format (:obj:`str`): Format of stickers in the set, must be one of - :attr:`~telegram.constants.StickerFormat.STATIC`, - :attr:`~telegram.constants.StickerFormat.ANIMATED` or - :attr:`~telegram.constants.StickerFormat.VIDEO`. + name (:obj:`str`): Sticker set name. + sticker (:class:`telegram.InputSticker`): An object with information about the added + sticker. If exactly the same sticker had already been added to the set, then the + set isn't changed. .. versionadded:: 20.2 - sticker_type (:obj:`str`, optional): Type of stickers in the set, pass - :attr:`telegram.Sticker.REGULAR` or :attr:`telegram.Sticker.MASK`, or - :attr:`telegram.Sticker.CUSTOM_EMOJI`. By default, a regular sticker set is created - - .. versionadded:: 20.0 - - needs_repainting (:obj:`bool`, optional): Pass :obj:`True` if stickers in the sticker - set must be repainted to the color of text when used in messages, the accent color - if used as emoji status, white on chat photos, or another appropriate color based - on context; for custom emoji sticker sets only. + Returns: + :obj:`bool`: On success, :obj:`True` is returned. - .. versionadded:: 20.2 + Raises: + :class:`telegram.error.TelegramError` - png_sticker (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path`, \ - optional): **PNG** image with the sticker, - must be up to 512 kilobytes in size, dimensions must not exceed 512px, - and either width or height must be exactly 512px. - |fileinput| + """ + data: JSONDict = { + "user_id": user_id, + "name": name, + "sticker": sticker, + } - .. versionchanged:: 13.2 - Accept :obj:`bytes` as input. + return await self._post( + "addStickerToSet", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) - .. versionchanged:: 20.0 - File paths as input is also accepted for bots *not* running in - :paramref:`~telegram.Bot.local_mode`. - - .. deprecated:: 20.2 - |api6_6_depr| - - tgs_sticker (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path`, \ - optional): **TGS** animation with the sticker. |uploadinput| - See https://core.telegram.org/stickers#animation-requirements for technical - requirements. - - .. versionchanged:: 13.2 - Accept :obj:`bytes` as input. - - .. versionchanged:: 20.0 - File paths as input is also accepted for bots *not* running in - :paramref:`~telegram.Bot.local_mode`. - - .. deprecated:: 20.2 - |api6_6_depr| - - webm_sticker (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path`,\ - optional): **WEBM** video with the sticker. |uploadinput| - See https://core.telegram.org/stickers#video-requirements for - technical requirements. - - .. versionadded:: 13.11 - - .. versionchanged:: 20.0 - File paths as input is also accepted for bots *not* running in - :paramref:`~telegram.Bot.local_mode`. - - .. deprecated:: 20.2 - |api6_6_depr| - - emojis (:obj:`str`, optional): One or more emoji corresponding to the sticker. - - .. deprecated:: 20.2 - |api6_6_depr| + async def set_sticker_position_in_set( + self, + sticker: "str | Sticker", + position: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Use this method to move a sticker in a set created by the bot to a specific position. - mask_position (:class:`telegram.MaskPosition`, optional): Position where the mask - should be placed on faces. + Args: + sticker (:obj:`str` | :class:`~telegram.Sticker`): File identifier of the sticker or + the sticker object. - .. deprecated:: 20.2 - |api6_6_depr| + .. versionchanged:: 21.10 + Accepts also :class:`telegram.Sticker` instances. + position (:obj:`int`): New sticker position in the set, zero-based. Returns: :obj:`bool`: On success, :obj:`True` is returned. Raises: - :exc:`TypeError`: Raised when: 1) ``stickers`` and ``sticker_format`` are passed - together with the deprecated parameters. 2) If neither the new parameters nor - the deprecated parameters are passed. + :class:`telegram.error.TelegramError` - :class:`telegram.error.TelegramError`: For other errors. """ - pre_api_6_6_params = { - "emojis": emojis, - "png_sticker": png_sticker, - "mask_position": mask_position, - "tgs_sticker": tgs_sticker, - "webm_sticker": webm_sticker, - } - - if not any(pre_api_6_6_params.values()) and not all((stickers, sticker_format)): - raise TypeError( - "Since Bot API 6.6, the parameters `stickers` and `sticker_format` " - "are required, please pass them as well." - ) - - if any(pre_api_6_6_params.values()) and any((stickers, sticker_format)): - raise TypeError( - "Since Bot API 6.6, the parameters `stickers` and `sticker_format` " - "are mutually exclusive with the deprecated parameters " - f"{set(pre_api_6_6_params)}. Please use the new parameter " - "`stickers` and `sticker_format` instead." - ) - # If we had allowed this, the created sticker set would have used the newer parameters - # only, which would have been confusing. - - if any(pre_api_6_6_params.values()): - self._warn( - f"Since Bot API 6.6, the parameters {set(pre_api_6_6_params)} for " - "`create_new_sticker_set` are deprecated. Please use the new parameter " - "`stickers` and `sticker_format` instead.", - stacklevel=3, - category=PTBDeprecationWarning, - ) - data: JSONDict = { - "user_id": user_id, - "name": name, - "title": title, - "stickers": stickers, - "sticker_format": sticker_format, - "sticker_type": sticker_type, - "needs_repainting": needs_repainting, - # Deprecated params since bot api 6.6 - "emojis": emojis, - "png_sticker": self._parse_file_input(png_sticker) if png_sticker else None, - "tgs_sticker": self._parse_file_input(tgs_sticker) if tgs_sticker else None, - "webm_sticker": self._parse_file_input(webm_sticker) if webm_sticker else None, - "mask_position": mask_position, + "sticker": sticker if isinstance(sticker, str) else sticker.file_id, + "position": position, } - return await self._post( - "createNewStickerSet", + "setStickerPositionInSet", data, read_timeout=read_timeout, write_timeout=write_timeout, @@ -5907,204 +7065,90 @@ async def create_new_sticker_set( api_kwargs=api_kwargs, ) - @_log - async def add_sticker_to_set( + async def create_new_sticker_set( self, - user_id: Union[str, int], + user_id: int, name: str, - # Deprecated params since bot api 6.6 - # ---- - emojis: Optional[str] = None, # Was made optional for compatibility reasons - png_sticker: Optional[FileInput] = None, - mask_position: Optional[MaskPosition] = None, - tgs_sticker: Optional[FileInput] = None, - webm_sticker: Optional[FileInput] = None, - # ---- - # New in bot api 6.6: - sticker: Optional[ - InputSticker - ] = None, # Actually a required param, but is optional for compat. + title: str, + stickers: Sequence["InputSticker"], + sticker_type: str | None = None, + needs_repainting: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ - Use this method to add a new sticker to a set created by the bot. The format of the added - sticker must match the format of the other stickers in the set. Emoji sticker sets can have - up to :tg-const:`telegram.constants.StickerSetLimit.MAX_EMOJI_STICKERS` stickers. Animated - and video sticker sets can have up to - :tg-const:`telegram.constants.StickerSetLimit.MAX_ANIMATED_STICKERS` stickers. Static - sticker sets can have up to - :tg-const:`telegram.constants.StickerSetLimit.MAX_STATIC_STICKERS` stickers. + Use this method to create new sticker set owned by a user. + The bot will be able to edit the created sticker set thus created. - .. |api6_6| replace:: Since Bot API 6.6, this argument is deprecated in favour of - :paramref:`sticker`. + .. versionchanged:: 20.0 + The parameter ``contains_masks`` has been removed. Use :paramref:`sticker_type` + instead. .. versionchanged:: 20.2 - Since Bot API 6.6, the parameter :paramref:`sticker` replace the parameters - :paramref:`png_sticker`, :paramref:`tgs_sticker`, :paramref:`webm_sticker`, - :paramref:`emojis`, and :paramref:`mask_position`. - - Args: - user_id (:obj:`int`): User identifier of created sticker set owner. - name (:obj:`str`): Sticker set name. - sticker (:class:`telegram.InputSticker`): An object with information about the added - sticker. If exactly the same sticker had already been added to the set, then the - set isn't changed. - - .. versionadded:: 20.2 - - emojis (:obj:`str`, optional): One or more emoji corresponding to the sticker. - - .. deprecated:: 20.2 - |api6_6| - - png_sticker (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path`, \ - optional): **PNG** image with the sticker, - must be up to 512 kilobytes in size, dimensions must not exceed 512px, - and either width or height must be exactly 512px. - |fileinput| - - .. versionchanged:: 13.2 - Accept :obj:`bytes` as input. - - .. versionchanged:: 20.0 - File paths as input is also accepted for bots *not* running in - :paramref:`~telegram.Bot.local_mode`. - - .. deprecated:: 20.2 - |api6_6| - - mask_position (:class:`telegram.MaskPosition`, optional): Position where the mask - should be placed on faces. + Since Bot API 6.6, the parameters :paramref:`stickers` and :paramref:`sticker_format` + replace the parameters ``png_sticker``, ``tgs_sticker``,``webm_sticker``, ``emojis``, + and ``mask_position``. - .. deprecated:: 20.2 - |api6_6| + .. versionchanged:: 20.5 + Removed the deprecated parameters mentioned above and adjusted the order of the + parameters. - tgs_sticker (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path`, \ - optional): **TGS** animation with the sticker. |uploadinput| - See https://core.telegram.org/stickers#animation-requirements for technical - requirements. + .. versionremoved:: 21.2 + Removed the deprecated parameter ``sticker_format``. - .. versionchanged:: 13.2 - Accept :obj:`bytes` as input. + Args: + user_id (:obj:`int`): User identifier of created sticker set owner. + name (:obj:`str`): Short name of sticker set, to be used in t.me/addstickers/ URLs + (e.g., animals). Can contain only english letters, digits and underscores. + Must begin with a letter, can't contain consecutive underscores and + must end in "_by_". is case insensitive. + :tg-const:`telegram.constants.StickerLimit.MIN_NAME_AND_TITLE`- + :tg-const:`telegram.constants.StickerLimit.MAX_NAME_AND_TITLE` characters. + title (:obj:`str`): Sticker set title, + :tg-const:`telegram.constants.StickerLimit.MIN_NAME_AND_TITLE`- + :tg-const:`telegram.constants.StickerLimit.MAX_NAME_AND_TITLE` characters. - .. versionchanged:: 20.0 - File paths as input is also accepted for bots *not* running in - :paramref:`~telegram.Bot.local_mode`. + stickers (Sequence[:class:`telegram.InputSticker`]): A sequence of + :tg-const:`telegram.constants.StickerSetLimit.MIN_INITIAL_STICKERS`- + :tg-const:`telegram.constants.StickerSetLimit.MAX_INITIAL_STICKERS` initial + stickers to be added to the sticker set. - .. deprecated:: 20.2 - |api6_6| + .. versionadded:: 20.2 - webm_sticker (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path`,\ - optional): **WEBM** video with the sticker. |uploadinput| - See https://core.telegram.org/stickers#video-requirements for - technical requirements. + sticker_type (:obj:`str`, optional): Type of stickers in the set, pass + :attr:`telegram.Sticker.REGULAR` or :attr:`telegram.Sticker.MASK`, or + :attr:`telegram.Sticker.CUSTOM_EMOJI`. By default, a regular sticker set is created - .. versionadded:: 13.11 + .. versionadded:: 20.0 - .. versionchanged:: 20.0 - File paths as input is also accepted for bots *not* running in - :paramref:`~telegram.Bot.local_mode`. + needs_repainting (:obj:`bool`, optional): Pass :obj:`True` if stickers in the sticker + set must be repainted to the color of text when used in messages, the accent color + if used as emoji status, white on chat photos, or another appropriate color based + on context; for custom emoji sticker sets only. - .. deprecated:: 20.2 - |api6_6| + .. versionadded:: 20.2 Returns: :obj:`bool`: On success, :obj:`True` is returned. Raises: - :exc:`TypeError`: Raised when: 1) ``sticker`` is passed - together with the deprecated parameters. 2) If neither the new parameter nor - the deprecated parameters are passed. - - :class:`telegram.error.TelegramError`: For other errors. - + :class:`telegram.error.TelegramError` """ - pre_api_6_6_params = { - "emojis": emojis, - "png_sticker": png_sticker, - "mask_position": mask_position, - "tgs_sticker": tgs_sticker, - "webm_sticker": webm_sticker, - } - - if not any(pre_api_6_6_params.values()) and not sticker: - raise TypeError( - "The parameter `sticker` is a required argument since Bot API 6.6 and" - " must be passed." - ) - - if any(pre_api_6_6_params.values()) and sticker: - raise TypeError( - "Since Bot API 6.6, the parameter `sticker` " - "is mutually exclusive with the deprecated parameters " - f"{set(pre_api_6_6_params)}. Please use the new parameter " - "`sticker` instead." - ) - - if any(pre_api_6_6_params.values()): - self._warn( - f"Since Bot API 6.6, the parameters {set(pre_api_6_6_params)} for " - "`add_sticker_to_set` are deprecated. Please use the new parameter `sticker` " - "instead.", - stacklevel=3, - category=PTBDeprecationWarning, - ) - data: JSONDict = { "user_id": user_id, "name": name, - "sticker": sticker, - # Deprecated params since bot api 6.6: - "emojis": emojis, - "png_sticker": self._parse_file_input(png_sticker) if png_sticker else None, - "tgs_sticker": self._parse_file_input(tgs_sticker) if tgs_sticker else None, - "webm_sticker": self._parse_file_input(webm_sticker) if webm_sticker else None, - "mask_position": mask_position, + "title": title, + "stickers": stickers, + "sticker_type": sticker_type, + "needs_repainting": needs_repainting, } return await self._post( - "addStickerToSet", - data, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - @_log - async def set_sticker_position_in_set( - self, - sticker: str, - position: int, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> bool: - """Use this method to move a sticker in a set created by the bot to a specific position. - - Args: - sticker (:obj:`str`): File identifier of the sticker. - position (:obj:`int`): New sticker position in the set, zero-based. - - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - - """ - data: JSONDict = {"sticker": sticker, "position": position} - return await self._post( - "setStickerPositionInSet", + "createNewStickerSet", data, read_timeout=read_timeout, write_timeout=write_timeout, @@ -6113,21 +7157,24 @@ async def set_sticker_position_in_set( api_kwargs=api_kwargs, ) - @_log async def delete_sticker_from_set( self, - sticker: str, + sticker: "str | Sticker", *, 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to delete a sticker from a set created by the bot. Args: - sticker (:obj:`str`): File identifier of the sticker. + sticker (:obj:`str` | :class:`telegram.Sticker`): File identifier of the sticker or + the sticker object. + + .. versionchanged:: 21.10 + Accepts also :class:`telegram.Sticker` instances. Returns: :obj:`bool`: On success, :obj:`True` is returned. @@ -6136,7 +7183,7 @@ async def delete_sticker_from_set( :class:`telegram.error.TelegramError` """ - data: JSONDict = {"sticker": sticker} + data: JSONDict = {"sticker": sticker if isinstance(sticker, str) else sticker.file_id} return await self._post( "deleteStickerFromSet", data, @@ -6147,7 +7194,6 @@ async def delete_sticker_from_set( api_kwargs=api_kwargs, ) - @_log async def delete_sticker_set( self, name: str, @@ -6156,7 +7202,7 @@ async def delete_sticker_set( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to delete a sticker set that was created by the bot. @@ -6184,41 +7230,54 @@ async def delete_sticker_set( api_kwargs=api_kwargs, ) - @_log async def set_sticker_set_thumbnail( self, name: str, - user_id: Union[str, int], - thumbnail: Optional[FileInput] = None, + user_id: int, + format: str, # pylint: disable=redefined-builtin + thumbnail: "FileInput | None" = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to set the thumbnail of a regular or mask sticker set. The format of the thumbnail file must match the format of the stickers in the set. .. versionadded:: 20.2 + .. versionchanged:: 21.1 + As per Bot API 7.2, the new argument :paramref:`format` will be required, and thus the + order of the arguments had to be changed. + Args: name (:obj:`str`): Sticker set name user_id (:obj:`int`): User identifier of created sticker set owner. - thumbnail (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path`, \ - optional): A **.WEBP** or **.PNG** image with the thumbnail, must + format (:obj:`str`): Format of the added sticker, must be one of + :tg-const:`telegram.constants.StickerFormat.STATIC` for a + ``.WEBP`` or ``.PNG`` image, :tg-const:`telegram.constants.StickerFormat.ANIMATED` + for a ``.TGS`` animation, :tg-const:`telegram.constants.StickerFormat.VIDEO` for a + ``.WEBM`` video. + + .. versionadded:: 21.1 + + thumbnail (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | \ + :obj:`bytes` | :class:`pathlib.Path`, optional): A **.WEBP** or **.PNG** image + with the thumbnail, must be up to :tg-const:`telegram.constants.StickerSetLimit.MAX_STATIC_THUMBNAIL_SIZE` kilobytes in size and have width and height of exactly :tg-const:`telegram.constants.StickerSetLimit.STATIC_THUMB_DIMENSIONS` px, or a **.TGS** animation with the thumbnail up to :tg-const:`telegram.constants.StickerSetLimit.MAX_ANIMATED_THUMBNAIL_SIZE` kilobytes in size; see - `the docs `_ for - animated sticker technical requirements, or a **.WEBM** video with the thumbnail up + `the docs `_ for + animated sticker technical requirements, or a ``.WEBM`` video with the thumbnail up to :tg-const:`telegram.constants.StickerSetLimit.MAX_ANIMATED_THUMBNAIL_SIZE` kilobytes in size; see - `this `_ for video sticker - technical requirements. + `this `_ for video + sticker technical requirements. |fileinput| @@ -6233,99 +7292,11 @@ async def set_sticker_set_thumbnail( :class:`telegram.error.TelegramError` """ - return await self._set_sticker_set_thumbnail( - name=name, - user_id=user_id, - thumbnail=thumbnail, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - @_log - async def set_sticker_set_thumb( - self, - name: str, - user_id: Union[str, int], - thumb: Optional[FileInput] = None, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> bool: - """Use this method to set the thumbnail of a sticker set. Animated thumbnails can be set - for animated sticker sets only. Video thumbnails can be set only for video sticker sets - only. - - .. deprecated:: 20.2 - Bot API 6.6 renamed this method to :meth:`~Bot.set_sticker_set_thumbnail`. - - Args: - name (:obj:`str`): Sticker set name - user_id (:obj:`int`): User identifier of created sticker set owner. - thumb (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path`, \ - optional): A **PNG** image with the thumbnail, must - be up to 128 kilobytes in size and have width and height exactly 100px, or a - **TGS** animation with the thumbnail up to 32 kilobytes in size; see - https://core.telegram.org/stickers#animation-requirements for animated - sticker technical requirements, or a **WEBM** video with the thumbnail up to 32 - kilobytes in size; see - https://core.telegram.org/stickers#video-requirements for video sticker - technical requirements. - |fileinput| - Animated sticker set thumbnails can't be uploaded via HTTP URL. - - .. versionchanged:: 13.2 - Accept :obj:`bytes` as input. - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - - """ - self._warn( - message=( - "Bot API 6.6 renamed the method 'setStickerSetThumb' to 'setStickerSetThumbnail', " - "hence method 'set_sticker_set_thumb' was renamed to 'set_sticker_set_thumbnail' " - "in PTB." - ), - category=PTBDeprecationWarning, - stacklevel=3, - ) - - return await self._set_sticker_set_thumbnail( - name=name, - user_id=user_id, - thumbnail=thumb, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - @_log - async def _set_sticker_set_thumbnail( - self, - name: str, - user_id: Union[str, int], - thumbnail: Optional[FileInput] = None, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> bool: data: JSONDict = { "name": name, "user_id": user_id, "thumbnail": self._parse_file_input(thumbnail) if thumbnail else None, + "format": format, } return await self._post( @@ -6338,7 +7309,6 @@ async def _set_sticker_set_thumbnail( api_kwargs=api_kwargs, ) - @_log async def set_sticker_set_title( self, name: str, @@ -6348,7 +7318,7 @@ async def set_sticker_set_title( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to set the title of a created sticker set. @@ -6379,17 +7349,16 @@ async def set_sticker_set_title( api_kwargs=api_kwargs, ) - @_log async def set_sticker_emoji_list( self, - sticker: str, + sticker: "str | Sticker", emoji_list: Sequence[str], *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change the list of emoji assigned to a regular or custom emoji sticker. @@ -6398,7 +7367,11 @@ async def set_sticker_emoji_list( .. versionadded:: 20.2 Args: - sticker (:obj:`str`): File identifier of the sticker. + sticker (:obj:`str` | :class:`~telegram.Sticker`): File identifier of the sticker or + the sticker object. + + .. versionchanged:: 21.10 + Accepts also :class:`telegram.Sticker` instances. emoji_list (Sequence[:obj:`str`]): A sequence of :tg-const:`telegram.constants.StickerLimit.MIN_STICKER_EMOJI`- :tg-const:`telegram.constants.StickerLimit.MAX_STICKER_EMOJI` emoji associated with @@ -6410,7 +7383,10 @@ async def set_sticker_emoji_list( Raises: :class:`telegram.error.TelegramError` """ - data: JSONDict = {"sticker": sticker, "emoji_list": emoji_list} + data: JSONDict = { + "sticker": sticker if isinstance(sticker, str) else sticker.file_id, + "emoji_list": emoji_list, + } return await self._post( "setStickerEmojiList", data, @@ -6421,17 +7397,16 @@ async def set_sticker_emoji_list( api_kwargs=api_kwargs, ) - @_log async def set_sticker_keywords( self, - sticker: str, - keywords: Optional[Sequence[str]] = None, + sticker: "str | Sticker", + keywords: Sequence[str] | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change search keywords assigned to a regular or custom emoji sticker. @@ -6440,7 +7415,11 @@ async def set_sticker_keywords( .. versionadded:: 20.2 Args: - sticker (:obj:`str`): File identifier of the sticker. + sticker (:obj:`str` | :class:`~telegram.Sticker`): File identifier of the sticker or + the sticker object. + + .. versionchanged:: 21.10 + Accepts also :class:`telegram.Sticker` instances. keywords (Sequence[:obj:`str`]): A sequence of 0-:tg-const:`telegram.constants.StickerLimit.MAX_SEARCH_KEYWORDS` search keywords for the sticker with total length up to @@ -6452,7 +7431,10 @@ async def set_sticker_keywords( Raises: :class:`telegram.error.TelegramError` """ - data: JSONDict = {"sticker": sticker, "keywords": keywords} + data: JSONDict = { + "sticker": sticker if isinstance(sticker, str) else sticker.file_id, + "keywords": keywords, + } return await self._post( "setStickerKeywords", data, @@ -6463,17 +7445,16 @@ async def set_sticker_keywords( api_kwargs=api_kwargs, ) - @_log async def set_sticker_mask_position( self, - sticker: str, - mask_position: Optional[MaskPosition] = None, + sticker: "str | Sticker", + mask_position: MaskPosition | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change the mask position of a mask sticker. @@ -6482,7 +7463,11 @@ async def set_sticker_mask_position( .. versionadded:: 20.2 Args: - sticker (:obj:`str`): File identifier of the sticker. + sticker (:obj:`str` | :class:`~telegram.Sticker`): File identifier of the sticker or + the sticker object. + + .. versionchanged:: 21.10 + Accepts also :class:`telegram.Sticker` instances. mask_position (:class:`telegram.MaskPosition`, optional): A object with the position where the mask should be placed on faces. Omit the parameter to remove the mask position. @@ -6493,7 +7478,10 @@ async def set_sticker_mask_position( Raises: :class:`telegram.error.TelegramError` """ - data: JSONDict = {"sticker": sticker, "mask_position": mask_position} + data: JSONDict = { + "sticker": sticker if isinstance(sticker, str) else sticker.file_id, + "mask_position": mask_position, + } return await self._post( "setStickerMaskPosition", data, @@ -6504,17 +7492,16 @@ async def set_sticker_mask_position( api_kwargs=api_kwargs, ) - @_log async def set_custom_emoji_sticker_set_thumbnail( self, name: str, - custom_emoji_id: Optional[str] = None, + custom_emoji_id: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to set the thumbnail of a custom emoji sticker set. @@ -6546,17 +7533,16 @@ async def set_custom_emoji_sticker_set_thumbnail( api_kwargs=api_kwargs, ) - @_log async def set_passport_data_errors( self, - user_id: Union[str, int], - errors: Sequence[PassportElementError], + user_id: int, + errors: Sequence["PassportElementError"], *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Informs a user that some of the Telegram Passport elements they provided contains errors. @@ -6593,34 +7579,39 @@ async def set_passport_data_errors( api_kwargs=api_kwargs, ) - @_log async def send_poll( self, - chat_id: Union[int, str], + chat_id: int | str, question: str, - options: Sequence[str], - is_anonymous: Optional[bool] = None, - type: Optional[str] = None, # pylint: disable=redefined-builtin - allows_multiple_answers: Optional[bool] = None, - correct_option_id: Optional[int] = None, - is_closed: Optional[bool] = None, + options: Sequence["str | InputPollOption"], + is_anonymous: bool | None = None, + type: str | None = None, # pylint: disable=redefined-builtin + allows_multiple_answers: bool | None = None, + correct_option_id: CorrectOptionID | None = None, + is_closed: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - explanation: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + explanation: str | None = None, explanation_parse_mode: ODVInput[str] = DEFAULT_NONE, - open_period: Optional[int] = None, - close_date: Optional[Union[int, datetime]] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - explanation_entities: Optional[Sequence["MessageEntity"]] = None, + open_period: TimePeriod | None = None, + close_date: int | dtm.datetime | None = None, + explanation_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + question_parse_mode: ODVInput[str] = DEFAULT_NONE, + question_entities: Sequence["MessageEntity"] | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = 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, + api_kwargs: JSONDict | None = None, ) -> Message: """ Use this method to send a native poll. @@ -6629,14 +7620,20 @@ async def send_poll( chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| question (:obj:`str`): Poll question, :tg-const:`telegram.Poll.MIN_QUESTION_LENGTH`- :tg-const:`telegram.Poll.MAX_QUESTION_LENGTH` characters. - options (Sequence[:obj:`str`]): Sequence of answer options, + options (Sequence[:obj:`str` | :class:`telegram.InputPollOption`]): Sequence of :tg-const:`telegram.Poll.MIN_OPTION_NUMBER`- - :tg-const:`telegram.Poll.MAX_OPTION_NUMBER` strings + :tg-const:`telegram.Poll.MAX_OPTION_NUMBER` answer options. Each option may either + be a string with :tg-const:`telegram.Poll.MIN_OPTION_LENGTH`- - :tg-const:`telegram.Poll.MAX_OPTION_LENGTH` characters each. + :tg-const:`telegram.Poll.MAX_OPTION_LENGTH` characters or an + :class:`~telegram.InputPollOption` object. Strings are converted to + :class:`~telegram.InputPollOption` objects automatically. .. versionchanged:: 20.0 |sequenceargs| + + .. versionchanged:: 21.2 + Bot API 7.3 adds support for :class:`~telegram.InputPollOption` objects. is_anonymous (:obj:`bool`, optional): :obj:`True`, if the poll needs to be anonymous, defaults to :obj:`True`. type (:obj:`str`, optional): Poll type, :tg-const:`telegram.Poll.QUIZ` or @@ -6659,18 +7656,20 @@ async def send_poll( .. versionchanged:: 20.0 |sequenceargs| - open_period (:obj:`int`, optional): Amount of time in seconds the poll will be active + open_period (:obj:`int` | :class:`datetime.timedelta`, optional): Amount of time in + seconds the poll will be active after creation, :tg-const:`telegram.Poll.MIN_OPEN_PERIOD`- :tg-const:`telegram.Poll.MAX_OPEN_PERIOD`. Can't be used together with :paramref:`close_date`. + + .. versionchanged:: 21.11 + |time-period-input| close_date (:obj:`int` | :obj:`datetime.datetime`, optional): Point in time (Unix timestamp) when the poll will be automatically closed. Must be at least :tg-const:`telegram.Poll.MIN_OPEN_PERIOD` and no more than :tg-const:`telegram.Poll.MAX_OPEN_PERIOD` seconds in the future. Can't be used together with :paramref:`open_period`. - For timezone naive :obj:`datetime.datetime` objects, the default timezone of the - bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is - used. + |tz-naive-dtms| is_closed (:obj:`bool`, optional): Pass :obj:`True`, if the poll needs to be immediately closed. This can be useful for poll preview. disable_notification (:obj:`bool`, optional): |disable_notification| @@ -6681,24 +7680,67 @@ async def send_poll( .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| 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. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| - Returns: - :class:`telegram.Message`: On success, the sent Message is returned. + .. versionadded:: 20.8 + business_connection_id (:obj:`str`, optional): |business_id_str| - Raises: + .. versionadded:: 21.1 + question_parse_mode (:obj:`str`, optional): Mode for parsing entities in the question. + See the constants in :class:`telegram.constants.ParseMode` for the available modes. + Currently, only custom emoji entities are allowed. + + .. versionadded:: 21.2 + question_entities (Sequence[:class:`telegram.Message`], optional): Special entities + that appear in the poll :paramref:`question`. It can be specified instead of + :paramref:`question_parse_mode`. + + .. versionadded:: 21.2 + message_effect_id (:obj:`str`, optional): |message_effect_id| + + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + + 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + + Returns: + :class:`telegram.Message`: On success, the sent Message is returned. + + Raises: :class:`telegram.error.TelegramError` """ data: JSONDict = { "chat_id": chat_id, "question": question, - "options": options, + "options": [ + InputPollOption(option) if isinstance(option, str) else option + for option in options + ], "explanation_parse_mode": explanation_parse_mode, "is_anonymous": is_anonymous, "type": type, @@ -6709,6 +7751,8 @@ async def send_poll( "explanation_entities": explanation_entities, "open_period": open_period, "close_date": close_date, + "question_parse_mode": question_parse_mode, + "question_entities": question_entities, } return await self._send_message( @@ -6720,25 +7764,29 @@ async def send_poll( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, ) - @_log async def stop_poll( self, - chat_id: Union[int, str], + chat_id: int | str, message_id: int, - reply_markup: Optional[InlineKeyboardMarkup] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + business_connection_id: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> Poll: """ Use this method to stop a poll which was sent by the bot. @@ -6748,6 +7796,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. @@ -6760,6 +7811,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( @@ -6771,25 +7823,166 @@ async def stop_poll( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - return Poll.de_json(result, self) # type: ignore[return-value] + return Poll.de_json(result, self) - @_log - async def send_dice( + async def send_checklist( self, - chat_id: Union[int, str], + business_connection_id: str, + chat_id: int, + checklist: InputChecklist, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - emoji: Optional[str] = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_effect_id: str | None = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> Message: + """ + Use this method to send a checklist on behalf of a connected business account. + + .. versionadded:: 22.3 + + Args: + business_connection_id (:obj:`str`): + |business_id_str| + chat_id (:obj:`int`): + Unique identifier for the target chat. + checklist (:class:`telegram.InputChecklist`): + The checklist to send. + disable_notification (:obj:`bool`, optional): + |disable_notification| + protect_content (:obj:`bool`, optional): + |protect_content| + message_effect_id (:obj:`str`, optional): + |message_effect_id| + reply_parameters (:class:`telegram.ReplyParameters`, optional): + |reply_parameters| + reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): + An object for an inline keyboard + + 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, + "checklist": checklist, + } + + return await self._send_message( + "sendChecklist", + data, + disable_notification=disable_notification, + reply_markup=reply_markup, + protect_content=protect_content, + reply_parameters=reply_parameters, + message_effect_id=message_effect_id, + business_connection_id=business_connection_id, + 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, + ) + + async def edit_message_checklist( + self, + business_connection_id: str, + chat_id: int, + message_id: int, + checklist: InputChecklist, + reply_markup: "InlineKeyboardMarkup | None" = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> Message: + """ + Use this method to edit a checklist on behalf of a connected business account. + + .. versionadded:: 22.3 + + Args: + business_connection_id (:obj:`str`): + |business_id_str| + chat_id (:obj:`int`): + Unique identifier for the target chat. + message_id (:obj:`int`): + Unique identifier for the target message. + checklist (:class:`telegram.InputChecklist`): + The new checklist. + reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): + An object for the new inline keyboard for the message. + + Returns: + :class:`telegram.Message`: On success, the sent Message is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "chat_id": chat_id, + "message_id": message_id, + "checklist": checklist, + } + + return await self._send_message( + "editMessageChecklist", + data, + reply_markup=reply_markup, + business_connection_id=business_connection_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def send_dice( + self, + chat_id: int | str, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + emoji: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = 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, + api_kwargs: JSONDict | None = None, ) -> Message: """ Use this method to send an animated emoji that will display a random value. @@ -6797,7 +7990,6 @@ async def send_dice( Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| disable_notification (:obj:`bool`, optional): |disable_notification| - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): Additional interface options. An object for an inline keyboard, custom reply @@ -6817,13 +8009,52 @@ async def send_dice( .. versionchanged:: 13.4 Added the :tg-const:`telegram.Dice.BOWLING` emoji. - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| protect_content (:obj:`bool`, optional): |protect_content| .. versionadded:: 13.10 message_thread_id (:obj:`int`, optional): |message_thread_id_arg| .. versionadded:: 20.0 + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: 20.8 + business_connection_id (:obj:`str`, optional): |business_id_str| + + .. versionadded:: 21.1 + message_effect_id (:obj:`str`, optional): |message_effect_id| + + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 + + 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + Returns: :class:`telegram.Message`: On success, the sent Message is returned. @@ -6843,23 +8074,28 @@ async def send_dice( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) - @_log async def get_my_default_administrator_rights( self, - for_channels: Optional[bool] = None, + for_channels: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> ChatAdministratorRights: """Use this method to get the current default administrator rights of the bot. @@ -6890,31 +8126,31 @@ async def get_my_default_administrator_rights( api_kwargs=api_kwargs, ) - return ChatAdministratorRights.de_json(result, self) # type: ignore[return-value] + return ChatAdministratorRights.de_json(result, self) - @_log async def set_my_default_administrator_rights( self, - rights: Optional[ChatAdministratorRights] = None, - for_channels: Optional[bool] = None, + rights: ChatAdministratorRights | None = None, + for_channels: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to change the default administrator rights requested by the bot when it's added as an administrator to groups or channels. These rights will be suggested to - users, but they are are free to modify the list before adding the bot. + users, but they are free to modify the list before adding the bot. .. seealso:: :meth:`get_my_default_administrator_rights` .. versionadded:: 20.0 Args: - rights (:obj:`telegram.ChatAdministratorRights`, optional): A - :obj:`telegram.ChatAdministratorRights` object describing new default administrator + rights (:class:`telegram.ChatAdministratorRights`, optional): A + :class:`telegram.ChatAdministratorRights` object describing new default + administrator rights. If not specified, the default administrator rights will be cleared. for_channels (:obj:`bool`, optional): Pass :obj:`True` to change the default administrator rights of the bot in channels. Otherwise, the default administrator @@ -6924,7 +8160,7 @@ async def set_my_default_administrator_rights( :obj:`bool`: Returns :obj:`True` on success. Raises: - :obj:`telegram.error.TelegramError` + :exc:`telegram.error.TelegramError` """ data: JSONDict = {"rights": rights, "for_channels": for_channels} @@ -6938,18 +8174,17 @@ async def set_my_default_administrator_rights( api_kwargs=api_kwargs, ) - @_log async def get_my_commands( self, - scope: Optional[BotCommandScope] = None, - language_code: Optional[str] = None, + scope: BotCommandScope | None = None, + language_code: str | None = 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, - ) -> Tuple[BotCommand, ...]: + api_kwargs: JSONDict | None = None, + ) -> tuple[BotCommand, ...]: """ Use this method to get the current list of the bot's commands for the given scope and user language. @@ -6971,7 +8206,7 @@ async def get_my_commands( .. versionadded:: 13.7 Returns: - Tuple[:class:`telegram.BotCommand`]: On success, the commands set for the bot. An empty + tuple[:class:`telegram.BotCommand`]: On success, the commands set for the bot. An empty tuple is returned if commands are not set. Raises: @@ -6992,18 +8227,17 @@ async def get_my_commands( return BotCommand.de_list(result, self) - @_log async def set_my_commands( self, - commands: Sequence[Union[BotCommand, Tuple[str, str]]], - scope: Optional[BotCommandScope] = None, - language_code: Optional[str] = None, + commands: Sequence[BotCommand | tuple[str, str]], + scope: BotCommandScope | None = None, + language_code: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change the list of the bot's commands. See the @@ -7057,17 +8291,16 @@ async def set_my_commands( api_kwargs=api_kwargs, ) - @_log async def delete_my_commands( self, - scope: Optional[BotCommandScope] = None, - language_code: Optional[str] = None, + scope: BotCommandScope | None = None, + language_code: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to delete the list of the bot's commands for the given scope and user @@ -7105,7 +8338,6 @@ async def delete_my_commands( api_kwargs=api_kwargs, ) - @_log async def log_out( self, *, @@ -7113,7 +8345,7 @@ async def log_out( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to log out from the cloud Bot API server before launching the bot locally. @@ -7123,7 +8355,7 @@ async def log_out( minutes. Returns: - :obj:`True`: On success + :obj:`True`: On success, :obj:`True` is returned. Raises: :class:`telegram.error.TelegramError` @@ -7138,7 +8370,6 @@ async def log_out( api_kwargs=api_kwargs, ) - @_log async def close( self, *, @@ -7146,7 +8377,7 @@ async def close( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to close the bot instance before moving it from one local server to @@ -7155,7 +8386,7 @@ async def close( 10 minutes after the bot is launched. Returns: - :obj:`True`: On success + :obj:`True`: On success, :obj:`True` is returned. Raises: :class:`telegram.error.TelegramError` @@ -7170,38 +8401,48 @@ async def close( api_kwargs=api_kwargs, ) - @_log async def copy_message( self, - chat_id: Union[int, str], - from_chat_id: Union[str, int], + chat_id: int | str, + from_chat_id: str | int, message_id: int, - caption: Optional[str] = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - allow_sending_without_reply: DVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + caption_entities: Sequence["MessageEntity"] | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = 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, + api_kwargs: JSONDict | None = None, ) -> MessageId: - """ - Use this method to copy messages of any kind. Service 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. + """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. Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| from_chat_id (:obj:`int` | :obj:`str`): Unique identifier for the chat where the original message was sent (or channel username in the format ``@channelusername``). message_id (:obj:`int`): Message identifier in the chat specified in from_chat_id. + video_start_timestamp (:obj:`int`, optional): New start timestamp for the + copied video in the message + + .. versionadded:: 21.11 caption (:obj:`str`, optional): New caption for media, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. If not specified, the original caption is kept. @@ -7220,33 +8461,93 @@ async def copy_message( .. versionadded:: 20.0 - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| 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. + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + + .. versionadded:: 20.8 + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + + .. versionadded:: 21.3 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 + message_effect_id (:obj:`str`, optional): Unique identifier of the message effect to be + added to the message; only available when copying to private chats + + .. versionadded:: 22.6 + + 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 + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`reply_parameters` |rtm_aswr_deprecated| + + .. versionchanged:: 21.0 + |keyword_only_arg| Returns: - :class:`telegram.MessageId`: On success + :class:`telegram.MessageId`: On success, the :class:`telegram.MessageId` of the sent + message is returned. Raises: :class:`telegram.error.TelegramError` """ + if allow_sending_without_reply is not DEFAULT_NONE and reply_parameters is not None: + raise ValueError( + "`allow_sending_without_reply` and `reply_parameters` are mutually exclusive." + ) + + if reply_to_message_id is not None and reply_parameters is not None: + raise ValueError( + "`reply_to_message_id` and `reply_parameters` are mutually exclusive." + ) + + if reply_to_message_id is not None: + reply_parameters = ReplyParameters( + message_id=reply_to_message_id, + allow_sending_without_reply=allow_sending_without_reply, + ) + data: JSONDict = { "chat_id": chat_id, "from_chat_id": from_chat_id, "message_id": message_id, "parse_mode": parse_mode, "disable_notification": disable_notification, - "allow_sending_without_reply": allow_sending_without_reply, "protect_content": protect_content, "caption": caption, "caption_entities": caption_entities, - "reply_to_message_id": reply_to_message_id, "reply_markup": reply_markup, "message_thread_id": message_thread_id, + "reply_parameters": reply_parameters, + "show_caption_above_media": show_caption_above_media, + "allow_paid_broadcast": allow_paid_broadcast, + "direct_messages_topic_id": direct_messages_topic_id, + "video_start_timestamp": video_start_timestamp, + "suggested_post_parameters": suggested_post_parameters, + "message_effect_id": message_effect_id, } result = await self._post( @@ -7258,19 +8559,97 @@ async def copy_message( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - return MessageId.de_json(result, self) # type: ignore[return-value] + return MessageId.de_json(result, self) + + async def copy_messages( + self, + chat_id: int | str, + from_chat_id: str | int, + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + remove_caption: bool | None = None, + direct_messages_topic_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> 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, 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 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + from_chat_id (:obj:`int` | :obj:`str`): Unique identifier for the chat where the + original message was sent (or channel username in the format ``@channelusername``). + message_ids (Sequence[:obj:`int`]): A list of + :tg-const:`telegram.constants.BulkRequestLimit.MIN_LIMIT` - + :tg-const:`telegram.constants.BulkRequestLimit.MAX_LIMIT` identifiers of messages + in the chat :paramref:`from_chat_id` to copy. The identifiers must be + specified in a strictly increasing order. + disable_notification (:obj:`bool`, optional): |disable_notification| + protect_content (:obj:`bool`, optional): |protect_content| + message_thread_id (:obj:`int`, optional): |message_thread_id_arg| + remove_caption (:obj:`bool`, optional): Pass :obj:`True` to copy the messages without + their captions. + direct_messages_topic_id (:obj:`int`, optional): Identifier of the direct messages + topic to which the message will be sent; required if the message is sent to a + direct messages chat. + + .. versionadded:: 22.4 + + + Returns: + tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of the sent messages is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "chat_id": chat_id, + "from_chat_id": from_chat_id, + "message_ids": message_ids, + "disable_notification": disable_notification, + "protect_content": protect_content, + "message_thread_id": message_thread_id, + "remove_caption": remove_caption, + "direct_messages_topic_id": direct_messages_topic_id, + } + + result = await self._post( + "copyMessages", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + return MessageId.de_list(result, self) - @_log async def set_chat_menu_button( self, - chat_id: Optional[int] = None, - menu_button: Optional[MenuButton] = None, + chat_id: int | None = None, + menu_button: MenuButton | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to change the bot's menu button in a private chat, or the default menu button. @@ -7302,16 +8681,15 @@ async def set_chat_menu_button( api_kwargs=api_kwargs, ) - @_log async def get_chat_menu_button( self, - chat_id: Optional[int] = None, + chat_id: int | None = 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, + api_kwargs: JSONDict | None = None, ) -> MenuButton: """Use this method to get the current value of the bot's menu button in a private chat, or the default menu button. @@ -7340,43 +8718,48 @@ async def get_chat_menu_button( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - return MenuButton.de_json(result, bot=self) # type: ignore[return-value] + return MenuButton.de_json(result, bot=self) - @_log async def create_invoice_link( self, title: str, description: str, payload: str, - provider_token: str, currency: str, prices: Sequence["LabeledPrice"], - max_tip_amount: Optional[int] = None, - suggested_tip_amounts: Optional[Sequence[int]] = None, - provider_data: Optional[Union[str, object]] = None, - photo_url: Optional[str] = None, - photo_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - need_name: Optional[bool] = None, - need_phone_number: Optional[bool] = None, - need_email: Optional[bool] = None, - need_shipping_address: Optional[bool] = None, - send_phone_number_to_provider: Optional[bool] = None, - send_email_to_provider: Optional[bool] = None, - is_flexible: Optional[bool] = None, + provider_token: str | None = None, + max_tip_amount: int | None = None, + suggested_tip_amounts: Sequence[int] | None = None, + provider_data: str | object | None = None, + photo_url: str | None = None, + photo_size: int | None = None, + photo_width: int | None = None, + photo_height: int | None = None, + need_name: bool | None = None, + need_phone_number: bool | None = None, + need_email: bool | None = None, + need_shipping_address: bool | None = None, + send_phone_number_to_provider: bool | None = None, + send_email_to_provider: bool | None = None, + is_flexible: bool | None = None, + subscription_period: TimePeriod | None = None, + business_connection_id: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> str: """Use this method to create a link for an invoice. .. versionadded:: 20.0 Args: + business_connection_id (:obj:`str`, optional): |business_id_str| + For payments in |tg_stars| only. + + .. versionadded:: 21.8 title (:obj:`str`): Product name. :tg-const:`telegram.Invoice.MIN_TITLE_LENGTH`- :tg-const:`telegram.Invoice.MAX_TITLE_LENGTH` characters. description (:obj:`str`): Product description. @@ -7385,25 +8768,45 @@ async def create_invoice_link( payload (:obj:`str`): Bot-defined invoice payload. :tg-const:`telegram.Invoice.MIN_PAYLOAD_LENGTH`- :tg-const:`telegram.Invoice.MAX_PAYLOAD_LENGTH` bytes. This will not be - displayed to the user, use for your internal processes. - provider_token (:obj:`str`): Payments provider token, obtained via - `@BotFather `_. + displayed to the user, use it for your internal processes. + provider_token (:obj:`str`, optional): Payments provider token, obtained via + `@BotFather `_. Pass an empty string for payments in + |tg_stars|. + + .. versionchanged:: 21.11 + Bot API 7.4 made this parameter is optional and this is now reflected in the + function signature. + currency (:obj:`str`): Three-letter ISO 4217 currency code, see `more on currencies - `_. + `_. Pass ``XTR`` for + payments in |tg_stars|. prices (Sequence[:class:`telegram.LabeledPrice`)]: Price breakdown, a sequence of components (e.g. product price, tax, discount, delivery cost, delivery tax, - bonus, etc.). + bonus, etc.). Must contain exactly one item for payments in |tg_stars|. .. versionchanged:: 20.0 |sequenceargs| + subscription_period (:obj:`int` | :class:`datetime.timedelta`, optional): The time the + subscription will be active for before the next payment, either as number of + seconds or as :class:`datetime.timedelta` object. The currency must be set to + ``“XTR”`` (Telegram Stars) if the parameter is used. Currently, it must always be + :tg-const:`telegram.constants.InvoiceLimit.SUBSCRIPTION_PERIOD` if specified. Any + number of subscriptions can be active for a given bot at the same time, including + multiple concurrent subscriptions from the same user. Subscription price must + not exceed + :tg-const:`telegram.constants.InvoiceLimit.SUBSCRIPTION_MAX_PRICE` + Telegram Stars. + + .. versionadded:: 21.8 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``. + *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`. @@ -7420,19 +8823,20 @@ async def create_invoice_link( photo_width (:obj:`int`, optional): Photo width. photo_height (:obj:`int`, optional): Photo height. need_name (:obj:`bool`, optional): Pass :obj:`True`, if you require the user's full - name to complete the order. + name to complete the order. Ignored for payments in |tg_stars|. need_phone_number (:obj:`bool`, optional): Pass :obj:`True`, if you require the user's - phone number to complete the order. + phone number to complete the order. Ignored for payments in |tg_stars|. need_email (:obj:`bool`, optional): Pass :obj:`True`, if you require the user's email - address to complete the order. + address to complete the order. Ignored for payments in |tg_stars|. need_shipping_address (:obj:`bool`, optional): Pass :obj:`True`, if you require the - user's shipping address to complete the order. + user's shipping address to complete the order. Ignored for payments in + |tg_stars|. send_phone_number_to_provider (:obj:`bool`, optional): Pass :obj:`True`, if user's - phone number should be sent to provider. + phone number should be sent to provider. Ignored for payments in |tg_stars|. send_email_to_provider (:obj:`bool`, optional): Pass :obj:`True`, if user's email - address should be sent to provider. + address should be sent to provider. Ignored for payments in |tg_stars|. is_flexible (:obj:`bool`, optional): Pass :obj:`True`, if the final price depends on - the shipping method. + the shipping method. Ignored for payments in |tg_stars|. Returns: :class:`str`: On success, the created invoice link is returned. @@ -7459,6 +8863,8 @@ async def create_invoice_link( "is_flexible": is_flexible, "send_phone_number_to_provider": send_phone_number_to_provider, "send_email_to_provider": send_email_to_provider, + "subscription_period": subscription_period, + "business_connection_id": business_connection_id, } return await self._post( @@ -7471,7 +8877,6 @@ async def create_invoice_link( api_kwargs=api_kwargs, ) - @_log async def get_forum_topic_icon_stickers( self, *, @@ -7479,15 +8884,15 @@ async def get_forum_topic_icon_stickers( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Tuple[Sticker, ...]: + api_kwargs: JSONDict | None = None, + ) -> tuple[Sticker, ...]: """Use this method to get custom emoji stickers, which can be used as a forum topic icon by any user. Requires no parameters. .. versionadded:: 20.0 Returns: - Tuple[:class:`telegram.Sticker`] + tuple[:class:`telegram.Sticker`] Raises: :class:`telegram.error.TelegramError` @@ -7503,19 +8908,18 @@ async def get_forum_topic_icon_stickers( ) return Sticker.de_list(result, self) - @_log async def create_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, name: str, - icon_color: Optional[int] = None, - icon_custom_emoji_id: Optional[str] = None, + icon_color: int | None = None, + icon_custom_emoji_id: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> ForumTopic: """ Use this method to create a topic in a forum supergroup chat. The bot must be @@ -7561,25 +8965,25 @@ async def create_forum_topic( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - return ForumTopic.de_json(result, self) # type: ignore[return-value] + return ForumTopic.de_json(result, self) - @_log async def edit_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, - name: Optional[str] = None, - icon_custom_emoji_id: Optional[str] = None, + name: str | None = None, + icon_custom_emoji_id: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ - Use this method to edit name and icon of a topic in a forum supergroup chat. The bot must - be an administrator in the chat for this to work and must have + Use this method to edit name and icon of a topic in a forum supergroup chat or a private + chat with a user. In the case of a supergroup chat the bot must be an administrator in the + chat for this to work and must have the :paramref:`~telegram.ChatAdministratorRights.can_manage_topics` administrator rights, unless it is the creator of the topic. @@ -7620,17 +9024,16 @@ async def edit_forum_topic( api_kwargs=api_kwargs, ) - @_log async def close_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to close an open topic in a forum supergroup chat. The bot must @@ -7665,17 +9068,16 @@ async def close_forum_topic( api_kwargs=api_kwargs, ) - @_log async def reopen_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to reopen a closed topic in a forum supergroup chat. The bot must @@ -7710,21 +9112,21 @@ async def reopen_forum_topic( api_kwargs=api_kwargs, ) - @_log async def delete_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to delete a forum topic along with all its messages in a forum supergroup - chat. The bot must be an administrator in the chat for this to work and must have + chat or a private chat with a user. In the case of a supergroup chat the bot must be an + administrator in the chat for this to work and must have the :paramref:`~telegram.ChatAdministratorRights.can_delete_messages` administrator rights. .. versionadded:: 20.0 @@ -7754,23 +9156,23 @@ async def delete_forum_topic( api_kwargs=api_kwargs, ) - @_log async def unpin_all_forum_topic_messages( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ - Use this method to clear the list of pinned messages in a forum topic. The bot must - be an administrator in the chat for this to work and must have - :paramref:`~telegram.ChatAdministratorRights.can_pin_messages` administrator rights - in the supergroup. + Use this method to clear the list of pinned messages in a forum topic in a forum supergroup + chat or a private chat with a user. In the case of a supergroup chat the bot must be an + administrator in the chat for this to work and must have the + :paramref:`~telegram.ChatAdministratorRights.can_pin_messages` administrator right in + the supergroup. .. versionadded:: 20.0 @@ -7799,21 +9201,59 @@ async def unpin_all_forum_topic_messages( api_kwargs=api_kwargs, ) - @_log + async def unpin_all_general_forum_topic_messages( + self, + chat_id: str | int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Use this method to clear the list of pinned messages in a General forum topic. The bot must + be an administrator in the chat for this to work and must have + :paramref:`~telegram.ChatAdministratorRights.can_pin_messages` administrator rights in the + supergroup. + + .. versionadded:: 20.5 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_group| + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = {"chat_id": chat_id} + + return await self._post( + "unpinAllGeneralForumTopicMessages", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def edit_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, name: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to edit the name of the 'General' topic in a forum supergroup chat. The bot - must be an administrator in the chat for this to work and must have + must be an administrator in the chat for this to work and must have the :attr:`~telegram.ChatAdministratorRights.can_manage_topics` administrator rights. .. versionadded:: 20.0 @@ -7843,16 +9283,15 @@ async def edit_general_forum_topic( api_kwargs=api_kwargs, ) - @_log async def close_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to close an open 'General' topic in a forum supergroup chat. The bot must @@ -7883,16 +9322,15 @@ async def close_general_forum_topic( api_kwargs=api_kwargs, ) - @_log async def reopen_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to reopen a closed 'General' topic in a forum supergroup chat. The bot must @@ -7924,16 +9362,15 @@ async def reopen_general_forum_topic( api_kwargs=api_kwargs, ) - @_log async def hide_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to hide the 'General' topic in a forum supergroup chat. The bot must @@ -7965,16 +9402,15 @@ async def hide_general_forum_topic( api_kwargs=api_kwargs, ) - @_log async def unhide_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to unhide the 'General' topic in a forum supergroup chat. The bot must @@ -8005,17 +9441,16 @@ async def unhide_general_forum_topic( api_kwargs=api_kwargs, ) - @_log async def set_my_description( self, - description: Optional[str] = None, - language_code: Optional[str] = None, + description: str | None = None, + language_code: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change the bot's description, which is shown in the chat with the bot @@ -8051,17 +9486,16 @@ async def set_my_description( api_kwargs=api_kwargs, ) - @_log async def set_my_short_description( self, - short_description: Optional[str] = None, - language_code: Optional[str] = None, + short_description: str | None = None, + language_code: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change the bot's short description, which is shown on the bot's profile @@ -8097,16 +9531,15 @@ async def set_my_short_description( api_kwargs=api_kwargs, ) - @_log async def get_my_description( self, - language_code: Optional[str] = None, + language_code: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> BotDescription: """ Use this method to get the current bot description for the given user language. @@ -8123,7 +9556,7 @@ async def get_my_description( """ data = {"language_code": language_code} - return BotDescription.de_json( # type: ignore[return-value] + return BotDescription.de_json( await self._post( "getMyDescription", data, @@ -8136,16 +9569,15 @@ async def get_my_description( bot=self, ) - @_log async def get_my_short_description( self, - language_code: Optional[str] = None, + language_code: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> BotShortDescription: """ Use this method to get the current bot short description for the given user language. @@ -8163,7 +9595,7 @@ async def get_my_short_description( """ data = {"language_code": language_code} - return BotShortDescription.de_json( # type: ignore[return-value] + return BotShortDescription.de_json( await self._post( "getMyShortDescription", data, @@ -8176,17 +9608,16 @@ async def get_my_short_description( bot=self, ) - @_log async def set_my_name( self, - name: Optional[str] = None, - language_code: Optional[str] = None, + name: str | None = None, + language_code: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """ Use this method to change the bot's name. @@ -8225,16 +9656,15 @@ async def set_my_name( api_kwargs=api_kwargs, ) - @_log async def get_my_name( self, - language_code: Optional[str] = None, + language_code: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> BotName: """ Use this method to get the current bot name for the given user language. @@ -8251,7 +9681,7 @@ async def get_my_name( """ data = {"language_code": language_code} - return BotName.de_json( # type: ignore[return-value] + return BotName.de_json( await self._post( "getMyName", data, @@ -8264,33 +9694,2253 @@ async def get_my_name( bot=self, ) - def to_dict(self, recursive: bool = True) -> JSONDict: # skipcq: PYL-W0613 - """See :meth:`telegram.TelegramObject.to_dict`.""" - data: JSONDict = {"id": self.id, "username": self.username, "first_name": self.first_name} - - if self.last_name: - data["last_name"] = self.last_name + async def get_user_chat_boosts( + self, + chat_id: str | int, + user_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> UserChatBoosts: + """ + Use this method to get the list of boosts added to a chat by a user. Requires + administrator rights in the chat. - return data + .. versionadded:: 20.8 - def __eq__(self, other: object) -> bool: - if isinstance(other, self.__class__): - return self.bot == other.bot - return False + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + user_id (:obj:`int`): Unique identifier of the target user. - def __hash__(self) -> int: - return hash((self.__class__, self.bot)) + Returns: + :class:`telegram.UserChatBoosts`: On success, the object containing the list of boosts + is returned. - # camelCase aliases - getMe = get_me - """Alias for :meth:`get_me`""" - sendMessage = send_message - """Alias for :meth:`send_message`""" - deleteMessage = delete_message - """Alias for :meth:`delete_message`""" - forwardMessage = forward_message - """Alias for :meth:`forward_message`""" - sendPhoto = send_photo + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = {"chat_id": chat_id, "user_id": user_id} + return UserChatBoosts.de_json( + await self._post( + "getUserChatBoosts", + 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 set_message_reaction( + self, + chat_id: str | int, + message_id: int, + reaction: Sequence[ReactionType | str] | ReactionType | str | None = None, + is_big: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Use this method to change the chosen reactions on a message. Service messages of some types + can't be + reacted to. Automatically forwarded messages from a channel to its discussion group have + the same available reactions as messages in the channel. Bots can't use paid reactions. + + .. versionadded:: 20.8 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + message_id (:obj:`int`): Identifier of the target message. If the message belongs to a + media group, the reaction is set to the first non-deleted message in the group + instead. + reaction (Sequence[:class:`telegram.ReactionType` | :obj:`str`] | \ + :class:`telegram.ReactionType` | :obj:`str`, optional): A list of reaction + types to set on the message. Currently, as non-premium users, bots can set up to + one reaction per message. A custom emoji reaction can be used if it is either + already present on the message or explicitly allowed by chat administrators. Paid + reactions can't be used by bots. + + Tip: + Passed :obj:`str` values will be converted to either + :class:`telegram.ReactionTypeEmoji` or + :class:`telegram.ReactionTypeCustomEmoji` + depending on whether they are listed in + :class:`~telegram.constants.ReactionEmoji`. + + is_big (:obj:`bool`, optional): Pass :obj:`True` to set the reaction with a big + animation. + + Returns: + :obj:`bool` On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + allowed_reactions: set[str] = set(ReactionEmoji) + parsed_reaction = ( + [ + ( + entry + if isinstance(entry, ReactionType) + else ( + ReactionTypeEmoji(emoji=entry) + if entry in allowed_reactions + else ReactionTypeCustomEmoji(custom_emoji_id=entry) + ) + ) + for entry in ([reaction] if isinstance(reaction, ReactionType | str) else reaction) + ] + if reaction is not None + else None + ) + + data: JSONDict = { + "chat_id": chat_id, + "message_id": message_id, + "reaction": parsed_reaction, + "is_big": is_big, + } + + return await self._post( + "setMessageReaction", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def gift_premium_subscription( + self, + user_id: int, + month_count: int, + star_count: int, + text: str | None = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Sequence["MessageEntity"] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Gifts a Telegram Premium subscription to the given user. + + .. versionadded:: 22.1 + + Args: + user_id (:obj:`int`): Unique identifier of the target user who will receive a Telegram + Premium subscription. + month_count (:obj:`int`): Number of months the Telegram Premium subscription will be + active for the user; must be one of + :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_THREE`, + :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_SIX`, + or :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_TWELVE`. + star_count (:obj:`int`): Number of Telegram Stars to pay for the Telegram Premium + subscription; must be + :tg-const:`telegram.constants.PremiumSubscription.STARS_THREE_MONTHS` + for :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_THREE` months, + :tg-const:`telegram.constants.PremiumSubscription.STARS_SIX_MONTHS` + for :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_SIX` months, + and :tg-const:`telegram.constants.PremiumSubscription.STARS_TWELVE_MONTHS` + for :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_TWELVE` months. + text (:obj:`str`, optional): Text that will be shown along with the service message + about the subscription; + 0-:tg-const:`telegram.constants.PremiumSubscription.MAX_TEXT_LENGTH` characters. + text_parse_mode (:obj:`str`, optional): Mode for parsing entities. + See :class:`telegram.constants.ParseMode` and + `formatting options `__ for + more details. Entities other than :attr:`~MessageEntity.BOLD`, + :attr:`~MessageEntity.ITALIC`, :attr:`~MessageEntity.UNDERLINE`, + :attr:`~MessageEntity.STRIKETHROUGH`, :attr:`~MessageEntity.SPOILER`, and + :attr:`~MessageEntity.CUSTOM_EMOJI` are ignored. + text_entities (Sequence[:class:`telegram.MessageEntity`], optional): A list of special + entities that appear in the gift text. It can be specified instead of + :paramref:`text_parse_mode`. Entities other than :attr:`~MessageEntity.BOLD`, + :attr:`~MessageEntity.ITALIC`, :attr:`~MessageEntity.UNDERLINE`, + :attr:`~MessageEntity.STRIKETHROUGH`, :attr:`~MessageEntity.SPOILER`, and + :attr:`~MessageEntity.CUSTOM_EMOJI` are ignored. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "user_id": user_id, + "month_count": month_count, + "star_count": star_count, + "text": text, + "text_entities": text_entities, + "text_parse_mode": text_parse_mode, + } + return await self._post( + "giftPremiumSubscription", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def get_business_connection( + self, + business_connection_id: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> BusinessConnection: + """ + Use this method to get information about the connection of the bot with a business account. + + .. versionadded:: 21.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + + Returns: + :class:`telegram.BusinessConnection`: On success, the object containing the business + connection information is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = {"business_connection_id": business_connection_id} + return BusinessConnection.de_json( + await self._post( + "getBusinessConnection", + 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 get_business_account_gifts( + self, + business_connection_id: str, + exclude_unsaved: bool | None = None, + exclude_saved: bool | None = None, + exclude_unlimited: bool | None = None, + # tags: deprecated 22.6; bot api 9.3 + exclude_limited: bool | None = None, + exclude_unique: bool | None = None, + sort_by_price: bool | None = None, + offset: str | None = None, + limit: int | None = None, + exclude_limited_upgradable: bool | None = None, + exclude_limited_non_upgradable: bool | None = None, + exclude_from_blockchain: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> OwnedGifts: + """ + Returns the gifts received and owned by a managed business account. Requires the + :attr:`~telegram.BusinessBotRights.can_view_gifts_and_stars` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + exclude_unsaved (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that aren't + saved to the account's profile page. + exclude_saved (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that are saved + to the account's profile page. + exclude_unlimited (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that can + be purchased an unlimited number of times. + exclude_limited (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that can be + purchased a limited number of times. + + .. deprecated:: 22.6 + Bot API 9.3 deprecated this parameter in favor of + :paramref:`exclude_limited_upgradabale` and + :paramref:`exclude_limited_non_upgradable`. + exclude_limited_upgradable (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts + that can be purchased a limited number of times and can be upgraded to unique. + + .. versionadded:: 22.6 + exclude_limited_non_upgradable (:obj:`bool`, optional): Pass :obj:`True` to exclude + gifts that can be purchased a limited number of times and can't be upgraded to + unique + + .. versionadded:: 22.6 + exclude_unique (:obj:`bool`, optional): Pass :obj:`True` to exclude unique gifts. + exclude_from_blockchain (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts + that were assigned from the TON blockchain and can't be resold or transferred in + Telegram. + + .. versionadded:: 22.6 + sort_by_price (:obj:`bool`, optional): Pass :obj:`True` to sort results by gift price + instead of send date. Sorting is applied before pagination. + offset (:obj:`str`, optional): Offset of the first entry to return as received from + the previous request; use empty string to get the first chunk of results. + limit (:obj:`int`, optional): The maximum number of gifts to be returned; + :tg-const:`telegram.constants.BusinessLimit.MIN_GIFT_RESULTS`-\ + :tg-const:`telegram.constants.BusinessLimit.MAX_GIFT_RESULTS`. + Defaults to :tg-const:`telegram.constants.BusinessLimit.MAX_GIFT_RESULTS`. + + Returns: + :class:`telegram.OwnedGifts` + + Raises: + :class:`telegram.error.TelegramError` + """ + if exclude_limited is not None: + self._warn( + PTBDeprecationWarning( + version="22.6", + message=build_deprecation_warning_message( + deprecated_name="exclude_limited", + new_name="exclude_limited_(non_)upgradable", + bot_api_version="9.3", + object_type="parameter", + ), + ), + stacklevel=2, + ) + data: JSONDict = { + "business_connection_id": business_connection_id, + "exclude_unsaved": exclude_unsaved, + "exclude_saved": exclude_saved, + "exclude_unlimited": exclude_unlimited, + "exclude_limited": exclude_limited, + "exclude_limited_upgradable": exclude_limited_upgradable, + "exclude_limited_non_upgradable": exclude_limited_non_upgradable, + "exclude_unique": exclude_unique, + "exclude_from_blockchain": exclude_from_blockchain, + "sort_by_price": sort_by_price, + "offset": offset, + "limit": limit, + } + + return OwnedGifts.de_json( + await self._post( + "getBusinessAccountGifts", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + ) + + async def get_business_account_star_balance( + self, + business_connection_id: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> StarAmount: + """ + Returns the amount of Telegram Stars owned by a managed business account. Requires the + :attr:`~telegram.BusinessBotRights.can_view_gifts_and_stars` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + + Returns: + :class:`telegram.StarAmount` + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = {"business_connection_id": business_connection_id} + return StarAmount.de_json( + await self._post( + "getBusinessAccountStarBalance", + 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 read_business_message( + self, + business_connection_id: str, + chat_id: int, + message_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Marks incoming message as read on behalf of a business account. + Requires the :attr:`~telegram.BusinessBotRights.can_read_messages` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection on + behalf of which to read the message. + chat_id (:obj:`int`): Unique identifier of the chat in which the message was received. + The chat must have been active in the last + :tg-const:`~telegram.constants.BusinessLimit.\ +CHAT_ACTIVITY_TIMEOUT` seconds. + message_id (:obj:`int`): Unique identifier of the message to mark as read. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "chat_id": chat_id, + "message_id": message_id, + } + return await self._post( + "readBusinessMessage", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_business_messages( + self, + business_connection_id: str, + message_ids: Sequence[int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Delete messages on behalf of a business account. Requires the + :attr:`~telegram.BusinessBotRights.can_delete_sent_messages` business bot right to + delete messages sent by the bot itself, or the + :attr:`~telegram.BusinessBotRights.can_delete_all_messages` business bot right to delete + any message. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`int` | :obj:`str`): Unique identifier of the business + connection on behalf of which to delete the messages + message_ids (Sequence[:obj:`int`]): A list of + :tg-const:`telegram.constants.BulkRequestLimit.MIN_LIMIT`- + :tg-const:`telegram.constants.BulkRequestLimit.MAX_LIMIT` identifiers of messages + to delete. See :meth:`delete_message` for limitations on which messages can be + deleted. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "message_ids": message_ids, + } + return await self._post( + "deleteBusinessMessages", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def post_story( + self, + business_connection_id: str, + content: "InputStoryContent", + active_period: TimePeriod, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + areas: Sequence["StoryArea"] | None = None, + post_to_chat_page: bool | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> Story: + """ + Posts a story on behalf of a managed business account. Requires the + :attr:`~telegram.BusinessBotRights.can_manage_stories` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + content (:class:`telegram.InputStoryContent`): Content of the story. + active_period (:obj:`int` | :class:`datetime.timedelta`, optional): Period after which + the story is moved to the archive, in seconds; must be one of + :tg-const:`~telegram.constants.StoryLimit.ACTIVITY_SIX_HOURS`, + :tg-const:`~telegram.constants.StoryLimit.ACTIVITY_TWELVE_HOURS`, + :tg-const:`~telegram.constants.StoryLimit.ACTIVITY_ONE_DAY`, + or :tg-const:`~telegram.constants.StoryLimit.ACTIVITY_TWO_DAYS`. + caption (:obj:`str`, optional): Caption of the story, + 0-:tg-const:`~telegram.constants.StoryLimit.CAPTION_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`, optional): Mode for parsing entities in the story caption. + See the constants in :class:`telegram.constants.ParseMode` for the + available modes. + caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): + |caption_entities| + areas (Sequence[:class:`telegram.StoryArea`], optional): Sequence of clickable areas to + be shown on the story. + + Note: + Each type of clickable area in :paramref:`areas` has its own maximum limit: + + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_LOCATION_AREAS` + of :class:`telegram.StoryAreaTypeLocation`. + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.\ +MAX_SUGGESTED_REACTION_AREAS` of :class:`telegram.StoryAreaTypeSuggestedReaction`. + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_LINK_AREAS` + of :class:`telegram.StoryAreaTypeLink`. + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_WEATHER_AREAS` + of :class:`telegram.StoryAreaTypeWeather`. + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.\ +MAX_UNIQUE_GIFT_AREAS` of :class:`telegram.StoryAreaTypeUniqueGift`. + post_to_chat_page (:class:`telegram.InputStoryContent`, optional): Pass :obj:`True` to + keep the story accessible after it expires. + protect_content (:obj:`bool`, optional): Pass :obj:`True` if the content of the story + must be protected from forwarding and screenshotting + + Returns: + :class:`Story` + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "content": content, + "active_period": active_period, + "caption": caption, + "parse_mode": parse_mode, + "caption_entities": caption_entities, + "areas": areas, + "post_to_chat_page": post_to_chat_page, + "protect_content": protect_content, + } + return Story.de_json( + await self._post( + "postStory", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + ) + + async def edit_story( + self, + business_connection_id: str, + story_id: int, + content: "InputStoryContent", + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + areas: Sequence["StoryArea"] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> Story: + """ + Edits a story previously posted by the bot on behalf of a managed business account. + Requires the :attr:`~telegram.BusinessBotRights.can_manage_stories` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + story_id (:obj:`int`): Unique identifier of the story to edit. + content (:class:`telegram.InputStoryContent`): Content of the story. + caption (:obj:`str`, optional): Caption of the story, + 0-:tg-const:`~telegram.constants.StoryLimit.CAPTION_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`, optional): Mode for parsing entities in the story caption. + See the constants in :class:`telegram.constants.ParseMode` for the + available modes. + caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): + |caption_entities| + areas (Sequence[:class:`telegram.StoryArea`], optional): Sequence of clickable areas to + be shown on the story. + + Note: + Each type of clickable area in :paramref:`areas` has its own maximum limit: + + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_LOCATION_AREAS` + of :class:`telegram.StoryAreaTypeLocation`. + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.\ +MAX_SUGGESTED_REACTION_AREAS` of :class:`telegram.StoryAreaTypeSuggestedReaction`. + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_LINK_AREAS` + of :class:`telegram.StoryAreaTypeLink`. + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_WEATHER_AREAS` + of :class:`telegram.StoryAreaTypeWeather`. + * Up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.\ +MAX_UNIQUE_GIFT_AREAS` of :class:`telegram.StoryAreaTypeUniqueGift`. + + Returns: + :class:`Story` + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "story_id": story_id, + "content": content, + "caption": caption, + "parse_mode": parse_mode, + "caption_entities": caption_entities, + "areas": areas, + } + return Story.de_json( + await self._post( + "editStory", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + ) + + async def delete_story( + self, + business_connection_id: str, + story_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Deletes a story previously posted by the bot on behalf of a managed business account. + Requires the :attr:`~telegram.BusinessBotRights.can_manage_stories` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + story_id (:obj:`int`): Unique identifier of the story to delete. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "story_id": story_id, + } + return await self._post( + "deleteStory", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def set_business_account_name( + self, + business_connection_id: str, + first_name: str, + last_name: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Changes the first and last name of a managed business account. Requires the + :attr:`~telegram.BusinessBotRights.can_edit_name` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`int` | :obj:`str`): Unique identifier of the business + connection + first_name (:obj:`str`): New first name of the business account; + :tg-const:`telegram.constants.BusinessLimit.MIN_NAME_LENGTH`- + :tg-const:`telegram.constants.BusinessLimit.MAX_NAME_LENGTH` characters. + last_name (:obj:`str`, optional): New last name of the business account; + 0-:tg-const:`telegram.constants.BusinessLimit.MAX_NAME_LENGTH` characters. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "first_name": first_name, + "last_name": last_name, + } + return await self._post( + "setBusinessAccountName", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def set_business_account_username( + self, + business_connection_id: str, + username: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Changes the username of a managed business account. Requires the + :attr:`~telegram.BusinessBotRights.can_edit_username` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + username (:obj:`str`, optional): New business account username; + 0-:tg-const:`telegram.constants.BusinessLimit.MAX_USERNAME_LENGTH` characters. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "username": username, + } + return await self._post( + "setBusinessAccountUsername", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def set_business_account_bio( + self, + business_connection_id: str, + bio: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Changes the bio of a managed business account. Requires the + :attr:`~telegram.BusinessBotRights.can_edit_bio` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + bio (:obj:`str`, optional): The new value of the bio for the business account; + 0-:tg-const:`telegram.constants.BusinessLimit.MAX_BIO_LENGTH` characters. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "bio": bio, + } + return await self._post( + "setBusinessAccountBio", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def set_business_account_gift_settings( + self, + business_connection_id: str, + show_gift_button: bool, + accepted_gift_types: AcceptedGiftTypes, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Changes the privacy settings pertaining to incoming gifts in a managed business account. + Requires the :attr:`~telegram.BusinessBotRights.can_change_gift_settings` business + bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business + connection + show_gift_button (:obj:`bool`): Pass :obj:`True`, if a button for sending a gift to the + user or by the business account must always be shown in the input field. + accepted_gift_types (:class:`telegram.AcceptedGiftTypes`): Types of gifts accepted by + the business account. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "show_gift_button": show_gift_button, + "accepted_gift_types": accepted_gift_types, + } + return await self._post( + "setBusinessAccountGiftSettings", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def set_business_account_profile_photo( + self, + business_connection_id: str, + photo: "InputProfilePhoto", + is_public: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Changes the profile photo of a managed business account. + Requires the :attr:`~telegram.BusinessBotRights.can_edit_profile_photo` business + bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + photo (:class:`telegram.InputProfilePhoto`): The new profile photo to set. + is_public (:obj:`bool`, optional): Pass :obj:`True` to set the public photo, which will + be visible even if the main photo is hidden by the business account's privacy + settings. An account can have only one public photo. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "photo": photo, + "is_public": is_public, + } + return await self._post( + "setBusinessAccountProfilePhoto", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def remove_business_account_profile_photo( + self, + business_connection_id: str, + is_public: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Removes the current profile photo of a managed business account. + Requires the :attr:`~telegram.BusinessBotRights.can_edit_profile_photo` business + bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + is_public (:obj:`bool`, optional): Pass :obj:`True` to remove the public photo, which + will be visible even if the main photo is hidden by the business account's privacy + settings. After the main photo is removed, the previous profile photo (if present) + becomes the main photo. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "is_public": is_public, + } + return await self._post( + "removeBusinessAccountProfilePhoto", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def convert_gift_to_stars( + self, + business_connection_id: str, + owned_gift_id: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Converts a given regular gift to Telegram Stars. Requires the + :attr:`~telegram.BusinessBotRights.can_convert_gifts_to_stars` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business + connection + owned_gift_id (:obj:`str`): Unique identifier of the regular gift that should be + converted to Telegram Stars. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "owned_gift_id": owned_gift_id, + } + return await self._post( + "convertGiftToStars", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def upgrade_gift( + self, + business_connection_id: str, + owned_gift_id: str, + keep_original_details: bool | None = None, + star_count: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Upgrades a given regular gift to a unique gift. Requires the + :attr:`~telegram.BusinessBotRights.can_transfer_and_upgrade_gifts` business bot right. + Additionally requires the :attr:`~telegram.BusinessBotRights.can_transfer_stars` business + bot right if the upgrade is paid. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business + connection + owned_gift_id (:obj:`str`): Unique identifier of the regular gift that should be + upgraded to a unique one. + keep_original_details (:obj:`bool`, optional): Pass :obj:`True` to keep the original + gift text, sender and receiver in the upgraded gift + star_count (:obj:`int`, optional): The amount of Telegram Stars that will + be paid for the upgrade from the business account balance. If + ``gift.prepaid_upgrade_star_count > 0``, then pass ``0``, otherwise, + the :attr:`~telegram.BusinessBotRights.can_transfer_stars` + business bot right is required and :attr:`telegram.Gift.upgrade_star_count` + must be passed. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "owned_gift_id": owned_gift_id, + "keep_original_details": keep_original_details, + "star_count": star_count, + } + return await self._post( + "upgradeGift", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def transfer_gift( + self, + business_connection_id: str, + owned_gift_id: str, + new_owner_chat_id: int, + star_count: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Transfers an owned unique gift to another user. Requires the + :attr:`~telegram.BusinessBotRights.can_transfer_and_upgrade_gifts` business bot right. + Requires :attr:`~telegram.BusinessBotRights.can_transfer_stars` business bot right if the + transfer is paid. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business + connection + owned_gift_id (:obj:`str`): Unique identifier of the regular gift that should be + transferred. + new_owner_chat_id (:obj:`int`): Unique identifier of the chat which will + own the gift. The chat must be active in the last + :tg-const:`~telegram.constants.BusinessLimit.\ +CHAT_ACTIVITY_TIMEOUT` seconds. + star_count (:obj:`int`, optional): The amount of Telegram Stars that will be paid for + the transfer from the business account balance. If positive, then + the :attr:`~telegram.BusinessBotRights.can_transfer_stars` business bot + right is required. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "owned_gift_id": owned_gift_id, + "new_owner_chat_id": new_owner_chat_id, + "star_count": star_count, + } + return await self._post( + "transferGift", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def transfer_business_account_stars( + self, + business_connection_id: str, + star_count: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Transfers Telegram Stars from the business account balance to the bot's balance. Requires + the :attr:`~telegram.BusinessBotRights.can_transfer_stars` business bot right. + + .. versionadded:: 22.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business + connection + star_count (:obj:`int`): Number of Telegram Stars to transfer; + :tg-const:`~telegram.constants.BusinessLimit.MIN_STAR_COUNT`\ +-:tg-const:`~telegram.constants.BusinessLimit.MAX_STAR_COUNT` + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "star_count": star_count, + } + return await self._post( + "transferBusinessAccountStars", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def replace_sticker_in_set( + self, + user_id: int, + name: str, + old_sticker: "str | Sticker", + sticker: "InputSticker", + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Use this method to replace an existing sticker in a sticker set with a new one. + The method is equivalent to calling :meth:`delete_sticker_from_set`, + then :meth:`add_sticker_to_set`, then :meth:`set_sticker_position_in_set`. + + .. versionadded:: 21.1 + + Args: + user_id (:obj:`int`): User identifier of the sticker set owner. + name (:obj:`str`): Sticker set name. + old_sticker (:obj:`str` | :class:`~telegram.Sticker`): File identifier of the replaced + sticker or the sticker object itself. + + .. versionchanged:: 21.10 + Accepts also :class:`telegram.Sticker` instances. + sticker (:class:`telegram.InputSticker`): An object with information about the added + sticker. If exactly the same sticker had already been added to the set, then the + set remains unchanged. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "user_id": user_id, + "name": name, + "old_sticker": old_sticker if isinstance(old_sticker, str) else old_sticker.file_id, + "sticker": sticker, + } + + return await self._post( + "replaceStickerInSet", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def refund_star_payment( + self, + user_id: int, + telegram_payment_charge_id: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Refunds a successful payment in |tg_stars|. + + .. versionadded:: 21.3 + + Args: + user_id (:obj:`int`): User identifier of the user whose payment will be refunded. + telegram_payment_charge_id (:obj:`str`): Telegram payment identifier. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "user_id": user_id, + "telegram_payment_charge_id": telegram_payment_charge_id, + } + + return await self._post( + "refundStarPayment", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def get_star_transactions( + self, + offset: int | None = None, + limit: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = 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( + 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 edit_user_star_subscription( + self, + user_id: int, + telegram_payment_charge_id: str, + is_canceled: bool, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Allows the bot to cancel or re-enable extension of a subscription paid in Telegram + Stars. + + .. versionadded:: 21.8 + + Args: + user_id (:obj:`int`): Identifier of the user whose subscription will be edited. + telegram_payment_charge_id (:obj:`str`): Telegram payment identifier for the + subscription. + is_canceled (:obj:`bool`): Pass :obj:`True` to cancel extension of the user + subscription; the subscription must be active up to the end of the current + subscription period. Pass :obj:`False` to allow the user to re-enable a + subscription that was previously canceled by the bot. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "user_id": user_id, + "telegram_payment_charge_id": telegram_payment_charge_id, + "is_canceled": is_canceled, + } + return await self._post( + "editUserStarSubscription", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def send_paid_media( + self, + chat_id: str | int, + star_count: int, + media: Sequence["InputPaidMedia"], + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + business_connection_id: str | None = None, + payload: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_thread_id: int | None = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> Message: + """Use this method to send paid media. + + .. versionadded:: 21.4 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| If the chat is a channel, all + Telegram Star proceeds from this media will be credited to the chat's balance. + Otherwise, they will be credited to the bot's balance. + star_count (:obj:`int`): The number of Telegram Stars that must be paid to buy access + to the media; :tg-const:`telegram.constants.InvoiceLimit.MIN_STAR_COUNT` - + :tg-const:`telegram.constants.InvoiceLimit.MAX_STAR_COUNT`. + media (Sequence[:class:`telegram.InputPaidMedia`]): A list describing the media to be + sent; up to :tg-const:`telegram.constants.MediaGroupLimit.MAX_MEDIA_LENGTH` items. + payload (:obj:`str`, optional): Bot-defined paid media payload, + 0-:tg-const:`telegram.constants.InvoiceLimit.MAX_PAYLOAD_LENGTH` bytes. This will + not be displayed to the user, use it for your internal processes. + + .. versionadded:: 21.6 + 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. + business_connection_id (:obj:`str`, optional): |business_id_str| + + .. versionadded:: 21.5 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 + message_thread_id (:obj:`int`, optional): |message_thread_id_arg| + + .. versionadded:: 22.4 + + 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, + "payload": payload, + } + + 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, + business_connection_id=business_connection_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_thread_id=message_thread_id, + ) + + async def create_chat_subscription_invite_link( + self, + chat_id: str | int, + subscription_period: TimePeriod, + subscription_price: int, + name: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> ChatInviteLink: + """ + Use this method to create a `subscription invite link `_ for a channel chat. + The bot must have the :attr:`~telegram.ChatPermissions.can_invite_users` administrator + right. The link can be edited using the :meth:`edit_chat_subscription_invite_link` or + revoked using the :meth:`revoke_chat_invite_link`. + + .. versionadded:: 21.5 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + subscription_period (:obj:`int` | :class:`datetime.timedelta`): The number of seconds + the subscription will be + active for before the next payment. Currently, it must always be + :tg-const:`telegram.constants.ChatSubscriptionLimit.SUBSCRIPTION_PERIOD` (30 days). + + .. versionchanged:: 21.11 + |time-period-input| + subscription_price (:obj:`int`): The number of Telegram Stars a user must pay initially + and after each subsequent subscription period to be a member of the chat; + :tg-const:`telegram.constants.ChatSubscriptionLimit.MIN_PRICE`- + :tg-const:`telegram.constants.ChatSubscriptionLimit.MAX_PRICE`. + name (:obj:`str`, optional): Invite link name; + 0-:tg-const:`telegram.constants.ChatInviteLinkLimit.NAME_LENGTH` characters. + + Returns: + :class:`telegram.ChatInviteLink` + + Raises: + :class:`telegram.error.TelegramError` + + """ + data: JSONDict = { + "chat_id": chat_id, + "subscription_period": subscription_period, + "subscription_price": subscription_price, + "name": name, + } + + result = await self._post( + "createChatSubscriptionInviteLink", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + return ChatInviteLink.de_json(result, self) + + async def edit_chat_subscription_invite_link( + self, + chat_id: str | int, + invite_link: "str | ChatInviteLink", + name: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> ChatInviteLink: + """ + Use this method to edit a subscription invite link created by the bot. The bot must have + :attr:`telegram.ChatPermissions.can_invite_users` administrator right. + + .. versionadded:: 21.5 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + invite_link (:obj:`str` | :obj:`telegram.ChatInviteLink`): The invite link to edit. + name (:obj:`str`, optional): Invite link name; + 0-:tg-const:`telegram.constants.ChatInviteLinkLimit.NAME_LENGTH` characters. + + Tip: + Omitting this argument removes the name of the invite link. + + Returns: + :class:`telegram.ChatInviteLink` + + Raises: + :class:`telegram.error.TelegramError` + + """ + link = invite_link.invite_link if isinstance(invite_link, ChatInviteLink) else invite_link + data: JSONDict = { + "chat_id": chat_id, + "invite_link": link, + "name": name, + } + + result = await self._post( + "editChatSubscriptionInviteLink", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + return ChatInviteLink.de_json(result, self) + + async def get_available_gifts( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> Gifts: + """Returns the list of gifts that can be sent by the bot to users and channel chats. + Requires no parameters. + + .. versionadded:: 21.8 + + Returns: + :class:`telegram.Gifts` + + Raises: + :class:`telegram.error.TelegramError` + """ + return Gifts.de_json( + await self._post( + "getAvailableGifts", + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + ) + + async def send_gift( + self, + gift_id: "str | Gift", + text: str | None = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Sequence["MessageEntity"] | None = None, + pay_for_upgrade: bool | None = None, + chat_id: str | int | None = None, + user_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Sends a gift to the given user or channel chat. + The gift can't be converted to Telegram Stars by the receiver. + + .. versionadded:: 21.8 + .. versionchanged:: 22.1 + Bot API 8.3 made :paramref:`user_id` optional. In version 22.1, the methods + signature was changed accordingly. + + Args: + gift_id (:obj:`str` | :class:`~telegram.Gift`): Identifier of the gift or a + :class:`~telegram.Gift` object; limited gifts can't be sent to channel chats + user_id (:obj:`int`, optional): Required if :paramref:`chat_id` is not specified. + Unique identifier of the target user that will receive the gift. + + .. versionchanged:: 21.11 + Now optional. + chat_id (:obj:`int` | :obj:`str`, optional): Required if :paramref:`user_id` + is not specified. |chat_id_channel| It will receive the gift. + + .. versionadded:: 21.11 + text (:obj:`str`, optional): Text that will be shown along with the gift; + 0- :tg-const:`telegram.constants.GiftLimit.MAX_TEXT_LENGTH` characters + text_parse_mode (:obj:`str`, optional): Mode for parsing entities. + See :class:`telegram.constants.ParseMode` and + `formatting options `__ for + more details. Entities other than :attr:`~MessageEntity.BOLD`, + :attr:`~MessageEntity.ITALIC`, :attr:`~MessageEntity.UNDERLINE`, + :attr:`~MessageEntity.STRIKETHROUGH`, :attr:`~MessageEntity.SPOILER`, and + :attr:`~MessageEntity.CUSTOM_EMOJI` are ignored. + text_entities (Sequence[:class:`telegram.MessageEntity`], optional): A list of special + entities that appear in the gift text. It can be specified instead of + :paramref:`text_parse_mode`. Entities other than :attr:`~MessageEntity.BOLD`, + :attr:`~MessageEntity.ITALIC`, :attr:`~MessageEntity.UNDERLINE`, + :attr:`~MessageEntity.STRIKETHROUGH`, :attr:`~MessageEntity.SPOILER`, and + :attr:`~MessageEntity.CUSTOM_EMOJI` are ignored. + pay_for_upgrade (:obj:`bool`, optional): Pass :obj:`True` to pay for the gift upgrade + from the bot's balance, thereby making the upgrade free for the receiver. + + .. versionadded:: 21.10 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "user_id": user_id, + "gift_id": gift_id.id if isinstance(gift_id, Gift) else gift_id, + "text": text, + "text_parse_mode": text_parse_mode, + "text_entities": text_entities, + "pay_for_upgrade": pay_for_upgrade, + "chat_id": chat_id, + } + return await self._post( + "sendGift", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def verify_chat( + self, + chat_id: int | str, + custom_description: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Verifies a chat |org-verify| which is represented by the bot. + + .. versionadded:: 21.10 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + custom_description (:obj:`str`, optional): Custom description for the verification; + 0- :tg-const:`telegram.constants.VerifyLimit.MAX_TEXT_LENGTH` characters. Must be + empty if the organization isn't allowed to provide a custom verification + description. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "chat_id": chat_id, + "custom_description": custom_description, + } + return await self._post( + "verifyChat", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def verify_user( + self, + user_id: int, + custom_description: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Verifies a user |org-verify| which is represented by the bot. + + .. versionadded:: 21.10 + + Args: + user_id (:obj:`int`): Unique identifier of the target user. + custom_description (:obj:`str`, optional): Custom description for the verification; + 0- :tg-const:`telegram.constants.VerifyLimit.MAX_TEXT_LENGTH` characters. Must be + empty if the organization isn't allowed to provide a custom verification + description. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "user_id": user_id, + "custom_description": custom_description, + } + return await self._post( + "verifyUser", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def remove_chat_verification( + self, + chat_id: int | str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Removes verification from a chat that is currently verified |org-verify| + represented by the bot. + + .. versionadded:: 21.10 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "chat_id": chat_id, + } + return await self._post( + "removeChatVerification", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def remove_user_verification( + self, + user_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Removes verification from a user who is currently verified |org-verify| + represented by the bot. + + .. versionadded:: 21.10 + + Args: + user_id (:obj:`int`): Unique identifier of the target user. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "user_id": user_id, + } + return await self._post( + "removeUserVerification", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def get_my_star_balance( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> StarAmount: + """A method to get the current Telegram Stars balance of the bot. Requires no parameters. + + .. versionadded:: 22.3 + + Returns: + :class:`telegram.StarAmount` + + Raises: + :class:`telegram.error.TelegramError` + """ + return StarAmount.de_json( + await self._post( + "getMyStarBalance", + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + ) + + async def approve_suggested_post( + self, + chat_id: int, + message_id: int, + send_date: int | dtm.datetime | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Use this method to approve a suggested post in a direct messages chat. + The bot must have the :attr:`~telegram.ChatMemberAdministrator.can_post_messages` + administrator right in the corresponding channel chat. + + .. versionadded:: 22.4 + + Args: + chat_id (:obj:`int`): Unique identifier of the target direct messages chat. + message_id (:obj:`int`): Identifier of a suggested post message to approve. + send_date (:obj:`int` | :obj:`datetime.datetime`, optional): Date when the post is + expected to be published; omit if the date has already been specified when the + suggested post was created. If specified, then the date must be not more than + :tg-const:`telegram.constants.SuggestedPost.MAX_SEND_DATE` seconds (30 days) + in the future. + + |tz-naive-dtms| + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "chat_id": chat_id, + "message_id": message_id, + "send_date": send_date, + } + + return await self._post( + "approveSuggestedPost", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def decline_suggested_post( + self, + chat_id: int, + message_id: int, + comment: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Use this method to decline a suggested post in a direct messages chat. + The bot must have the :attr:`~telegram.ChatMemberAdministrator.can_manage_direct_messages` + administrator right in the corresponding channel chat. + + .. versionadded:: 22.4 + + Args: + chat_id (:obj:`int`): Unique identifier of the target direct messages chat. + message_id (:obj:`int`): Identifier of a suggested post message to decline. + comment (:obj:`str`, optional): Comment for the creator of the suggested post. + 0-:tg-const:`telegram.constants.SuggestedPost.MAX_COMMENT_LENGTH` characters. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "chat_id": chat_id, + "message_id": message_id, + "comment": comment, + } + + return await self._post( + "declineSuggestedPost", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def repost_story( + self, + business_connection_id: str, + from_chat_id: int, + from_story_id: int, + active_period: TimePeriod, + post_to_chat_page: bool | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> Story: + """ + Reposts a story on behalf of a business account from another business account. + Both business accounts must be managed by the same bot, and the story on the source account + must have been posted (or reposted) by the bot. Requires the + :attr:`~telegram.BusinessBotRight.can_manage_stories` business bot right for both + business accounts. + + .. versionadded:: 22.6 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business + from_chat_id (:obj:`int`): Unique identifier of the chat which posted the story that + should be reposted + from_story_id (:obj:`int`): Unique identifier of the story that should be reposted + active_period (:obj:`int` | :class:`datetime.timedelta`): Period after which the story + is moved to the archive, in seconds; must be one of + :tg-const:`telegram.constants.StoryLimit.SIX_HOURS`, + :tg-const:`telegram.constants.StoryLimit.TWELVE_HOURS`, + :tg-const:`telegram.constants.StoryLimit.ONE_DAY`, or + :tg-const:`telegram.constants.StoryLimit.TWO_DAYS`. + post_to_chat_page (:obj:`bool`, optional): Pass :obj:`True` to keep the story + accessible after it expires. + protect_content (:obj:`bool`, optional): Pass :obj:`True` if the content of the story + must be protected from forwarding and screenshotting + + Returns: + :class:`telegram.Story` + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "business_connection_id": business_connection_id, + "from_chat_id": from_chat_id, + "from_story_id": from_story_id, + "active_period": active_period, + "post_to_chat_page": post_to_chat_page, + "protect_content": protect_content, + } + return Story.de_json( + data=await self._post( + "repostStory", + 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 get_user_gifts( + self, + user_id: int, + exclude_unlimited: bool | None = None, + exclude_limited_upgradable: bool | None = None, + exclude_limited_non_upgradable: bool | None = None, + exclude_from_blockchain: bool | None = None, + exclude_unique: bool | None = None, + sort_by_price: bool | None = None, + offset: str | None = None, + limit: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> OwnedGifts: + """Returns the gifts owned and hosted by a user. + + .. versionadded:: 22.6 + + user_id (:obj:`int`): Unique identifier of the user + exclude_unlimited (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that can be + purchased an unlimited number of times + exclude_limited_upgradable (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that + can be purchased a limited number of times and can be upgraded to unique + exclude_limited_non_upgradable (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts + that can be purchased a limited number of times and can't be upgraded to unique + exclude_from_blockchain (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that + were assigned from the TON blockchain and can't be resold or transferred in Telegram + exclude_unique (:obj:`bool`, optional): Pass :obj:`True` to exclude unique gifts + sort_by_price (:obj:`bool`, optional): Pass :obj:`True` to sort results by gift price + instead of send date. Sorting is applied before pagination. + offset (:obj:`str`, optional): Offset of the first entry to return as received from the + previous request; use an empty string to get the first chunk of results + limit (:obj:`int`, optional): The maximum number of gifts to be returned; + :tg-const:`~telegram.constants.BusinessLimit.MIN_GIFT_RESULTS` - + :tg-const:`~telegram.constants.BusinessLimit.MAX_GIFT_RESLUTS`. + Defaults to :tg-const:`~telegram.constants.BusinessLimit.MAX_GIFT_RESLUTS` + + Returns: + :class:`telegram.OwnedGifts`: The owned gifts for the user. + + Raises: + :class:`telegram.error.TelegramError` + """ + data: JSONDict = { + "user_id": user_id, + "exclude_unlimited": exclude_unlimited, + "exclude_limited_upgradable": exclude_limited_upgradable, + "exclude_limited_non_upgradable": exclude_limited_non_upgradable, + "exclude_from_blockchain": exclude_from_blockchain, + "exclude_unique": exclude_unique, + "sort_by_price": sort_by_price, + "offset": offset, + "limit": limit, + } + + result = await self._post( + "getUserGifts", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + return OwnedGifts.de_json(result, self) + + async def get_chat_gifts( + self, + chat_id: int | str, + exclude_unsaved: bool | None = None, + exclude_saved: bool | None = None, + exclude_unlimited: bool | None = None, + exclude_limited_upgradable: bool | None = None, + exclude_limited_non_upgradable: bool | None = None, + exclude_from_blockchain: bool | None = None, + exclude_unique: bool | None = None, + sort_by_price: bool | None = None, + offset: str | None = None, + limit: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> OwnedGifts: + """Use this method to get gifts owned by a chat. + + .. versionadded:: 22.6 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + exclude_unsaved (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that aren't + saved to the chat's profile page. Always :obj:`True`, unless the bot has the + :attr:`~telegram.ChatAdministratorRights..can_post_messages` administrator right in the + channel. + exclude_saved (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that are saved to + the chat's profile page. Always :obj:`False`, unless the bot has the + :attr:`~telegram.ChatAdministratorRights..can_post_messages` administrator right in the + channel. + exclude_unlimited (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that can be + purchased an unlimited number of times + exclude_limited_upgradable (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that + can be purchased a limited number of times and can be upgraded to unique + exclude_limited_non_upgradable (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts + that can be purchased a limited number of times and can't be upgraded to unique + exclude_from_blockchain (:obj:`bool`, optional): Pass :obj:`True` to exclude gifts that + were assigned from the TON blockchain and can't be resold or transferred in Telegram + exclude_unique (:obj:`bool`, optional): Pass :obj:`True` to exclude unique gifts + sort_by_price (:obj:`bool`, optional): Pass :obj:`True` to sort results by gift price + instead of send date. Sorting is applied before pagination. + offset (:obj:`str`, optional): Offset of the first entry to return as received from the + previous request; use an empty string to get the first chunk of results + limit (:obj:`int`, optional): The maximum number of gifts to be returned; + :tg-const:`~telegram.constants.BusinessLimit.MIN_GIFT_RESULTS` - + :tg-const:`~telegram.constants.BusinessLimit.MAX_GIFT_RESLUTS`. + Defaults to :tg-const:`~telegram.constants.BusinessLimit.MAX_GIFT_RESLUTS` + + Returns: + :class:`telegram.OwnedGifts`: The owned gifts for the chat. + + Raises: + :class:`telegram.error.TelegramError` + """ + + data: JSONDict = { + "chat_id": chat_id, + "exclude_unsaved": exclude_unsaved, + "exclude_saved": exclude_saved, + "exclude_unlimited": exclude_unlimited, + "exclude_limited_upgradable": exclude_limited_upgradable, + "exclude_limited_non_upgradable": exclude_limited_non_upgradable, + "exclude_from_blockchain": exclude_from_blockchain, + "exclude_unique": exclude_unique, + "sort_by_price": sort_by_price, + "offset": offset, + "limit": limit, + } + + result = await self._post( + "getChatGifts", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + return OwnedGifts.de_json(result, self) + + 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} + + if self.last_name: + data["last_name"] = self.last_name + + return data + + # camelCase aliases + getMe = get_me + """Alias for :meth:`get_me`""" + sendMessage = send_message + """Alias for :meth:`send_message`""" + sendMessageDraft = send_message_draft + """Alias for :meth:`send_message_draft`""" + deleteMessage = delete_message + """Alias for :meth:`delete_message`""" + deleteMessages = delete_messages + """Alias for :meth:`delete_messages`""" + forwardMessage = forward_message + """Alias for :meth:`forward_message`""" + forwardMessages = forward_messages + """Alias for :meth:`forward_messages`""" + sendPhoto = send_photo """Alias for :meth:`send_photo`""" sendAudio = send_audio """Alias for :meth:`send_audio`""" @@ -8324,6 +11974,8 @@ def __hash__(self) -> int: """Alias for :meth:`send_chat_action`""" answerInlineQuery = answer_inline_query """Alias for :meth:`answer_inline_query`""" + savePreparedInlineMessage = save_prepared_inline_message + """Alias for :meth:`save_prepared_inline_message`""" getUserProfilePhotos = get_user_profile_photos """Alias for :meth:`get_user_profile_photos`""" getFile = get_file @@ -8408,6 +12060,8 @@ def __hash__(self) -> int: """Alias for :meth:`set_chat_title`""" setChatDescription = set_chat_description """Alias for :meth:`set_chat_description`""" + setUserEmojiStatus = set_user_emoji_status + """Alias for :meth:`set_user_emoji_status`""" pinChatMessage = pin_chat_message """Alias for :meth:`pin_chat_message`""" unpinChatMessage = unpin_chat_message @@ -8428,12 +12082,6 @@ def __hash__(self) -> int: """Alias for :meth:`set_sticker_position_in_set`""" deleteStickerFromSet = delete_sticker_from_set """Alias for :meth:`delete_sticker_from_set`""" - setStickerSetThumb = set_sticker_set_thumb - """Alias for :meth:`set_sticker_set_thumb` - - .. deprecated:: 20.2 - Bot API 6.6 renamed this method to :meth:`~Bot.set_sticker_set_thumbnail`. - """ setStickerSetThumbnail = set_sticker_set_thumbnail """Alias for :meth:`set_sticker_set_thumbnail`""" setPassportDataErrors = set_passport_data_errors @@ -8442,6 +12090,10 @@ def __hash__(self) -> int: """Alias for :meth:`send_poll`""" stopPoll = stop_poll """Alias for :meth:`stop_poll`""" + sendChecklist = send_checklist + """Alias for :meth:`send_checklist`""" + editMessageChecklist = edit_message_checklist + """Alias for :meth:`edit_message_checklist`""" sendDice = send_dice """Alias for :meth:`send_dice`""" getMyCommands = get_my_commands @@ -8454,6 +12106,8 @@ def __hash__(self) -> int: """Alias for :meth:`log_out`""" copyMessage = copy_message """Alias for :meth:`copy_message`""" + copyMessages = copy_messages + """Alias for :meth:`copy_messages`""" getChatMenuButton = get_chat_menu_button """Alias for :meth:`get_chat_menu_button`""" setChatMenuButton = set_chat_menu_button @@ -8512,3 +12166,85 @@ def __hash__(self) -> int: """Alias for :meth:`set_my_name`""" getMyName = get_my_name """Alias for :meth:`get_my_name`""" + unpinAllGeneralForumTopicMessages = unpin_all_general_forum_topic_messages + """Alias for :meth:`unpin_all_general_forum_topic_messages`""" + getUserChatBoosts = get_user_chat_boosts + """Alias for :meth:`get_user_chat_boosts`""" + setMessageReaction = set_message_reaction + """Alias for :meth:`set_message_reaction`""" + giftPremiumSubscription = gift_premium_subscription + """Alias for :meth:`gift_premium_subscription`""" + getBusinessAccountGifts = get_business_account_gifts + """Alias for :meth:`get_business_account_gifts`""" + getBusinessAccountStarBalance = get_business_account_star_balance + """Alias for :meth:`get_business_account_star_balance`""" + getBusinessConnection = get_business_connection + """Alias for :meth:`get_business_connection`""" + readBusinessMessage = read_business_message + """Alias for :meth:`read_business_message`""" + deleteBusinessMessages = delete_business_messages + """Alias for :meth:`delete_business_messages`""" + postStory = post_story + """Alias for :meth:`post_story`""" + editStory = edit_story + """Alias for :meth:`edit_story`""" + deleteStory = delete_story + """Alias for :meth:`delete_story`""" + setBusinessAccountName = set_business_account_name + """Alias for :meth:`set_business_account_name`""" + setBusinessAccountUsername = set_business_account_username + """Alias for :meth:`set_business_account_username`""" + setBusinessAccountBio = set_business_account_bio + """Alias for :meth:`set_business_account_bio`""" + setBusinessAccountGiftSettings = set_business_account_gift_settings + """Alias for :meth:`set_business_account_gift_settings`""" + setBusinessAccountProfilePhoto = set_business_account_profile_photo + """Alias for :meth:`set_business_account_profile_photo`""" + removeBusinessAccountProfilePhoto = remove_business_account_profile_photo + """Alias for :meth:`remove_business_account_profile_photo`""" + convertGiftToStars = convert_gift_to_stars + """Alias for :meth:`convert_gift_to_stars`""" + upgradeGift = upgrade_gift + """Alias for :meth:`upgrade_gift`""" + transferGift = transfer_gift + """Alias for :meth:`transfer_gift`""" + transferBusinessAccountStars = transfer_business_account_stars + """Alias for :meth:`transfer_business_account_stars`""" + replaceStickerInSet = replace_sticker_in_set + """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`""" + editUserStarSubscription = edit_user_star_subscription + """Alias for :meth:`edit_user_star_subscription`""" + sendPaidMedia = send_paid_media + """Alias for :meth:`send_paid_media`""" + createChatSubscriptionInviteLink = create_chat_subscription_invite_link + """Alias for :meth:`create_chat_subscription_invite_link`""" + editChatSubscriptionInviteLink = edit_chat_subscription_invite_link + """Alias for :meth:`edit_chat_subscription_invite_link`""" + getAvailableGifts = get_available_gifts + """Alias for :meth:`get_available_gifts`""" + sendGift = send_gift + """Alias for :meth:`send_gift`""" + verifyChat = verify_chat + """Alias for :meth:`verify_chat`""" + verifyUser = verify_user + """Alias for :meth:`verify_user`""" + removeChatVerification = remove_chat_verification + """Alias for :meth:`remove_chat_verification`""" + removeUserVerification = remove_user_verification + """Alias for :meth:`remove_user_verification`""" + getMyStarBalance = get_my_star_balance + """Alias for :meth:`get_my_star_balance`""" + approveSuggestedPost = approve_suggested_post + """Alias for :meth:`approve_suggested_post`""" + declineSuggestedPost = decline_suggested_post + """Alias for :meth:`decline_suggested_post`""" + repostStory = repost_story + """Alias for :meth:`repost_story`""" + getUserGifts = get_user_gifts + """Alias for :meth:`get_user_gifts`""" + getChatGifts = get_chat_gifts + """Alias for :meth:`get_chat_gifts`""" diff --git a/telegram/_botcommand.py b/src/telegram/_botcommand.py similarity index 86% rename from telegram/_botcommand.py rename to src/telegram/_botcommand.py index a196fd5795c..6b697128427 100644 --- a/telegram/_botcommand.py +++ b/src/telegram/_botcommand.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Bot Command.""" -from typing import ClassVar, Optional +from typing import Final from telegram import constants from telegram._telegramobject import TelegramObject @@ -50,9 +50,9 @@ class BotCommand(TelegramObject): """ - __slots__ = ("description", "command") + __slots__ = ("command", "description") - def __init__(self, command: str, description: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, command: str, description: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self.command: str = command self.description: str = description @@ -61,22 +61,22 @@ def __init__(self, command: str, description: str, *, api_kwargs: Optional[JSOND self._freeze() - MIN_COMMAND: ClassVar[int] = constants.BotCommandLimit.MIN_COMMAND + MIN_COMMAND: Final[int] = constants.BotCommandLimit.MIN_COMMAND """:const:`telegram.constants.BotCommandLimit.MIN_COMMAND` .. versionadded:: 20.0 """ - MAX_COMMAND: ClassVar[int] = constants.BotCommandLimit.MAX_COMMAND + MAX_COMMAND: Final[int] = constants.BotCommandLimit.MAX_COMMAND """:const:`telegram.constants.BotCommandLimit.MAX_COMMAND` .. versionadded:: 20.0 """ - MIN_DESCRIPTION: ClassVar[int] = constants.BotCommandLimit.MIN_DESCRIPTION + MIN_DESCRIPTION: Final[int] = constants.BotCommandLimit.MIN_DESCRIPTION """:const:`telegram.constants.BotCommandLimit.MIN_DESCRIPTION` .. versionadded:: 20.0 """ - MAX_DESCRIPTION: ClassVar[int] = constants.BotCommandLimit.MAX_DESCRIPTION + MAX_DESCRIPTION: Final[int] = constants.BotCommandLimit.MAX_DESCRIPTION """:const:`telegram.constants.BotCommandLimit.MAX_DESCRIPTION` .. versionadded:: 20.0 diff --git a/telegram/_botcommandscope.py b/src/telegram/_botcommandscope.py similarity index 81% rename from telegram/_botcommandscope.py rename to src/telegram/_botcommandscope.py index 55d969e445c..cde05a79ac6 100644 --- a/telegram/_botcommandscope.py +++ b/src/telegram/_botcommandscope.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,10 +18,12 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. # pylint: disable=redefined-builtin """This module contains objects representing Telegram bot command scopes.""" -from typing import TYPE_CHECKING, ClassVar, Dict, Optional, Type, Union + +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._telegramobject import TelegramObject +from telegram._utils import enum from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -60,36 +62,40 @@ class BotCommandScope(TelegramObject): __slots__ = ("type",) - DEFAULT: ClassVar[str] = constants.BotCommandScopeType.DEFAULT + DEFAULT: Final[str] = constants.BotCommandScopeType.DEFAULT """:const:`telegram.constants.BotCommandScopeType.DEFAULT`""" - ALL_PRIVATE_CHATS: ClassVar[str] = constants.BotCommandScopeType.ALL_PRIVATE_CHATS + ALL_PRIVATE_CHATS: Final[str] = constants.BotCommandScopeType.ALL_PRIVATE_CHATS """:const:`telegram.constants.BotCommandScopeType.ALL_PRIVATE_CHATS`""" - ALL_GROUP_CHATS: ClassVar[str] = constants.BotCommandScopeType.ALL_GROUP_CHATS + ALL_GROUP_CHATS: Final[str] = constants.BotCommandScopeType.ALL_GROUP_CHATS """:const:`telegram.constants.BotCommandScopeType.ALL_GROUP_CHATS`""" - ALL_CHAT_ADMINISTRATORS: ClassVar[str] = constants.BotCommandScopeType.ALL_CHAT_ADMINISTRATORS + ALL_CHAT_ADMINISTRATORS: Final[str] = constants.BotCommandScopeType.ALL_CHAT_ADMINISTRATORS """:const:`telegram.constants.BotCommandScopeType.ALL_CHAT_ADMINISTRATORS`""" - CHAT: ClassVar[str] = constants.BotCommandScopeType.CHAT + CHAT: Final[str] = constants.BotCommandScopeType.CHAT """:const:`telegram.constants.BotCommandScopeType.CHAT`""" - CHAT_ADMINISTRATORS: ClassVar[str] = constants.BotCommandScopeType.CHAT_ADMINISTRATORS + CHAT_ADMINISTRATORS: Final[str] = constants.BotCommandScopeType.CHAT_ADMINISTRATORS """:const:`telegram.constants.BotCommandScopeType.CHAT_ADMINISTRATORS`""" - CHAT_MEMBER: ClassVar[str] = constants.BotCommandScopeType.CHAT_MEMBER + CHAT_MEMBER: Final[str] = constants.BotCommandScopeType.CHAT_MEMBER """:const:`telegram.constants.BotCommandScopeType.CHAT_MEMBER`""" - def __init__(self, type: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, type: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) - self.type: str = type + self.type: str = enum.get_member(constants.BotCommandScopeType, type, type) self._id_attrs = (self.type,) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["BotCommandScope"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "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. + data (dict[:obj:`str`, ...]): The JSON data. + 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. @@ -97,10 +103,7 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["BotCommandSc """ data = cls._parse_data(data) - if not data: - return None - - _class_mapping: Dict[str, Type["BotCommandScope"]] = { + _class_mapping: dict[str, type[BotCommandScope]] = { cls.DEFAULT: BotCommandScopeDefault, cls.ALL_PRIVATE_CHATS: BotCommandScopeAllPrivateChats, cls.ALL_GROUP_CHATS: BotCommandScopeAllGroupChats, @@ -128,7 +131,7 @@ class BotCommandScopeDefault(BotCommandScope): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(type=BotCommandScope.DEFAULT, api_kwargs=api_kwargs) self._freeze() @@ -144,7 +147,7 @@ class BotCommandScopeAllPrivateChats(BotCommandScope): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(type=BotCommandScope.ALL_PRIVATE_CHATS, api_kwargs=api_kwargs) self._freeze() @@ -159,7 +162,7 @@ class BotCommandScopeAllGroupChats(BotCommandScope): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(type=BotCommandScope.ALL_GROUP_CHATS, api_kwargs=api_kwargs) self._freeze() @@ -174,7 +177,7 @@ class BotCommandScopeAllChatAdministrators(BotCommandScope): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(type=BotCommandScope.ALL_CHAT_ADMINISTRATORS, api_kwargs=api_kwargs) self._freeze() @@ -197,10 +200,10 @@ class BotCommandScopeChat(BotCommandScope): __slots__ = ("chat_id",) - def __init__(self, chat_id: Union[str, int], *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, chat_id: str | int, *, api_kwargs: JSONDict | None = None): super().__init__(type=BotCommandScope.CHAT, api_kwargs=api_kwargs) with self._unfrozen(): - self.chat_id: Union[str, int] = ( + self.chat_id: str | int = ( chat_id if isinstance(chat_id, str) and chat_id.startswith("@") else int(chat_id) ) self._id_attrs = (self.type, self.chat_id) @@ -224,10 +227,10 @@ class BotCommandScopeChatAdministrators(BotCommandScope): __slots__ = ("chat_id",) - def __init__(self, chat_id: Union[str, int], *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, chat_id: str | int, *, api_kwargs: JSONDict | None = None): super().__init__(type=BotCommandScope.CHAT_ADMINISTRATORS, api_kwargs=api_kwargs) with self._unfrozen(): - self.chat_id: Union[str, int] = ( + self.chat_id: str | int = ( chat_id if isinstance(chat_id, str) and chat_id.startswith("@") else int(chat_id) ) self._id_attrs = (self.type, self.chat_id) @@ -254,12 +257,10 @@ class BotCommandScopeChatMember(BotCommandScope): __slots__ = ("chat_id", "user_id") - def __init__( - self, chat_id: Union[str, int], user_id: int, *, api_kwargs: Optional[JSONDict] = None - ): + def __init__(self, chat_id: str | int, user_id: int, *, api_kwargs: JSONDict | None = None): super().__init__(type=BotCommandScope.CHAT_MEMBER, api_kwargs=api_kwargs) with self._unfrozen(): - self.chat_id: Union[str, int] = ( + self.chat_id: str | int = ( chat_id if isinstance(chat_id, str) and chat_id.startswith("@") else int(chat_id) ) self.user_id: int = user_id diff --git a/telegram/_botdescription.py b/src/telegram/_botdescription.py similarity index 90% rename from telegram/_botdescription.py rename to src/telegram/_botdescription.py index f920e3624cf..e7fe7211cb3 100644 --- a/telegram/_botdescription.py +++ b/src/telegram/_botdescription.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # 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 two objects that represent a Telegram bots (short) description.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -41,7 +40,7 @@ class BotDescription(TelegramObject): __slots__ = ("description",) - def __init__(self, description: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, description: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self.description: str = description @@ -68,7 +67,7 @@ class BotShortDescription(TelegramObject): __slots__ = ("short_description",) - def __init__(self, short_description: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, short_description: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self.short_description: str = short_description diff --git a/telegram/_botname.py b/src/telegram/_botname.py similarity index 88% rename from telegram/_botname.py rename to src/telegram/_botname.py index d4b05f1dfcc..42021df472e 100644 --- a/telegram/_botname.py +++ b/src/telegram/_botname.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,8 @@ # 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 represent a Telegram bots name.""" -from typing import ClassVar, Optional + +from typing import Final from telegram import constants from telegram._telegramobject import TelegramObject @@ -42,7 +43,7 @@ class BotName(TelegramObject): __slots__ = ("name",) - def __init__(self, name: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, name: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self.name: str = name @@ -50,5 +51,5 @@ def __init__(self, name: str, *, api_kwargs: Optional[JSONDict] = None): self._freeze() - MAX_LENGTH: ClassVar[int] = constants.BotNameLimit.MAX_NAME_LENGTH + MAX_LENGTH: Final[int] = constants.BotNameLimit.MAX_NAME_LENGTH """:const:`telegram.constants.BotNameLimit.MAX_NAME_LENGTH`""" diff --git a/src/telegram/_business.py b/src/telegram/_business.py new file mode 100644 index 00000000000..5b2026c62d0 --- /dev/null +++ b/src/telegram/_business.py @@ -0,0 +1,691 @@ +#!/usr/bin/env python +# pylint: disable=redefined-builtin +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 the Telegram Business related classes.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING +from zoneinfo import ZoneInfo + +from telegram._chat import Chat +from telegram._files.location import Location +from telegram._files.sticker import Sticker +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils.argumentparsing import ( + de_json_optional, + de_list_optional, + parse_sequence_arg, +) +from telegram._utils.datetime import ( + extract_tzinfo_from_defaults, + from_timestamp, + get_zone_info, +) +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class BusinessBotRights(TelegramObject): + """ + This object represents the rights of a business bot. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if all their attributes are equal. + + .. versionadded:: 22.1 + + Args: + can_reply (:obj:`bool`, optional): True, if the bot can send and edit messages in the + private chats that had incoming messages in the last 24 hours. + can_read_messages (:obj:`bool`, optional): True, if the bot can mark incoming private + messages as read. + can_delete_sent_messages (:obj:`bool`, optional): True, if the bot can delete messages + sent by the bot. + can_delete_all_messages (:obj:`bool`, optional): True, if the bot can delete all private + messages in managed chats. + can_edit_name (:obj:`bool`, optional): True, if the bot can edit the first and last name + of the business account. + can_edit_bio (:obj:`bool`, optional): True, if the bot can edit the bio of the + business account. + can_edit_profile_photo (:obj:`bool`, optional): True, if the bot can edit the profile + photo of the business account. + can_edit_username (:obj:`bool`, optional): True, if the bot can edit the username of the + business account. + can_change_gift_settings (:obj:`bool`, optional): True, if the bot can change the privacy + settings pertaining to gifts for the business account. + can_view_gifts_and_stars (:obj:`bool`, optional): True, if the bot can view gifts and the + amount of Telegram Stars owned by the business account. + can_convert_gifts_to_stars (:obj:`bool`, optional): True, if the bot can convert regular + gifts owned by the business account to Telegram Stars. + can_transfer_and_upgrade_gifts (:obj:`bool`, optional): True, if the bot can transfer and + upgrade gifts owned by the business account. + can_transfer_stars (:obj:`bool`, optional): True, if the bot can transfer Telegram Stars + received by the business account to its own account, or use them to upgrade and + transfer gifts. + can_manage_stories (:obj:`bool`, optional): True, if the bot can post, edit and delete + stories on behalf of the business account. + + Attributes: + can_reply (:obj:`bool`): Optional. True, if the bot can send and edit messages in the + private chats that had incoming messages in the last 24 hours. + can_read_messages (:obj:`bool`): Optional. True, if the bot can mark incoming private + messages as read. + can_delete_sent_messages (:obj:`bool`): Optional. True, if the bot can delete messages + sent by the bot. + can_delete_all_messages (:obj:`bool`): Optional. True, if the bot can delete all private + messages in managed chats. + can_edit_name (:obj:`bool`): Optional. True, if the bot can edit the first and last name + of the business account. + can_edit_bio (:obj:`bool`): Optional. True, if the bot can edit the bio of the + business account. + can_edit_profile_photo (:obj:`bool`): Optional. True, if the bot can edit the profile + photo of the business account. + can_edit_username (:obj:`bool`): Optional. True, if the bot can edit the username of the + business account. + can_change_gift_settings (:obj:`bool`): Optional. True, if the bot can change the privacy + settings pertaining to gifts for the business account. + can_view_gifts_and_stars (:obj:`bool`): Optional. True, if the bot can view gifts and the + amount of Telegram Stars owned by the business account. + can_convert_gifts_to_stars (:obj:`bool`): Optional. True, if the bot can convert regular + gifts owned by the business account to Telegram Stars. + can_transfer_and_upgrade_gifts (:obj:`bool`): Optional. True, if the bot can transfer and + upgrade gifts owned by the business account. + can_transfer_stars (:obj:`bool`): Optional. True, if the bot can transfer Telegram Stars + received by the business account to its own account, or use them to upgrade and + transfer gifts. + can_manage_stories (:obj:`bool`): Optional. True, if the bot can post, edit and delete + stories on behalf of the business account. + """ + + __slots__ = ( + "can_change_gift_settings", + "can_convert_gifts_to_stars", + "can_delete_all_messages", + "can_delete_sent_messages", + "can_edit_bio", + "can_edit_name", + "can_edit_profile_photo", + "can_edit_username", + "can_manage_stories", + "can_read_messages", + "can_reply", + "can_transfer_and_upgrade_gifts", + "can_transfer_stars", + "can_view_gifts_and_stars", + ) + + def __init__( + self, + can_reply: bool | None = None, + can_read_messages: bool | None = None, + can_delete_sent_messages: bool | None = None, + can_delete_all_messages: bool | None = None, + can_edit_name: bool | None = None, + can_edit_bio: bool | None = None, + can_edit_profile_photo: bool | None = None, + can_edit_username: bool | None = None, + can_change_gift_settings: bool | None = None, + can_view_gifts_and_stars: bool | None = None, + can_convert_gifts_to_stars: bool | None = None, + can_transfer_and_upgrade_gifts: bool | None = None, + can_transfer_stars: bool | None = None, + can_manage_stories: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.can_reply: bool | None = can_reply + self.can_read_messages: bool | None = can_read_messages + self.can_delete_sent_messages: bool | None = can_delete_sent_messages + self.can_delete_all_messages: bool | None = can_delete_all_messages + self.can_edit_name: bool | None = can_edit_name + self.can_edit_bio: bool | None = can_edit_bio + self.can_edit_profile_photo: bool | None = can_edit_profile_photo + self.can_edit_username: bool | None = can_edit_username + self.can_change_gift_settings: bool | None = can_change_gift_settings + self.can_view_gifts_and_stars: bool | None = can_view_gifts_and_stars + self.can_convert_gifts_to_stars: bool | None = can_convert_gifts_to_stars + self.can_transfer_and_upgrade_gifts: bool | None = can_transfer_and_upgrade_gifts + self.can_transfer_stars: bool | None = can_transfer_stars + self.can_manage_stories: bool | None = can_manage_stories + + self._id_attrs = ( + self.can_reply, + self.can_read_messages, + self.can_delete_sent_messages, + self.can_delete_all_messages, + self.can_edit_name, + self.can_edit_bio, + self.can_edit_profile_photo, + self.can_edit_username, + self.can_change_gift_settings, + self.can_view_gifts_and_stars, + self.can_convert_gifts_to_stars, + self.can_transfer_and_upgrade_gifts, + self.can_transfer_stars, + self.can_manage_stories, + ) + + self._freeze() + + +class BusinessConnection(TelegramObject): + """ + Describes the connection of the bot with a business account. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`id`, :attr:`user`, :attr:`user_chat_id`, :attr:`date`, + :attr:`rights`, and :attr:`is_enabled` are equal. + + .. versionadded:: 21.1 + .. versionchanged:: 22.1 + Equality comparison now considers :attr:`rights` instead of ``can_reply``. + + .. versionremoved:: 22.3 + Removed argument and attribute ``can_reply`` deprecated by API 9.0. + + Args: + id (:obj:`str`): Unique identifier of the business connection. + user (:class:`telegram.User`): Business account user that created the business connection. + user_chat_id (:obj:`int`): Identifier of a private chat with the user who created the + business connection. + date (:obj:`datetime.datetime`): Date the connection was established in Unix time. + is_enabled (:obj:`bool`): True, if the connection is active. + rights (:class:`BusinessBotRights`, optional): Rights of the business bot. + + .. versionadded:: 22.1 + + Attributes: + id (:obj:`str`): Unique identifier of the business connection. + user (:class:`telegram.User`): Business account user that created the business connection. + user_chat_id (:obj:`int`): Identifier of a private chat with the user who created the + business connection. + date (:obj:`datetime.datetime`): Date the connection was established in Unix time. + is_enabled (:obj:`bool`): True, if the connection is active. + rights (:class:`BusinessBotRights`): Optional. Rights of the business bot. + + .. versionadded:: 22.1 + """ + + __slots__ = ( + "date", + "id", + "is_enabled", + "rights", + "user", + "user_chat_id", + ) + + def __init__( + self, + id: str, + user: "User", + user_chat_id: int, + date: dtm.datetime, + is_enabled: bool, + rights: BusinessBotRights | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.id: str = id + self.user: User = user + self.user_chat_id: int = user_chat_id + self.date: dtm.datetime = date + self.is_enabled: bool = is_enabled + self.rights: BusinessBotRights | None = rights + + self._id_attrs = ( + self.id, + self.user, + self.user_chat_id, + self.date, + self.rights, + self.is_enabled, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BusinessConnection": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # 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"), tzinfo=loc_tzinfo) + data["user"] = de_json_optional(data.get("user"), User, bot) + data["rights"] = de_json_optional(data.get("rights"), BusinessBotRights, bot) + + return super().de_json(data=data, bot=bot) + + +class BusinessMessagesDeleted(TelegramObject): + """ + This object is received when messages are deleted from a connected business account. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`business_connection_id`, :attr:`message_ids`, and + :attr:`chat` are equal. + + .. versionadded:: 21.1 + + Args: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + chat (:class:`telegram.Chat`): Information about a chat in the business account. The bot + may not have access to the chat or the corresponding user. + message_ids (Sequence[:obj:`int`]): A list of identifiers of the deleted messages in the + chat of the business account. + + Attributes: + business_connection_id (:obj:`str`): Unique identifier of the business connection. + chat (:class:`telegram.Chat`): Information about a chat in the business account. The bot + may not have access to the chat or the corresponding user. + message_ids (tuple[:obj:`int`]): A list of identifiers of the deleted messages in the + chat of the business account. + """ + + __slots__ = ( + "business_connection_id", + "chat", + "message_ids", + ) + + def __init__( + self, + business_connection_id: str, + chat: Chat, + message_ids: Sequence[int], + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.business_connection_id: str = business_connection_id + self.chat: Chat = chat + self.message_ids: tuple[int, ...] = parse_sequence_arg(message_ids) + + self._id_attrs = ( + self.business_connection_id, + self.chat, + self.message_ids, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BusinessMessagesDeleted": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + + return super().de_json(data=data, bot=bot) + + +class BusinessIntro(TelegramObject): + """ + This object contains information about the start page settings of a Telegram Business account. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their + :attr:`title`, :attr:`message` and :attr:`sticker` are equal. + + .. versionadded:: 21.1 + + Args: + title (:obj:`str`, optional): Title text of the business intro. + message (:obj:`str`, optional): Message text of the business intro. + sticker (:class:`telegram.Sticker`, optional): Sticker of the business intro. + + Attributes: + title (:obj:`str`): Optional. Title text of the business intro. + message (:obj:`str`): Optional. Message text of the business intro. + sticker (:class:`telegram.Sticker`): Optional. Sticker of the business intro. + """ + + __slots__ = ( + "message", + "sticker", + "title", + ) + + def __init__( + self, + title: str | None = None, + message: str | None = None, + sticker: Sticker | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.title: str | None = title + self.message: str | None = message + self.sticker: Sticker | None = sticker + + self._id_attrs = (self.title, self.message, self.sticker) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BusinessIntro": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + + return super().de_json(data=data, bot=bot) + + +class BusinessLocation(TelegramObject): + """ + This object contains information about the location of a Telegram Business account. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their + :attr:`address` is equal. + + .. versionadded:: 21.1 + + Args: + address (:obj:`str`): Address of the business. + location (:class:`telegram.Location`, optional): Location of the business. + + Attributes: + address (:obj:`str`): Address of the business. + location (:class:`telegram.Location`): Optional. Location of the business. + """ + + __slots__ = ( + "address", + "location", + ) + + def __init__( + self, + address: str, + location: "Location | None" = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.address: str = address + self.location: Location | None = location + + self._id_attrs = (self.address,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BusinessLocation": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["location"] = de_json_optional(data.get("location"), Location, bot) + + return super().de_json(data=data, bot=bot) + + +class BusinessOpeningHoursInterval(TelegramObject): + """ + This object describes an interval of time during which a business is open. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their + :attr:`opening_minute` and :attr:`closing_minute` are equal. + + .. versionadded:: 21.1 + + Examples: + A day has (24 * 60 =) 1440 minutes, a week has (7 * 1440 =) 10080 minutes. + Starting the minute's sequence from Monday, example values of + :attr:`opening_minute`, :attr:`closing_minute` will map to the following day times: + + * Monday - 8am to 8:30pm: + - ``opening_minute = 480`` :guilabel:`8 * 60` + - ``closing_minute = 1230`` :guilabel:`20 * 60 + 30` + * Tuesday - 24 hours: + - ``opening_minute = 1440`` :guilabel:`24 * 60` + - ``closing_minute = 2879`` :guilabel:`2 * 24 * 60 - 1` + * Sunday - 12am - 11:58pm: + - ``opening_minute = 8640`` :guilabel:`6 * 24 * 60` + - ``closing_minute = 10078`` :guilabel:`7 * 24 * 60 - 2` + + Args: + opening_minute (:obj:`int`): The minute's sequence number in a week, starting on Monday, + marking the start of the time interval during which the business is open; + 0 - 7 * 24 * 60. + closing_minute (:obj:`int`): The minute's + sequence number in a week, starting on Monday, marking the end of the time interval + during which the business is open; 0 - 8 * 24 * 60 + + Attributes: + opening_minute (:obj:`int`): The minute's sequence number in a week, starting on Monday, + marking the start of the time interval during which the business is open; + 0 - 7 * 24 * 60. + closing_minute (:obj:`int`): The minute's + sequence number in a week, starting on Monday, marking the end of the time interval + during which the business is open; 0 - 8 * 24 * 60 + """ + + __slots__ = ("_closing_time", "_opening_time", "closing_minute", "opening_minute") + + def __init__( + self, + opening_minute: int, + closing_minute: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.opening_minute: int = opening_minute + self.closing_minute: int = closing_minute + + self._opening_time: tuple[int, int, int] | None = None + self._closing_time: tuple[int, int, int] | None = None + + self._id_attrs = (self.opening_minute, self.closing_minute) + + self._freeze() + + def _parse_minute(self, minute: int) -> tuple[int, int, int]: + return (minute // 1440, minute % 1440 // 60, minute % 1440 % 60) + + @property + def opening_time(self) -> tuple[int, int, int]: + """Convenience attribute. A :obj:`tuple` parsed from :attr:`opening_minute`. It contains + the `weekday`, `hour` and `minute` in the same ranges as :attr:`datetime.datetime.weekday`, + :attr:`datetime.datetime.hour` and :attr:`datetime.datetime.minute` + + Returns: + tuple[:obj:`int`, :obj:`int`, :obj:`int`]: + """ + if self._opening_time is None: + self._opening_time = self._parse_minute(self.opening_minute) + return self._opening_time + + @property + def closing_time(self) -> tuple[int, int, int]: + """Convenience attribute. A :obj:`tuple` parsed from :attr:`closing_minute`. It contains + the `weekday`, `hour` and `minute` in the same ranges as :attr:`datetime.datetime.weekday`, + :attr:`datetime.datetime.hour` and :attr:`datetime.datetime.minute` + + Returns: + tuple[:obj:`int`, :obj:`int`, :obj:`int`]: + """ + if self._closing_time is None: + self._closing_time = self._parse_minute(self.closing_minute) + return self._closing_time + + +class BusinessOpeningHours(TelegramObject): + """ + This object describes the opening hours of a business. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their + :attr:`time_zone_name` and :attr:`opening_hours` are equal. + + .. versionadded:: 21.1 + + Args: + time_zone_name (:obj:`str`): Unique name of the time zone for which the opening + hours are defined. + opening_hours (Sequence[:class:`telegram.BusinessOpeningHoursInterval`]): List of + time intervals describing business opening hours. + + Attributes: + time_zone_name (:obj:`str`): Unique name of the time zone for which the opening + hours are defined. + opening_hours (Sequence[:class:`telegram.BusinessOpeningHoursInterval`]): List of + time intervals describing business opening hours. + """ + + __slots__ = ("_cached_zone_info", "opening_hours", "time_zone_name") + + def __init__( + self, + time_zone_name: str, + opening_hours: Sequence[BusinessOpeningHoursInterval], + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.time_zone_name: str = time_zone_name + self.opening_hours: Sequence[BusinessOpeningHoursInterval] = parse_sequence_arg( + opening_hours + ) + + self._cached_zone_info: ZoneInfo | None = None + + self._id_attrs = (self.time_zone_name, self.opening_hours) + + self._freeze() + + @property + def _zone_info(self) -> ZoneInfo: + if self._cached_zone_info is None: + self._cached_zone_info = get_zone_info(self.time_zone_name) + return self._cached_zone_info + + def get_opening_hours_for_day( + self, date: dtm.date, time_zone: dtm.tzinfo | str | None = None + ) -> tuple[tuple[dtm.datetime, dtm.datetime], ...]: + """Returns the opening hours intervals for a specific day as datetime objects. + + .. versionadded:: 22.5 + + Args: + date (:obj:`datetime.date`): The date to get opening hours for. + time_zone (:obj:`datetime.tzinfo` | :obj:`str`, optional): Timezone to use for the + returned datetime objects. If not specified, then :attr:`time_zone_name` be used. + + Returns: + tuple[tuple[:obj:`datetime.datetime`, :obj:`datetime.datetime`], ...]: + A tuple of datetime pairs representing opening and closing times for the specified day. + Each pair consists of ``(opening_time, closing_time)``. + Returns an empty tuple if there are no opening hours for the given day. + """ + + week_day = date.weekday() + res = [] + if isinstance(time_zone, str): + tz_target: dtm.tzinfo = get_zone_info(time_zone) + elif time_zone is None: + tz_target = self._zone_info + else: + tz_target = time_zone + + for interval in self.opening_hours: + int_open = interval.opening_time + int_close = interval.closing_time + + if int_open[0] != week_day: + continue + + # To get the correct localization, we first need to create the dtm object in + # self.time_zone_name, then convert it to the target timezone. We could check if + # self._zone_info == tz_target and skip the conversion, but it's not worth the added + # complexity. + result_int_open = dtm.datetime( + year=date.year, + month=date.month, + day=date.day, + hour=int_open[1], + minute=int_open[2], + tzinfo=self._zone_info, + ).astimezone(tz_target) + + result_int_close = dtm.datetime( + year=date.year, + month=date.month, + day=date.day, + hour=int_close[1], + minute=int_close[2], + tzinfo=self._zone_info, + ).astimezone(tz_target) + + res.append((result_int_open, result_int_close)) + + # The sorting is currently an implementation detail + return tuple(sorted(res, key=lambda x: x[0])) + + def is_open(self, datetime: dtm.datetime) -> bool: + """Check if the business is open at the specified datetime. + + .. versionadded:: 22.5 + + Args: + datetime (:obj:`datetime.datetime`): The datetime to check. + If the object is timezone-naive, it is assumed to be in the + timezone specified by :attr:`time_zone_name`. + + Returns: + :obj:`bool`: True if the business is open at the specified time, False otherwise. + """ + + datetime_in_native_tz = ( + datetime.replace(tzinfo=self._zone_info) if datetime.tzinfo is None else datetime + ).astimezone(self._zone_info) + minute_of_week = ( + datetime_in_native_tz.weekday() * 1440 + + datetime_in_native_tz.hour * 60 + + datetime_in_native_tz.minute + ) + + for interval in self.opening_hours: + if interval.opening_minute <= minute_of_week < interval.closing_minute: + return True + + return False + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BusinessOpeningHours": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["opening_hours"] = de_list_optional( + data.get("opening_hours"), BusinessOpeningHoursInterval, bot + ) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_callbackquery.py b/src/telegram/_callbackquery.py similarity index 69% rename from telegram/_callbackquery.py rename to src/telegram/_callbackquery.py index 61577df9090..9004de89388 100644 --- a/telegram/_callbackquery.py +++ b/src/telegram/_callbackquery.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,15 +18,18 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. # pylint: disable=redefined-builtin """This module contains an object that represents a Telegram CallbackQuery""" -from typing import TYPE_CHECKING, ClassVar, Optional, Sequence, Tuple, Union + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Final from telegram import constants -from telegram._files.location import Location -from telegram._message import Message +from telegram._inputchecklist import InputChecklist +from telegram._message import MaybeInaccessibleMessage, Message from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import DVInput, JSONDict, ODVInput, ReplyMarkup +from telegram._utils.types import JSONDict, ODVInput, TimePeriod if TYPE_CHECKING: from telegram import ( @@ -34,9 +37,14 @@ GameHighScore, InlineKeyboardMarkup, InputMedia, + LinkPreviewOptions, MessageEntity, MessageId, + ReplyParameters, + SuggestedPostParameters, ) + from telegram._files.location import Location + from telegram._utils.types import ReplyMarkup class CallbackQuery(TelegramObject): @@ -70,9 +78,11 @@ class CallbackQuery(TelegramObject): from_user (:class:`telegram.User`): Sender. chat_instance (:obj:`str`): Global identifier, uniquely corresponding to the chat to which the message with the callback button was sent. Useful for high scores in games. - message (:class:`telegram.Message`, optional): Message with the callback button that - originated the query. Note that message content and message date will not be available - if the message is too old. + message (:class:`telegram.MaybeInaccessibleMessage`, optional): Message sent by the bot + with the callback button that originated the query. + + .. versionchanged:: 20.8 + Accept objects of type :class:`telegram.MaybeInaccessibleMessage` since Bot API 7.0. data (:obj:`str`, optional): Data associated with the callback button. Be aware that the message, which originated the query, can contain no callback buttons with this data. inline_message_id (:obj:`str`, optional): Identifier of the message sent via the bot in @@ -85,9 +95,12 @@ class CallbackQuery(TelegramObject): from_user (:class:`telegram.User`): Sender. chat_instance (:obj:`str`): Global identifier, uniquely corresponding to the chat to which the message with the callback button was sent. Useful for high scores in games. - message (:class:`telegram.Message`): Optional. Message with the callback button that - originated the query. Note that message content and message date will not be available - if the message is too old. + message (:class:`telegram.MaybeInaccessibleMessage`): Optional. Message sent by the bot + with the callback button that originated the query. + + .. versionchanged:: 20.8 + 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 with this data. @@ -104,13 +117,13 @@ class CallbackQuery(TelegramObject): """ __slots__ = ( - "game_short_name", - "message", "chat_instance", - "id", + "data", "from_user", + "game_short_name", + "id", "inline_message_id", - "data", + "message", ) def __init__( @@ -118,53 +131,50 @@ def __init__( id: str, from_user: User, chat_instance: str, - message: Optional[Message] = None, - data: Optional[str] = None, - inline_message_id: Optional[str] = None, - game_short_name: Optional[str] = None, + message: MaybeInaccessibleMessage | None = None, + data: str | None = None, + inline_message_id: str | None = None, + game_short_name: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required - self.id: str = id # pylint: disable=invalid-name + self.id: str = id self.from_user: User = from_user self.chat_instance: str = chat_instance # Optionals - self.message: Optional[Message] = message - self.data: Optional[str] = data - self.inline_message_id: Optional[str] = inline_message_id - self.game_short_name: Optional[str] = game_short_name + self.message: MaybeInaccessibleMessage | None = message + self.data: str | None = data + self.inline_message_id: str | None = inline_message_id + self.game_short_name: str | None = game_short_name self._id_attrs = (self.id,) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["CallbackQuery"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "CallbackQuery": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["from_user"] = User.de_json(data.pop("from", None), bot) - data["message"] = Message.de_json(data.get("message"), bot) + data["from_user"] = de_json_optional(data.pop("from", None), User, bot) + data["message"] = de_json_optional(data.get("message"), Message, bot) return super().de_json(data=data, bot=bot) async def answer( self, - text: Optional[str] = None, - show_alert: Optional[bool] = None, - url: Optional[str] = None, - cache_time: Optional[int] = None, + text: str | None = None, + show_alert: bool | None = None, + url: str | None = None, + cache_time: TimePeriod | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -190,20 +200,29 @@ async def answer( api_kwargs=api_kwargs, ) + def _get_message(self, action: str = "edit") -> Message: + """Helper method to get the message for the shortcut methods. Must be called only + if :attr:`inline_message_id` is *not* set. + """ + if not isinstance(self.message, Message): + raise TypeError(f"Cannot {action} an inaccessible message") + return self.message + async def edit_message_text( self, text: str, parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, *, + disable_web_page_preview: bool | None = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for either:: await update.callback_query.message.edit_text(*args, **kwargs) @@ -217,10 +236,16 @@ async def edit_message_text( For the documentation of the arguments, please see :meth:`telegram.Bot.edit_message_text` and :meth:`telegram.Message.edit_text`. + .. versionchanged:: 20.8 + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ if self.inline_message_id: return await self.get_bot().edit_message_text( @@ -228,6 +253,7 @@ async def edit_message_text( text=text, parse_mode=parse_mode, disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -237,11 +263,14 @@ 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.message.edit_text( + return await self._get_message().edit_text( text=text, parse_mode=parse_mode, disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -253,17 +282,18 @@ async def edit_message_text( async def edit_message_caption( self, - caption: Optional[str] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + caption: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for either:: await update.callback_query.message.edit_caption(*args, **kwargs) @@ -277,10 +307,16 @@ async def edit_message_caption( For the documentation of the arguments, please see :meth:`telegram.Bot.edit_message_caption` and :meth:`telegram.Message.edit_caption`. + .. versionchanged:: 20.8 + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ if self.inline_message_id: return await self.get_bot().edit_message_caption( @@ -296,8 +332,11 @@ async def edit_message_caption( caption_entities=caption_entities, 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.message.edit_caption( + return await self._get_message().edit_caption( caption=caption, reply_markup=reply_markup, read_timeout=read_timeout, @@ -307,18 +346,56 @@ async def edit_message_caption( parse_mode=parse_mode, api_kwargs=api_kwargs, caption_entities=caption_entities, + show_caption_above_media=show_caption_above_media, + ) + + async def edit_message_checklist( + self, + checklist: InputChecklist, + reply_markup: "InlineKeyboardMarkup | None" = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": + """Shortcut for:: + + await update.callback_query.message.edit_checklist(*args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Message.edit_checklist`. + + .. versionadded:: 22.3 + + Returns: + :class:`telegram.Message`: On success, the edited Message is returned. + + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + + """ + return await self._get_message().edit_checklist( + checklist=checklist, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, ) async def edit_message_reply_markup( self, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + reply_markup: "InlineKeyboardMarkup | None" = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for either:: await update.callback_query.message.edit_reply_markup(*args, **kwargs) @@ -333,10 +410,16 @@ async def edit_message_reply_markup( :meth:`telegram.Bot.edit_message_reply_markup` and :meth:`telegram.Message.edit_reply_markup`. + .. versionchanged:: 20.8 + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ if self.inline_message_id: return await self.get_bot().edit_message_reply_markup( @@ -349,8 +432,10 @@ 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.message.edit_reply_markup( + return await self._get_message().edit_reply_markup( reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -362,14 +447,14 @@ async def edit_message_reply_markup( async def edit_message_media( self, media: "InputMedia", - reply_markup: Optional["InlineKeyboardMarkup"] = None, + reply_markup: "InlineKeyboardMarkup | None" = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for either:: await update.callback_query.message.edit_media(*args, **kwargs) @@ -383,10 +468,16 @@ async def edit_message_media( For the documentation of the arguments, please see :meth:`telegram.Bot.edit_message_media` and :meth:`telegram.Message.edit_media`. + .. versionchanged:: 20.8 + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :class:`telegram.Message`: On success, if edited message is not an inline message, the edited Message is returned, otherwise :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ if self.inline_message_id: return await self.get_bot().edit_message_media( @@ -400,8 +491,10 @@ 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.message.edit_media( + return await self._get_message().edit_media( media=media, reply_markup=reply_markup, read_timeout=read_timeout, @@ -413,20 +506,21 @@ async def edit_message_media( async def edit_message_live_location( self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, + latitude: float | None = None, + longitude: float | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, + live_period: TimePeriod | None = None, *, - location: Optional[Location] = None, + location: "Location | None" = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for either:: await update.callback_query.message.edit_live_location(*args, **kwargs) @@ -441,10 +535,16 @@ async def edit_message_live_location( :meth:`telegram.Bot.edit_message_live_location` and :meth:`telegram.Message.edit_live_location`. + .. versionchanged:: 20.8 + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ if self.inline_message_id: return await self.get_bot().edit_message_live_location( @@ -461,10 +561,13 @@ async def edit_message_live_location( horizontal_accuracy=horizontal_accuracy, heading=heading, proximity_alert_radius=proximity_alert_radius, + 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.message.edit_live_location( + return await self._get_message().edit_live_location( latitude=latitude, longitude=longitude, location=location, @@ -477,18 +580,19 @@ async def edit_message_live_location( horizontal_accuracy=horizontal_accuracy, heading=heading, proximity_alert_radius=proximity_alert_radius, + live_period=live_period, ) async def stop_message_live_location( self, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + reply_markup: "InlineKeyboardMarkup | None" = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for either:: await update.callback_query.message.stop_live_location(*args, **kwargs) @@ -503,10 +607,16 @@ async def stop_message_live_location( :meth:`telegram.Bot.stop_message_live_location` and :meth:`telegram.Message.stop_live_location`. + .. versionchanged:: 20.8 + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ if self.inline_message_id: return await self.get_bot().stop_message_live_location( @@ -519,8 +629,10 @@ 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.message.stop_live_location( + return await self._get_message().stop_live_location( reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -531,17 +643,17 @@ async def stop_message_live_location( async def set_game_score( self, - user_id: Union[int, str], + user_id: int, score: int, - force: Optional[bool] = None, - disable_edit_message: Optional[bool] = None, + force: bool | None = None, + disable_edit_message: bool | None = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": """Shortcut for either:: await update.callback_query.message.set_game_score(*args, **kwargs) @@ -555,10 +667,16 @@ async def set_game_score( For the documentation of the arguments, please see :meth:`telegram.Bot.set_game_score` and :meth:`telegram.Message.set_game_score`. + .. versionchanged:: 20.8 + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :class:`telegram.Message`: On success, if edited message is sent by the bot, the edited Message is returned, otherwise :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ if self.inline_message_id: return await self.get_bot().set_game_score( @@ -575,7 +693,7 @@ async def set_game_score( chat_id=None, message_id=None, ) - return await self.message.set_game_score( + return await self._get_message().set_game_score( user_id=user_id, score=score, force=force, @@ -589,14 +707,14 @@ async def set_game_score( async def get_game_high_scores( self, - user_id: Union[int, str], + user_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Tuple["GameHighScore", ...]: + api_kwargs: JSONDict | None = None, + ) -> tuple["GameHighScore", ...]: """Shortcut for either:: await update.callback_query.message.get_game_high_score(*args, **kwargs) @@ -611,8 +729,14 @@ async def get_game_high_scores( :meth:`telegram.Bot.get_game_high_scores` and :meth:`telegram.Message.get_game_high_scores`. + .. versionchanged:: 20.8 + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: - Tuple[:class:`telegram.GameHighScore`] + tuple[:class:`telegram.GameHighScore`] + + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. """ if self.inline_message_id: @@ -627,7 +751,7 @@ async def get_game_high_scores( chat_id=None, message_id=None, ) - return await self.message.get_game_high_scores( + return await self._get_message().get_game_high_scores( user_id=user_id, read_timeout=read_timeout, write_timeout=write_timeout, @@ -643,7 +767,7 @@ async def delete_message( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -651,11 +775,17 @@ async def delete_message( For the documentation of the arguments, please see :meth:`telegram.Message.delete`. + .. versionchanged:: 20.8 + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :obj:`bool`: On success, :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. + """ - return await self.message.delete( + return await self._get_message(action="delete").delete( read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -671,7 +801,7 @@ async def pin_message( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -679,11 +809,16 @@ async def pin_message( For the documentation of the arguments, please see :meth:`telegram.Message.pin`. + .. versionchanged:: 20.8 + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :obj:`bool`: On success, :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. """ - return await self.message.pin( + return await self._get_message(action="pin").pin( disable_notification=disable_notification, read_timeout=read_timeout, write_timeout=write_timeout, @@ -699,7 +834,7 @@ async def unpin_message( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -707,11 +842,16 @@ async def unpin_message( For the documentation of the arguments, please see :meth:`telegram.Message.unpin`. + .. versionchanged:: 20.8 + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :obj:`bool`: On success, :obj:`True` is returned. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. """ - return await self.message.unpin( + return await self._get_message(action="unpin").unpin( read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -721,42 +861,55 @@ async def unpin_message( async def copy_message( self, - chat_id: Union[int, str], - caption: Optional[str] = None, + chat_id: int | str, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - allow_sending_without_reply: DVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + caption_entities: Sequence["MessageEntity"] | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = 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, + api_kwargs: JSONDict | None = None, ) -> "MessageId": """Shortcut for:: await update.callback_query.message.copy( from_chat_id=update.message.chat_id, message_id=update.message.message_id, + direct_messages_topic_id=update.message.direct_messages_topic.topic_id, *args, **kwargs ) For the documentation of the arguments, please see :meth:`telegram.Message.copy`. + .. versionchanged:: 20.8 + Raises :exc:`TypeError` if :attr:`message` is not accessible. + Returns: :class:`telegram.MessageId`: On success, returns the MessageId of the sent message. + Raises: + :exc:`TypeError` if :attr:`message` is not accessible. """ - return await self.message.copy( + return await self._get_message(action="copy").copy( chat_id=chat_id, caption=caption, parse_mode=parse_mode, + video_start_timestamp=video_start_timestamp, caption_entities=caption_entities, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, @@ -769,11 +922,16 @@ async def copy_message( api_kwargs=api_kwargs, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, + show_caption_above_media=show_caption_above_media, + allow_paid_broadcast=allow_paid_broadcast, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, ) - MAX_ANSWER_TEXT_LENGTH: ClassVar[ - int - ] = constants.CallbackQueryLimit.ANSWER_CALLBACK_QUERY_TEXT_LENGTH + MAX_ANSWER_TEXT_LENGTH: Final[int] = ( + constants.CallbackQueryLimit.ANSWER_CALLBACK_QUERY_TEXT_LENGTH + ) """ :const:`telegram.constants.CallbackQueryLimit.ANSWER_CALLBACK_QUERY_TEXT_LENGTH` diff --git a/telegram/_chat.py b/src/telegram/_chat.py similarity index 56% rename from telegram/_chat.py rename to src/telegram/_chat.py index 9dd6618d21d..927da4d8cf2 100644 --- a/telegram/_chat.py +++ b/src/telegram/_chat.py @@ -2,7 +2,7 @@ # pylint: disable=redefined-builtin # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,21 +18,28 @@ # 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 Chat.""" -from datetime import datetime + +import datetime as dtm +from collections.abc import Sequence from html import escape -from typing import TYPE_CHECKING, ClassVar, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Final from telegram import constants -from telegram._chatlocation import ChatLocation from telegram._chatpermissions import ChatPermissions -from telegram._files.chatphoto import ChatPhoto from telegram._forumtopic import ForumTopic from telegram._menubutton import MenuButton +from telegram._reaction import ReactionType from telegram._telegramobject import TelegramObject from telegram._utils import enum -from telegram._utils.argumentparsing import parse_sequence_arg from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import DVInput, FileInput, JSONDict, ODVInput, ReplyMarkup +from telegram._utils.types import ( + CorrectOptionID, + FileInput, + JSONDict, + ODVInput, + TimePeriod, +) +from telegram._utils.usernames import get_full_name, get_link from telegram.helpers import escape_markdown from telegram.helpers import mention_html as helpers_mention_html from telegram.helpers import mention_markdown as helpers_mention_markdown @@ -41,360 +48,105 @@ from telegram import ( Animation, Audio, - Bot, ChatInviteLink, ChatMember, Contact, Document, + Gift, InlineKeyboardMarkup, + InputChecklist, InputMediaAudio, InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPaidMedia, + InputPollOption, LabeledPrice, + LinkPreviewOptions, Location, Message, MessageEntity, MessageId, + OwnedGifts, PhotoSize, + ReplyParameters, Sticker, + Story, + SuggestedPostParameters, + UserChatBoosts, Venue, Video, VideoNote, Voice, ) + from telegram._utils.types import ReplyMarkup -class Chat(TelegramObject): - """This object represents a chat. - - Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`id` is equal. - - .. versionchanged:: 20.0 - - * Removed the deprecated methods ``kick_member`` and ``get_members_count``. - * The following are now keyword-only arguments in Bot methods: - ``location``, ``filename``, ``contact``, ``{read, write, connect, pool}_timeout``, - ``api_kwargs``. Use a named argument for those, - and notice that some positional arguments changed position as a result. - - .. versionchanged:: 20.0 - Removed the attribute ``all_members_are_administrators``. As long as Telegram provides - this field for backwards compatibility, it is available through - :attr:`~telegram.TelegramObject.api_kwargs`. - - Args: - id (:obj:`int`): Unique identifier for this chat. This number may be greater than 32 bits - and some programming languages may have difficulty/silent defects in interpreting it. - But it is smaller than 52 bits, so a signed 64-bit integer or double-precision float - type are safe for storing this identifier. - type (:obj:`str`): Type of chat, can be either :attr:`PRIVATE`, :attr:`GROUP`, - :attr:`SUPERGROUP` or :attr:`CHANNEL`. - title (:obj:`str`, optional): Title, for supergroups, channels and group chats. - username (:obj:`str`, optional): Username, for private chats, supergroups and channels if - available. - first_name (:obj:`str`, optional): First name of the other party in a private chat. - last_name (:obj:`str`, optional): Last name of the other party in a private chat. - photo (:class:`telegram.ChatPhoto`, optional): Chat photo. - Returned only in :meth:`telegram.Bot.get_chat`. - bio (:obj:`str`, optional): Bio of the other party in a private chat. Returned only in - :meth:`telegram.Bot.get_chat`. - has_private_forwards (:obj:`bool`, optional): :obj:`True`, if privacy settings of the other - party in the private chat allows to use ``tg://user?id=`` links only in chats - with the user. Returned only in :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 13.9 - description (:obj:`str`, optional): Description, for groups, supergroups and channel chats. - Returned only in :meth:`telegram.Bot.get_chat`. - invite_link (:obj:`str`, optional): Primary invite link, for groups, supergroups and - channel. Returned only in :meth:`telegram.Bot.get_chat`. - pinned_message (:class:`telegram.Message`, optional): The most recent pinned message - (by sending date). Returned only in :meth:`telegram.Bot.get_chat`. - permissions (:class:`telegram.ChatPermissions`): Optional. Default chat member permissions, - for groups and supergroups. Returned only in :meth:`telegram.Bot.get_chat`. - slow_mode_delay (:obj:`int`, optional): For supergroups, the minimum allowed delay between - consecutive messages sent by each unprivileged user. - Returned only in :meth:`telegram.Bot.get_chat`. - message_auto_delete_time (:obj:`int`, optional): The time after which all messages sent to - the chat will be automatically deleted; in seconds. Returned only in - :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 13.4 - has_protected_content (:obj:`bool`, optional): :obj:`True`, if messages from the chat can't - be forwarded to other chats. Returned only in :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 13.9 - - sticker_set_name (:obj:`str`, optional): For supergroups, name of group sticker set. - Returned only in :meth:`telegram.Bot.get_chat`. - can_set_sticker_set (:obj:`bool`, optional): :obj:`True`, if the bot can change group the - sticker set. Returned only in :meth:`telegram.Bot.get_chat`. - linked_chat_id (:obj:`int`, optional): Unique identifier for the linked chat, i.e. the - discussion group identifier for a channel and vice versa; for supergroups and channel - chats. Returned only in :meth:`telegram.Bot.get_chat`. - location (:class:`telegram.ChatLocation`, optional): For supergroups, the location to which - the supergroup is connected. Returned only in :meth:`telegram.Bot.get_chat`. - join_to_send_messages (:obj:`bool`, optional): :obj:`True`, if users need to join the - supergroup before they can send messages. Returned only in - :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 20.0 - join_by_request (:obj:`bool`, optional): :obj:`True`, if all users directly joining the - supergroup need to be approved by supergroup administrators. Returned only in - :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 20.0 - has_restricted_voice_and_video_messages (:obj:`bool`, optional): :obj:`True`, if the - privacy settings of the other party restrict sending voice and video note messages - in the private chat. Returned only in :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 20.0 - is_forum (:obj:`bool`, optional): :obj:`True`, if the supergroup chat is a forum - (has topics_ enabled). - - .. versionadded:: 20.0 - active_usernames (Sequence[:obj:`str`], optional): If set, the list of all `active chat - usernames `_; for private chats, supergroups and channels. Returned - only in :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 20.0 - emoji_status_custom_emoji_id (:obj:`str`, optional): Custom emoji identifier of emoji - status of the other party in a private chat. Returned only in - :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 20.0 - has_aggressive_anti_spam_enabled (:obj:`bool`, optional): :obj:`True`, if aggressive - anti-spam checks are enabled in the supergroup. The field is only available to chat - administrators. Returned only in :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 20.0 - has_hidden_members (:obj:`bool`, optional): :obj:`True`, if non-administrators can only - get the list of bots and administrators in the chat. Returned only in - :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 20.0 - - Attributes: - id (:obj:`int`): Unique identifier for this chat. This number may be greater than 32 bits - and some programming languages may have difficulty/silent defects in interpreting it. - But it is smaller than 52 bits, so a signed 64-bit integer or double-precision float - type are safe for storing this identifier. - type (:obj:`str`): Type of chat, can be either :attr:`PRIVATE`, :attr:`GROUP`, - :attr:`SUPERGROUP` or :attr:`CHANNEL`. - title (:obj:`str`): Optional. Title, for supergroups, channels and group chats. - username (:obj:`str`): Optional. Username, for private chats, supergroups and channels if - available. - first_name (:obj:`str`): Optional. First name of the other party in a private chat. - last_name (:obj:`str`): Optional. Last name of the other party in a private chat. - photo (:class:`telegram.ChatPhoto`): Optional. Chat photo. - Returned only in :meth:`telegram.Bot.get_chat`. - bio (:obj:`str`): Optional. Bio of the other party in a private chat. Returned only in - :meth:`telegram.Bot.get_chat`. - has_private_forwards (:obj:`bool`): Optional. :obj:`True`, if privacy settings of the other - party in the private chat allows to use ``tg://user?id=`` links only in chats - with the user. Returned only in :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 13.9 - description (:obj:`str`): Optional. Description, for groups, supergroups and channel chats. - Returned only in :meth:`telegram.Bot.get_chat`. - invite_link (:obj:`str`): Optional. Primary invite link, for groups, supergroups and - channel. Returned only in :meth:`telegram.Bot.get_chat`. - pinned_message (:class:`telegram.Message`): Optional. The most recent pinned message - (by sending date). Returned only in :meth:`telegram.Bot.get_chat`. - permissions (:class:`telegram.ChatPermissions`): Optional. Default chat member permissions, - for groups and supergroups. Returned only in :meth:`telegram.Bot.get_chat`. - slow_mode_delay (:obj:`int`): Optional. For supergroups, the minimum allowed delay between - consecutive messages sent by each unprivileged user. Returned only in - :meth:`telegram.Bot.get_chat`. - message_auto_delete_time (:obj:`int`): Optional. The time after which all messages sent to - the chat will be automatically deleted; in seconds. Returned only in - :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 13.4 - has_protected_content (:obj:`bool`): Optional. :obj:`True`, if messages from the chat can't - be forwarded to other chats. Returned only in :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 13.9 - sticker_set_name (:obj:`str`): Optional. For supergroups, name of Group sticker set. - Returned only in :meth:`telegram.Bot.get_chat`. - can_set_sticker_set (:obj:`bool`): Optional. :obj:`True`, if the bot can change group the - sticker set. Returned only in :meth:`telegram.Bot.get_chat`. - linked_chat_id (:obj:`int`): Optional. Unique identifier for the linked chat, i.e. the - discussion group identifier for a channel and vice versa; for supergroups and channel - chats. Returned only in :meth:`telegram.Bot.get_chat`. - location (:class:`telegram.ChatLocation`): Optional. For supergroups, the location to which - the supergroup is connected. Returned only in :meth:`telegram.Bot.get_chat`. - join_to_send_messages (:obj:`bool`): Optional. :obj:`True`, if users need to join - the supergroup before they can send messages. Returned only in - :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 20.0 - join_by_request (:obj:`bool`): Optional. :obj:`True`, if all users directly - joining the supergroup need to be approved by supergroup administrators. Returned only - in :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 20.0 - has_restricted_voice_and_video_messages (:obj:`bool`): Optional. :obj:`True`, if the - privacy settings of the other party restrict sending voice and video note messages - in the private chat. Returned only in :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 20.0 - is_forum (:obj:`bool`): Optional. :obj:`True`, if the supergroup chat is a forum - (has topics_ enabled). - - .. versionadded:: 20.0 - active_usernames (Tuple[:obj:`str`]): Optional. If set, the list of all `active chat - usernames `_; for private chats, supergroups and channels. Returned - only in :meth:`telegram.Bot.get_chat`. - This list is empty if the chat has no active usernames or this chat instance was not - obtained via :meth:`~telegram.Bot.get_chat`. - - .. versionadded:: 20.0 - emoji_status_custom_emoji_id (:obj:`str`): Optional. Custom emoji identifier of emoji - status of the other party in a private chat. Returned only in - :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 20.0 - has_aggressive_anti_spam_enabled (:obj:`bool`): Optional. :obj:`True`, if aggressive - anti-spam checks are enabled in the supergroup. The field is only available to chat - administrators. Returned only in :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 20.0 - has_hidden_members (:obj:`bool`): Optional. :obj:`True`, if non-administrators can only - get the list of bots and administrators in the chat. Returned only in - :meth:`telegram.Bot.get_chat`. - - .. versionadded:: 20.0 +class _ChatBase(TelegramObject): + """Base class for :class:`telegram.Chat` and :class:`telegram.ChatFullInfo`. - .. _topics: https://telegram.org/blog/topics-in-groups-collectible-usernames#topics-in-groups + .. versionadded:: 21.3 """ __slots__ = ( - "bio", + "first_name", "id", - "type", + "is_direct_messages", + "is_forum", "last_name", - "sticker_set_name", - "slow_mode_delay", - "location", - "first_name", - "permissions", - "invite_link", - "pinned_message", - "description", - "can_set_sticker_set", - "username", "title", - "photo", - "linked_chat_id", - "message_auto_delete_time", - "has_protected_content", - "has_private_forwards", - "join_to_send_messages", - "join_by_request", - "has_restricted_voice_and_video_messages", - "is_forum", - "active_usernames", - "emoji_status_custom_emoji_id", - "has_hidden_members", - "has_aggressive_anti_spam_enabled", + "type", + "username", ) - SENDER: ClassVar[str] = constants.ChatType.SENDER - """:const:`telegram.constants.ChatType.SENDER` - - .. versionadded:: 13.5 - """ - PRIVATE: ClassVar[str] = constants.ChatType.PRIVATE - """:const:`telegram.constants.ChatType.PRIVATE`""" - GROUP: ClassVar[str] = constants.ChatType.GROUP - """:const:`telegram.constants.ChatType.GROUP`""" - SUPERGROUP: ClassVar[str] = constants.ChatType.SUPERGROUP - """:const:`telegram.constants.ChatType.SUPERGROUP`""" - CHANNEL: ClassVar[str] = constants.ChatType.CHANNEL - """:const:`telegram.constants.ChatType.CHANNEL`""" - def __init__( self, id: int, type: str, - title: Optional[str] = None, - username: Optional[str] = None, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - photo: Optional[ChatPhoto] = None, - description: Optional[str] = None, - invite_link: Optional[str] = None, - pinned_message: Optional["Message"] = None, - permissions: Optional[ChatPermissions] = None, - sticker_set_name: Optional[str] = None, - can_set_sticker_set: Optional[bool] = None, - slow_mode_delay: Optional[int] = None, - bio: Optional[str] = None, - linked_chat_id: Optional[int] = None, - location: Optional[ChatLocation] = None, - message_auto_delete_time: Optional[int] = None, - has_private_forwards: Optional[bool] = None, - has_protected_content: Optional[bool] = None, - join_to_send_messages: Optional[bool] = None, - join_by_request: Optional[bool] = None, - has_restricted_voice_and_video_messages: Optional[bool] = None, - is_forum: Optional[bool] = None, - active_usernames: Optional[Sequence[str]] = None, - emoji_status_custom_emoji_id: Optional[str] = None, - has_aggressive_anti_spam_enabled: Optional[bool] = None, - has_hidden_members: Optional[bool] = None, - *, - api_kwargs: Optional[JSONDict] = None, + title: str | None = None, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + is_forum: bool | None = None, + is_direct_messages: bool | None = None, + *, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required - self.id: int = id # pylint: disable=invalid-name + self.id: int = id self.type: str = enum.get_member(constants.ChatType, type, type) # Optionals - self.title: Optional[str] = title - self.username: Optional[str] = username - self.first_name: Optional[str] = first_name - self.last_name: Optional[str] = last_name - self.photo: Optional[ChatPhoto] = photo - self.bio: Optional[str] = bio - self.has_private_forwards: Optional[bool] = has_private_forwards - self.description: Optional[str] = description - self.invite_link: Optional[str] = invite_link - self.pinned_message: Optional[Message] = pinned_message - self.permissions: Optional[ChatPermissions] = permissions - self.slow_mode_delay: Optional[int] = slow_mode_delay - self.message_auto_delete_time: Optional[int] = ( - int(message_auto_delete_time) if message_auto_delete_time is not None else None - ) - self.has_protected_content: Optional[bool] = has_protected_content - self.sticker_set_name: Optional[str] = sticker_set_name - self.can_set_sticker_set: Optional[bool] = can_set_sticker_set - self.linked_chat_id: Optional[int] = linked_chat_id - self.location: Optional[ChatLocation] = location - self.join_to_send_messages: Optional[bool] = join_to_send_messages - self.join_by_request: Optional[bool] = join_by_request - self.has_restricted_voice_and_video_messages: Optional[ - bool - ] = has_restricted_voice_and_video_messages - self.is_forum: Optional[bool] = is_forum - self.active_usernames: Tuple[str, ...] = parse_sequence_arg(active_usernames) - self.emoji_status_custom_emoji_id: Optional[str] = emoji_status_custom_emoji_id - self.has_aggressive_anti_spam_enabled: Optional[bool] = has_aggressive_anti_spam_enabled - self.has_hidden_members: Optional[bool] = has_hidden_members + self.title: str | None = title + self.username: str | None = username + self.first_name: str | None = first_name + self.last_name: str | None = last_name + self.is_forum: bool | None = is_forum + self.is_direct_messages: bool | None = is_direct_messages self._id_attrs = (self.id,) self._freeze() + SENDER: Final[str] = constants.ChatType.SENDER + """:const:`telegram.constants.ChatType.SENDER` + + .. versionadded:: 13.5 + """ + PRIVATE: Final[str] = constants.ChatType.PRIVATE + """:const:`telegram.constants.ChatType.PRIVATE`""" + GROUP: Final[str] = constants.ChatType.GROUP + """:const:`telegram.constants.ChatType.GROUP`""" + SUPERGROUP: Final[str] = constants.ChatType.SUPERGROUP + """:const:`telegram.constants.ChatType.SUPERGROUP`""" + CHANNEL: Final[str] = constants.ChatType.CHANNEL + """:const:`telegram.constants.ChatType.CHANNEL`""" + @property - def effective_name(self) -> Optional[str]: + def effective_name(self) -> str | None: """ - :obj:`str`: Convenience property. Gives :attr:`title` if not :obj:`None`, - else :attr:`full_name` if not :obj:`None`. + :obj:`str`: Convenience property. Gives :attr:`~Chat.title` if not :obj:`None`, + else :attr:`~Chat.full_name` if not :obj:`None`. .. versionadded:: 20.1 """ @@ -405,10 +157,10 @@ def effective_name(self) -> Optional[str]: return None @property - def full_name(self) -> Optional[str]: + def full_name(self) -> str | None: """ - :obj:`str`: Convenience property. If :attr:`first_name` is not :obj:`None`, gives - :attr:`first_name` followed by (if available) :attr:`last_name`. + :obj:`str`: Convenience property. If :attr:`~Chat.first_name` is not :obj:`None`, gives + :attr:`~Chat.first_name` followed by (if available) :attr:`~Chat.last_name`. Note: :attr:`full_name` will always be :obj:`None`, if the chat is a (super)group or @@ -416,47 +168,16 @@ def full_name(self) -> Optional[str]: .. versionadded:: 13.2 """ - if not self.first_name: - return None - if self.last_name: - return f"{self.first_name} {self.last_name}" - return self.first_name + return get_full_name(self) @property - def link(self) -> Optional[str]: - """:obj:`str`: Convenience property. If the chat has a :attr:`username`, returns a t.me - link of the chat. + def link(self) -> str | None: + """:obj:`str`: Convenience property. If the chat has a :attr:`~Chat.username`, returns a + t.me link of the chat. """ - if self.username: - return f"https://t.me/{self.username}" - return None - - @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Chat"]: - """See :meth:`telegram.TelegramObject.de_json`.""" - data = cls._parse_data(data) - - if not data: - return None + return get_link(self) - data["photo"] = ChatPhoto.de_json(data.get("photo"), bot) - from telegram import Message # pylint: disable=import-outside-toplevel - - data["pinned_message"] = Message.de_json(data.get("pinned_message"), bot) - data["permissions"] = ChatPermissions.de_json(data.get("permissions"), bot) - data["location"] = ChatLocation.de_json(data.get("location"), bot) - - api_kwargs = {} - # This is a deprecated field that TG still returns for backwards compatibility - # Let's filter it out to speed up the de-json process - if "all_members_are_administrators" in data: - api_kwargs["all_members_are_administrators"] = data.pop( - "all_members_are_administrators" - ) - - return super()._de_json(data=data, bot=bot, api_kwargs=api_kwargs) - - def mention_markdown(self, name: Optional[str] = None) -> str: + def mention_markdown(self, name: str | None = None) -> str: """ Note: :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by @@ -466,17 +187,18 @@ def mention_markdown(self, name: Optional[str] = None) -> str: .. versionadded:: 20.0 Args: - name (:obj:`str`): The name used as a link for the chat. Defaults to :attr:`full_name`. + name (:obj:`str`): The name used as a link for the chat. Defaults to + :attr:`~Chat.full_name`. Returns: :obj:`str`: The inline mention for the chat as markdown (version 1). Raises: :exc:`TypeError`: If the chat is a private chat and neither the :paramref:`name` - nor the :attr:`first_name` is set, then throw an :exc:`TypeError`. - If the chat is a public chat and neither the :paramref:`name` nor the :attr:`title` - is set, then throw an :exc:`TypeError`. If chat is a private group chat, then - throw an :exc:`TypeError`. + nor the :attr:`~Chat.first_name` is set, then throw an :exc:`TypeError`. + If the chat is a public chat and neither the :paramref:`name` nor the + :attr:`~Chat.title` is set, then throw an :exc:`TypeError`. If chat is a + private group chat, then throw an :exc:`TypeError`. """ if self.type == self.PRIVATE: @@ -493,22 +215,23 @@ def mention_markdown(self, name: Optional[str] = None) -> str: raise TypeError("Can not create a mention to a public chat without title") raise TypeError("Can not create a mention to a private group chat") - def mention_markdown_v2(self, name: Optional[str] = None) -> str: + def mention_markdown_v2(self, name: str | None = None) -> str: """ .. versionadded:: 20.0 Args: - name (:obj:`str`): The name used as a link for the chat. Defaults to :attr:`full_name`. + name (:obj:`str`): The name used as a link for the chat. Defaults to + :attr:`~Chat.full_name`. Returns: :obj:`str`: The inline mention for the chat as markdown (version 2). Raises: :exc:`TypeError`: If the chat is a private chat and neither the :paramref:`name` - nor the :attr:`first_name` is set, then throw an :exc:`TypeError`. - If the chat is a public chat and neither the :paramref:`name` nor the :attr:`title` - is set, then throw an :exc:`TypeError`. If chat is a private group chat, then - throw an :exc:`TypeError`. + nor the :attr:`~Chat.first_name` is set, then throw an :exc:`TypeError`. + If the chat is a public chat and neither the :paramref:`name` nor the + :attr:`~Chat.title` is set, then throw an :exc:`TypeError`. If chat is a + private group chat, then throw an :exc:`TypeError`. """ if self.type == self.PRIVATE: @@ -525,7 +248,7 @@ def mention_markdown_v2(self, name: Optional[str] = None) -> str: raise TypeError("Can not create a mention to a public chat without title") raise TypeError("Can not create a mention to a private group chat") - def mention_html(self, name: Optional[str] = None) -> str: + def mention_html(self, name: str | None = None) -> str: """ .. versionadded:: 20.0 @@ -537,10 +260,10 @@ def mention_html(self, name: Optional[str] = None) -> str: Raises: :exc:`TypeError`: If the chat is a private chat and neither the :paramref:`name` - nor the :attr:`first_name` is set, then throw an :exc:`TypeError`. - If the chat is a public chat and neither the :paramref:`name` nor the :attr:`title` - is set, then throw an :exc:`TypeError`. If chat is a private group chat, then - throw an :exc:`TypeError`. + nor the :attr:`~Chat.first_name` is set, then throw an :exc:`TypeError`. + If the chat is a public chat and neither the :paramref:`name` nor the + :attr:`~Chat.title` is set, then throw an :exc:`TypeError`. + If chat is a private group chat, then throw an :exc:`TypeError`. """ if self.type == self.PRIVATE: @@ -564,7 +287,7 @@ async def leave( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -592,8 +315,8 @@ async def get_administrators( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Tuple["ChatMember", ...]: + api_kwargs: JSONDict | None = None, + ) -> tuple["ChatMember", ...]: """Shortcut for:: await bot.get_chat_administrators(update.effective_chat.id, *args, **kwargs) @@ -602,7 +325,7 @@ async def get_administrators( :meth:`telegram.Bot.get_chat_administrators`. Returns: - Tuple[:class:`telegram.ChatMember`]: A tuple of administrators in a chat. An Array of + tuple[:class:`telegram.ChatMember`]: A tuple of administrators in a chat. An Array of :class:`telegram.ChatMember` objects that contains information about all chat administrators except other bots. If the chat is a group or a supergroup and no administrators were appointed, only the creator will be returned. @@ -624,7 +347,7 @@ async def get_member_count( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> int: """Shortcut for:: @@ -647,13 +370,13 @@ async def get_member_count( async def get_member( self, - user_id: Union[str, int], + user_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "ChatMember": """Shortcut for:: @@ -677,15 +400,15 @@ async def get_member( async def ban_member( self, - user_id: Union[str, int], - revoke_messages: Optional[bool] = None, - until_date: Optional[Union[int, datetime]] = None, + user_id: int, + revoke_messages: bool | None = None, + until_date: int | dtm.datetime | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -717,7 +440,7 @@ async def ban_sender_chat( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -744,13 +467,13 @@ async def ban_sender_chat( async def ban_chat( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -785,7 +508,7 @@ async def unban_sender_chat( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -812,13 +535,13 @@ async def unban_sender_chat( async def unban_chat( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -847,14 +570,14 @@ async def unban_chat( async def unban_member( self, - user_id: Union[str, int], - only_if_banned: Optional[bool] = None, + user_id: int, + only_if_banned: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -879,25 +602,29 @@ async def unban_member( async def promote_member( self, - user_id: Union[str, int], - can_change_info: Optional[bool] = None, - can_post_messages: Optional[bool] = None, - can_edit_messages: Optional[bool] = None, - can_delete_messages: Optional[bool] = None, - can_invite_users: Optional[bool] = None, - can_restrict_members: Optional[bool] = None, - can_pin_messages: Optional[bool] = None, - can_promote_members: Optional[bool] = None, - is_anonymous: Optional[bool] = None, - can_manage_chat: Optional[bool] = None, - can_manage_video_chats: Optional[bool] = None, - can_manage_topics: Optional[bool] = None, + user_id: int, + can_change_info: bool | None = None, + can_post_messages: bool | None = None, + can_edit_messages: bool | None = None, + can_delete_messages: bool | None = None, + can_invite_users: bool | None = None, + can_restrict_members: bool | None = None, + can_pin_messages: bool | None = None, + can_promote_members: bool | None = None, + is_anonymous: bool | None = None, + can_manage_chat: bool | None = None, + can_manage_video_chats: bool | None = None, + can_manage_topics: bool | None = None, + can_post_stories: bool | None = None, + can_edit_stories: bool | None = None, + can_delete_stories: bool | None = None, + can_manage_direct_messages: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -911,6 +638,9 @@ async def promote_member( The argument ``can_manage_voice_chats`` was renamed to :paramref:`~telegram.Bot.promote_chat_member.can_manage_video_chats` in accordance to Bot API 6.0. + .. versionchanged:: 20.6 + The arguments `can_post_stories`, `can_edit_stories` and `can_delete_stories` were + added. Returns: :obj:`bool`: On success, :obj:`True` is returned. @@ -936,20 +666,24 @@ async def promote_member( can_manage_chat=can_manage_chat, can_manage_video_chats=can_manage_video_chats, can_manage_topics=can_manage_topics, + can_post_stories=can_post_stories, + can_edit_stories=can_edit_stories, + can_delete_stories=can_delete_stories, + can_manage_direct_messages=can_manage_direct_messages, ) async def restrict_member( self, - user_id: Union[str, int], + user_id: int, permissions: ChatPermissions, - until_date: Optional[Union[int, datetime]] = None, - use_independent_chat_permissions: Optional[bool] = None, + until_date: int | dtm.datetime | None = None, + use_independent_chat_permissions: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -983,13 +717,13 @@ async def restrict_member( async def set_permissions( self, permissions: ChatPermissions, - use_independent_chat_permissions: Optional[bool] = None, + use_independent_chat_permissions: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -1018,14 +752,14 @@ async def set_permissions( async def set_administrator_custom_title( self, - user_id: Union[int, str], + user_id: int, custom_title: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -1056,10 +790,10 @@ async def set_photo( photo: FileInput, *, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -1093,7 +827,7 @@ async def delete_photo( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -1127,7 +861,7 @@ async def set_title( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -1156,13 +890,13 @@ async def set_title( async def set_description( self, - description: Optional[str] = None, + description: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -1193,12 +927,13 @@ async def pin_message( self, message_id: int, disable_notification: ODVInput[bool] = DEFAULT_NONE, + business_connection_id: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -1220,17 +955,19 @@ async def pin_message( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, ) async def unpin_message( self, - message_id: Optional[int] = None, + message_id: int | None = None, + business_connection_id: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -1251,6 +988,7 @@ async def unpin_message( pool_timeout=pool_timeout, api_kwargs=api_kwargs, message_id=message_id, + business_connection_id=business_connection_id, ) async def unpin_all_messages( @@ -1260,7 +998,7 @@ async def unpin_all_messages( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -1286,20 +1024,27 @@ async def send_message( self, text: str, parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - entities: Optional[Sequence["MessageEntity"]] = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + disable_web_page_preview: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1318,6 +1063,8 @@ async def send_message( disable_web_page_preview=disable_web_page_preview, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + link_preview_options=link_preview_options, + reply_parameters=reply_parameters, reply_markup=reply_markup, allow_sending_without_reply=allow_sending_without_reply, entities=entities, @@ -1328,28 +1075,140 @@ async def send_message( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_message_draft( + self, + draft_id: int, + text: str, + message_thread_id: int | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + entities: Sequence["MessageEntity"] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.send_message_draft(update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_message_draft`. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().send_message_draft( + chat_id=self.id, + draft_id=draft_id, + text=text, + message_thread_id=message_thread_id, + parse_mode=parse_mode, + entities=entities, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_message( + self, + message_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.delete_message(update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.delete_message`. + + .. versionadded:: 20.8 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().delete_message( + chat_id=self.id, + message_id=message_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_messages( + self, + message_ids: Sequence[int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.delete_messages(update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.delete_messages`. + + .. versionadded:: 20.8 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().delete_messages( + chat_id=self.id, + message_ids=message_ids, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, ) async def send_media_group( self, media: Sequence[ - Union["InputMediaAudio", "InputMediaDocument", "InputMediaPhoto", "InputMediaVideo"] + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" ], disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - caption: Optional[str] = None, + api_kwargs: JSONDict | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - ) -> Tuple["Message", ...]: + caption_entities: Sequence["MessageEntity"] | None = None, + ) -> tuple["Message", ...]: """Shortcut for:: await bot.send_media_group(update.effective_chat.id, *args, **kwargs) @@ -1357,7 +1216,7 @@ async def send_media_group( For the documentation of the arguments, please see :meth:`telegram.Bot.send_media_group`. Returns: - Tuple[:class:`telegram.Message`]: On success, a tuple of :class:`~telegram.Message` + tuple[:class:`telegram.Message`]: On success, a tuple of :class:`~telegram.Message` instances that were sent is returned. """ @@ -1377,18 +1236,24 @@ async def send_media_group( caption=caption, parse_mode=parse_mode, caption_entities=caption_entities, + reply_parameters=reply_parameters, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, ) async def send_chat_action( self, action: str, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + business_connection_id: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -1409,6 +1274,7 @@ async def send_chat_action( connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, ) send_action = send_chat_action @@ -1416,24 +1282,31 @@ async def send_chat_action( async def send_photo( self, - photo: Union[FileInput, "PhotoSize"], - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, + photo: "FileInput | PhotoSize", + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1451,6 +1324,7 @@ async def send_photo( caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, parse_mode=parse_mode, allow_sending_without_reply=allow_sending_without_reply, @@ -1464,27 +1338,39 @@ async def send_photo( pool_timeout=pool_timeout, api_kwargs=api_kwargs, has_spoiler=has_spoiler, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_contact( self, - phone_number: Optional[str] = None, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - vcard: Optional[str] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + phone_number: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + vcard: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - contact: Optional["Contact"] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + contact: "Contact | None" = 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, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1503,6 +1389,7 @@ async def send_contact( last_name=last_name, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1514,32 +1401,42 @@ async def send_contact( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_audio( self, - audio: Union[FileInput, "Audio"], - duration: Optional[int] = None, - performer: Optional[str] = None, - title: Optional[str] = None, - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, + audio: "FileInput | Audio", + duration: TimePeriod | None = None, + performer: str | None = None, + title: str | None = None, + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - thumb: Optional[FileInput] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1560,9 +1457,9 @@ async def send_audio( caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, parse_mode=parse_mode, - thumb=thumb, allow_sending_without_reply=allow_sending_without_reply, caption_entities=caption_entities, filename=filename, @@ -1574,30 +1471,40 @@ async def send_audio( pool_timeout=pool_timeout, api_kwargs=api_kwargs, thumbnail=thumbnail, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_document( self, - document: Union[FileInput, "Document"], - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, + document: "FileInput | Document", + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - thumb: Optional[FileInput] = None, - disable_content_type_detection: Optional[bool] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + disable_content_type_detection: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1616,13 +1523,13 @@ async def send_document( caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, parse_mode=parse_mode, - thumb=thumb, thumbnail=thumbnail, api_kwargs=api_kwargs, disable_content_type_detection=disable_content_type_detection, @@ -1630,23 +1537,82 @@ async def send_document( caption_entities=caption_entities, protect_content=protect_content, message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) - async def send_dice( + async def send_checklist( self, + business_connection_id: str, + checklist: "InputChecklist", disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - emoji: Optional[str] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_checklist(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_checklist`. + + .. versionadded:: 22.3 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_checklist( + chat_id=self.id, + business_connection_id=business_connection_id, + checklist=checklist, + disable_notification=disable_notification, + protect_content=protect_content, + message_effect_id=message_effect_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + reply_to_message_id=reply_to_message_id, + allow_sending_without_reply=allow_sending_without_reply, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def send_dice( + self, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + emoji: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1662,6 +1628,7 @@ async def send_dice( chat_id=self.id, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1672,23 +1639,32 @@ async def send_dice( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_game( self, game_short_name: str, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "InlineKeyboardMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1705,6 +1681,7 @@ async def send_game( game_short_name=game_short_name, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1714,6 +1691,9 @@ async def send_game( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, ) async def send_invoice( @@ -1721,36 +1701,41 @@ async def send_invoice( title: str, description: str, payload: str, - provider_token: str, currency: str, prices: Sequence["LabeledPrice"], - start_parameter: Optional[str] = None, - photo_url: Optional[str] = None, - photo_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - need_name: Optional[bool] = None, - need_phone_number: Optional[bool] = None, - need_email: Optional[bool] = None, - need_shipping_address: Optional[bool] = None, - is_flexible: Optional[bool] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - provider_data: Optional[Union[str, object]] = None, - send_phone_number_to_provider: Optional[bool] = None, - send_email_to_provider: Optional[bool] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - max_tip_amount: Optional[int] = None, - suggested_tip_amounts: Optional[Sequence[int]] = None, + provider_token: str | None = None, + start_parameter: str | None = None, + photo_url: str | None = None, + photo_size: int | None = None, + photo_width: int | None = None, + photo_height: int | None = None, + need_name: bool | None = None, + need_phone_number: bool | None = None, + need_email: bool | None = None, + need_shipping_address: bool | None = None, + is_flexible: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "InlineKeyboardMarkup | None" = None, + provider_data: str | object | None = None, + send_phone_number_to_provider: bool | None = None, + send_email_to_provider: bool | None = None, + max_tip_amount: int | None = None, + suggested_tip_amounts: Sequence[int] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1806,29 +1791,40 @@ async def send_invoice( suggested_tip_amounts=suggested_tip_amounts, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_location( self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - live_period: Optional[int] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + latitude: float | None = None, + longitude: float | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + live_period: TimePeriod | None = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - location: Optional["Location"] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + location: "Location | None" = 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, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1846,6 +1842,7 @@ async def send_location( longitude=longitude, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1860,33 +1857,44 @@ async def send_location( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_animation( self, - animation: Union[FileInput, "Animation"], - duration: Optional[int] = None, - width: Optional[int] = None, - height: Optional[int] = None, - thumb: Optional[FileInput] = None, - caption: Optional[str] = None, + animation: "FileInput | Animation", + duration: TimePeriod | None = None, + width: int | None = None, + height: int | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1904,11 +1912,11 @@ async def send_animation( duration=duration, width=width, height=height, - thumb=thumb, caption=caption, parse_mode=parse_mode, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1922,24 +1930,36 @@ async def send_animation( message_thread_id=message_thread_id, has_spoiler=has_spoiler, thumbnail=thumbnail, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_sticker( self, - sticker: Union[FileInput, "Sticker"], - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + sticker: "FileInput | Sticker", + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - emoji: Optional[str] = None, + message_thread_id: int | None = None, + emoji: str | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -1956,6 +1976,7 @@ async def send_sticker( sticker=sticker, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -1966,31 +1987,42 @@ async def send_sticker( protect_content=protect_content, message_thread_id=message_thread_id, emoji=emoji, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_venue( self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - title: Optional[str] = None, - address: Optional[str] = None, - foursquare_id: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - foursquare_type: Optional[str] = None, - google_place_id: Optional[str] = None, - google_place_type: Optional[str] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + latitude: float | None = None, + longitude: float | None = None, + title: str | None = None, + address: str | None = None, + foursquare_id: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - venue: Optional["Venue"] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + venue: "Venue | None" = 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, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2011,6 +2043,7 @@ async def send_venue( foursquare_id=foursquare_id, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2024,34 +2057,47 @@ async def send_venue( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_video( self, - video: Union[FileInput, "Video"], - duration: Optional[int] = None, - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - width: Optional[int] = None, - height: Optional[int] = None, + video: "FileInput | Video", + duration: TimePeriod | None = None, + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + width: int | None = None, + height: int | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - supports_streaming: Optional[bool] = None, - thumb: Optional[FileInput] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + supports_streaming: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + cover: "FileInput | None" = None, + start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2070,6 +2116,7 @@ async def send_video( caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2079,8 +2126,9 @@ async def send_video( height=height, parse_mode=parse_mode, supports_streaming=supports_streaming, - thumb=thumb, thumbnail=thumbnail, + cover=cover, + start_timestamp=start_timestamp, api_kwargs=api_kwargs, allow_sending_without_reply=allow_sending_without_reply, caption_entities=caption_entities, @@ -2088,28 +2136,39 @@ async def send_video( protect_content=protect_content, message_thread_id=message_thread_id, has_spoiler=has_spoiler, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_video_note( self, - video_note: Union[FileInput, "VideoNote"], - duration: Optional[int] = None, - length: Optional[int] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - thumb: Optional[FileInput] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + video_note: "FileInput | VideoNote", + duration: TimePeriod | None = None, + length: int | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2128,40 +2187,51 @@ async def send_video_note( length=length, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, - thumb=thumb, thumbnail=thumbnail, api_kwargs=api_kwargs, allow_sending_without_reply=allow_sending_without_reply, filename=filename, protect_content=protect_content, message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_voice( self, - voice: Union[FileInput, "Voice"], - duration: Optional[int] = None, - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, + voice: "FileInput | Voice", + duration: TimePeriod | None = None, + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2180,6 +2250,7 @@ async def send_voice( caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2192,34 +2263,45 @@ async def send_voice( filename=filename, protect_content=protect_content, message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_poll( self, question: str, - options: Sequence[str], - is_anonymous: Optional[bool] = None, - type: Optional[str] = None, - allows_multiple_answers: Optional[bool] = None, - correct_option_id: Optional[int] = None, - is_closed: Optional[bool] = None, + options: Sequence["str | InputPollOption"], + is_anonymous: bool | None = None, + type: str | None = None, + allows_multiple_answers: bool | None = None, + correct_option_id: CorrectOptionID | None = None, + is_closed: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - explanation: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + explanation: str | None = None, explanation_parse_mode: ODVInput[str] = DEFAULT_NONE, - open_period: Optional[int] = None, - close_date: Optional[Union[int, datetime]] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - explanation_entities: Optional[Sequence["MessageEntity"]] = None, + open_period: TimePeriod | None = None, + close_date: int | dtm.datetime | None = None, + explanation_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + question_parse_mode: ODVInput[str] = DEFAULT_NONE, + question_entities: Sequence["MessageEntity"] | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2242,11 +2324,14 @@ async def send_poll( is_closed=is_closed, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, reply_markup=reply_markup, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, explanation=explanation, explanation_parse_mode=explanation_parse_mode, open_period=open_period, @@ -2256,27 +2341,37 @@ async def send_poll( explanation_entities=explanation_entities, protect_content=protect_content, message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + question_parse_mode=question_parse_mode, + question_entities=question_entities, ) async def send_copy( self, - from_chat_id: Union[str, int], + from_chat_id: str | int, message_id: int, - caption: Optional[str] = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - allow_sending_without_reply: DVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + caption_entities: Sequence["MessageEntity"] | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "MessageId": """Shortcut for:: @@ -2284,6 +2379,8 @@ async def send_copy( For the documentation of the arguments, please see :meth:`telegram.Bot.copy_message`. + .. seealso:: :meth:`copy_message`, :meth:`send_copies`, :meth:`copy_messages`. + Returns: :class:`telegram.Message`: On success, instance representing the message posted. @@ -2293,10 +2390,12 @@ async def send_copy( from_chat_id=from_chat_id, message_id=message_id, caption=caption, + video_start_timestamp=video_start_timestamp, parse_mode=parse_mode, caption_entities=caption_entities, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, read_timeout=read_timeout, @@ -2306,27 +2405,39 @@ async def send_copy( api_kwargs=api_kwargs, protect_content=protect_content, message_thread_id=message_thread_id, + show_caption_above_media=show_caption_above_media, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, ) async def copy_message( self, - chat_id: Union[int, str], + chat_id: int | str, message_id: int, - caption: Optional[str] = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - allow_sending_without_reply: DVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + caption_entities: Sequence["MessageEntity"] | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "MessageId": """Shortcut for:: @@ -2334,8 +2445,10 @@ async def copy_message( For the documentation of the arguments, please see :meth:`telegram.Bot.copy_message`. + .. seealso:: :meth:`send_copy`, :meth:`send_copies`, :meth:`copy_messages`. + Returns: - :class:`telegram.Message`: On success, instance representing the message posted. + :class:`telegram.MessageId`: On success, returns the MessageId of the sent message. """ return await self.get_bot().copy_message( @@ -2343,10 +2456,12 @@ async def copy_message( chat_id=chat_id, message_id=message_id, caption=caption, + video_start_timestamp=video_start_timestamp, parse_mode=parse_mode, caption_entities=caption_entities, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, read_timeout=read_timeout, @@ -2356,21 +2471,124 @@ async def copy_message( api_kwargs=api_kwargs, protect_content=protect_content, message_thread_id=message_thread_id, + show_caption_above_media=show_caption_above_media, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, + ) + + async def send_copies( + self, + from_chat_id: str | int, + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + remove_caption: bool | None = None, + direct_messages_topic_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> tuple["MessageId", ...]: + """Shortcut for:: + + await bot.copy_messages(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.copy_messages`. + + .. seealso:: :meth:`copy_message`, :meth:`send_copy`, :meth:`copy_messages`. + + .. versionadded:: 20.8 + + Returns: + tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of the sent messages is returned. + + """ + return await self.get_bot().copy_messages( + chat_id=self.id, + from_chat_id=from_chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + remove_caption=remove_caption, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, + ) + + async def copy_messages( + self, + chat_id: str | int, + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + remove_caption: bool | None = None, + direct_messages_topic_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> tuple["MessageId", ...]: + """Shortcut for:: + + await bot.copy_messages(from_chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.copy_messages`. + + .. seealso:: :meth:`copy_message`, :meth:`send_copy`, :meth:`send_copies`. + + .. versionadded:: 20.8 + + Returns: + tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of the sent messages is returned. + + """ + return await self.get_bot().copy_messages( + from_chat_id=self.id, + chat_id=chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + remove_caption=remove_caption, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, ) async def forward_from( self, - from_chat_id: Union[str, int], + from_chat_id: str | int, message_id: int, - disable_notification: DVInput[bool] = DEFAULT_NONE, + disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2378,7 +2596,7 @@ async def forward_from( For the documentation of the arguments, please see :meth:`telegram.Bot.forward_message`. - .. seealso:: :meth:`forward_to` + .. seealso:: :meth:`forward_to`, :meth:`forward_messages_from`, :meth:`forward_messages_to` .. versionadded:: 20.0 @@ -2390,6 +2608,7 @@ async def forward_from( chat_id=self.id, from_chat_id=from_chat_id, message_id=message_id, + video_start_timestamp=video_start_timestamp, disable_notification=disable_notification, read_timeout=read_timeout, write_timeout=write_timeout, @@ -2398,21 +2617,28 @@ async def forward_from( api_kwargs=api_kwargs, protect_content=protect_content, message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, ) async def forward_to( self, - chat_id: Union[int, str], + chat_id: int | str, message_id: int, - disable_notification: DVInput[bool] = DEFAULT_NONE, + disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> "Message": """Shortcut for:: @@ -2420,7 +2646,8 @@ async def forward_to( For the documentation of the arguments, please see :meth:`telegram.Bot.forward_message`. - .. seealso:: :meth:`forward_from` + .. seealso:: :meth:`forward_from`, :meth:`forward_messages_from`, + :meth:`forward_messages_to` .. versionadded:: 20.0 @@ -2432,14 +2659,108 @@ async def forward_to( from_chat_id=self.id, chat_id=chat_id, message_id=message_id, + video_start_timestamp=video_start_timestamp, + disable_notification=disable_notification, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + protect_content=protect_content, + message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, + ) + + async def forward_messages_from( + self, + from_chat_id: str | int, + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> tuple["MessageId", ...]: + """Shortcut for:: + + await bot.forward_messages(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.forward_messages`. + + .. seealso:: :meth:`forward_to`, :meth:`forward_from`, :meth:`forward_messages_to`. + + .. versionadded:: 20.8 + + Returns: + tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of sent messages is returned. + + """ + return await self.get_bot().forward_messages( + chat_id=self.id, + from_chat_id=from_chat_id, + message_ids=message_ids, disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, + ) + + async def forward_messages_to( + self, + chat_id: int | str, + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> tuple["MessageId", ...]: + """Shortcut for:: + + await bot.forward_messages(from_chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.forward_messages`. + + .. seealso:: :meth:`forward_from`, :meth:`forward_to`, :meth:`forward_messages_from`. + + .. versionadded:: 20.8 + + Returns: + tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of sent messages is returned. + + """ + return await self.get_bot().forward_messages( + from_chat_id=self.id, + chat_id=chat_id, + message_ids=message_ids, + disable_notification=disable_notification, protect_content=protect_content, message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, ) async def export_invite_link( @@ -2449,7 +2770,7 @@ async def export_invite_link( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> str: """Shortcut for:: @@ -2475,16 +2796,16 @@ async def export_invite_link( async def create_invite_link( self, - expire_date: Optional[Union[int, datetime]] = None, - member_limit: Optional[int] = None, - name: Optional[str] = None, - creates_join_request: Optional[bool] = None, + expire_date: int | dtm.datetime | None = None, + member_limit: int | None = None, + name: str | None = None, + creates_join_request: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> "ChatInviteLink": """Shortcut for:: @@ -2518,17 +2839,17 @@ async def create_invite_link( async def edit_invite_link( self, - invite_link: Union[str, "ChatInviteLink"], - expire_date: Optional[Union[int, datetime]] = None, - member_limit: Optional[int] = None, - name: Optional[str] = None, - creates_join_request: Optional[bool] = None, + invite_link: "str | ChatInviteLink", + expire_date: int | dtm.datetime | None = None, + member_limit: int | None = None, + name: str | None = None, + creates_join_request: bool | None = 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, + api_kwargs: JSONDict | None = None, ) -> "ChatInviteLink": """Shortcut for:: @@ -2562,13 +2883,13 @@ async def edit_invite_link( async def revoke_invite_link( self, - invite_link: Union[str, "ChatInviteLink"], + invite_link: "str | ChatInviteLink", *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "ChatInviteLink": """Shortcut for:: @@ -2593,6 +2914,81 @@ async def revoke_invite_link( api_kwargs=api_kwargs, ) + async def create_subscription_invite_link( + self, + subscription_period: TimePeriod, + subscription_price: int, + name: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "ChatInviteLink": + """Shortcut for:: + + await bot.create_chat_subscription_invite_link( + chat_id=update.effective_chat.id, *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.create_chat_subscription_invite_link`. + + .. versionadded:: 21.5 + + Returns: + :class:`telegram.ChatInviteLink` + """ + return await self.get_bot().create_chat_subscription_invite_link( + chat_id=self.id, + subscription_period=subscription_period, + subscription_price=subscription_price, + name=name, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def edit_subscription_invite_link( + self, + invite_link: "str | ChatInviteLink", + name: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "ChatInviteLink": + """Shortcut for:: + + await bot.edit_chat_subscription_invite_link( + chat_id=update.effective_chat.id, *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.edit_chat_subscription_invite_link`. + + .. versionadded:: 21.5 + + Returns: + :class:`telegram.ChatInviteLink` + + """ + return await self.get_bot().edit_chat_subscription_invite_link( + chat_id=self.id, + invite_link=invite_link, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + name=name, + ) + async def approve_join_request( self, user_id: int, @@ -2601,7 +2997,7 @@ async def approve_join_request( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2634,7 +3030,7 @@ async def decline_join_request( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2661,13 +3057,13 @@ async def decline_join_request( async def set_menu_button( self, - menu_button: Optional[MenuButton] = None, + menu_button: MenuButton | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2699,14 +3095,14 @@ async def set_menu_button( async def create_forum_topic( self, name: str, - icon_color: Optional[int] = None, - icon_custom_emoji_id: Optional[str] = None, + icon_color: int | None = None, + icon_custom_emoji_id: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> ForumTopic: """Shortcut for:: @@ -2735,14 +3131,14 @@ async def create_forum_topic( async def edit_forum_topic( self, message_thread_id: int, - name: Optional[str] = None, - icon_custom_emoji_id: Optional[str] = None, + name: str | None = None, + icon_custom_emoji_id: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2776,7 +3172,7 @@ async def close_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2808,7 +3204,7 @@ async def reopen_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2840,7 +3236,7 @@ async def delete_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2872,7 +3268,7 @@ async def unpin_all_forum_topic_messages( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2897,6 +3293,37 @@ async def unpin_all_forum_topic_messages( api_kwargs=api_kwargs, ) + async def unpin_all_general_forum_topic_messages( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.unpin_all_general_forum_topic_messages(chat_id=update.effective_chat.id, + *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.unpin_all_general_forum_topic_messages`. + + .. versionadded:: 20.5 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().unpin_all_general_forum_topic_messages( + chat_id=self.id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def edit_general_forum_topic( self, name: str, @@ -2905,7 +3332,7 @@ async def edit_general_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2938,7 +3365,7 @@ async def close_general_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -2968,7 +3395,7 @@ async def reopen_general_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -3000,7 +3427,7 @@ async def hide_general_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -3030,7 +3457,7 @@ async def unhide_general_forum_topic( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -3062,7 +3489,7 @@ async def get_menu_button( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> MenuButton: """Shortcut for:: @@ -3089,3 +3516,546 @@ async def get_menu_button( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) + + async def get_user_chat_boosts( + self, + user_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "UserChatBoosts": + """Shortcut for:: + + await bot.get_user_chat_boosts(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.get_user_chat_boosts`. + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.UserChatBoosts`: On success, returns the boosts applied in the chat. + """ + return await self.get_bot().get_user_chat_boosts( + chat_id=self.id, + user_id=user_id, + api_kwargs=api_kwargs, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + ) + + async def set_message_reaction( + self, + message_id: int, + reaction: Sequence[ReactionType | str] | ReactionType | str | None = None, + is_big: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.set_message_reaction(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.set_message_reaction`. + + .. versionadded:: 20.8 + + Returns: + :obj:`bool` On success, :obj:`True` is returned. + """ + return await self.get_bot().set_message_reaction( + chat_id=self.id, + message_id=message_id, + reaction=reaction, + is_big=is_big, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def send_paid_media( + self, + star_count: int, + media: Sequence["InputPaidMedia"], + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + business_connection_id: str | None = None, + payload: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_thread_id: int | None = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = 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, + business_connection_id=business_connection_id, + payload=payload, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_thread_id=message_thread_id, + ) + + async def send_gift( + self, + gift_id: "str | Gift", + text: str | None = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Sequence["MessageEntity"] | None = None, + pay_for_upgrade: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.send_gift(user_id=update.effective_chat.id, *args, **kwargs ) + + or:: + + await bot.send_gift(chat_id=update.effective_chat.id, *args, **kwargs ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_gift`. + + Caution: + Will only work if the chat is a private or channel chat, see :attr:`type`. + + .. versionadded:: 21.8 + + .. versionchanged:: 21.11 + + Added support for channel chats. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().send_gift( + gift_id=gift_id, + text=text, + text_parse_mode=text_parse_mode, + text_entities=text_entities, + pay_for_upgrade=pay_for_upgrade, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + **{"chat_id" if self.type == Chat.CHANNEL else "user_id": self.id}, + ) + + async def transfer_gift( + self, + business_connection_id: str, + owned_gift_id: str, + star_count: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.transfer_gift(new_owner_chat_id=update.effective_chat.id, *args, **kwargs ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.transfer_gift`. + + .. versionadded:: 22.1 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().transfer_gift( + new_owner_chat_id=self.id, + business_connection_id=business_connection_id, + owned_gift_id=owned_gift_id, + star_count=star_count, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def verify( + self, + custom_description: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.verify_chat(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.verify_chat`. + + .. versionadded:: 21.10 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().verify_chat( + chat_id=self.id, + custom_description=custom_description, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def remove_verification( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.remove_chat_verification(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.remove_chat_verification`. + + .. versionadded:: 21.10 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().remove_chat_verification( + chat_id=self.id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def read_business_message( + self, + business_connection_id: str, + message_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.read_business_message(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.read_business_message`. + + .. versionadded:: 22.1 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().read_business_message( + chat_id=self.id, + business_connection_id=business_connection_id, + message_id=message_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def approve_suggested_post( + self, + message_id: int, + send_date: int | dtm.datetime | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Shortcut for:: + + await bot.approve_suggested_post(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.approve_suggested_post`. + + .. versionadded:: 22.4 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().approve_suggested_post( + chat_id=self.id, + message_id=message_id, + send_date=send_date, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def decline_suggested_post( + self, + message_id: int, + comment: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Shortcut for:: + + await bot.decline_suggested_post(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.decline_suggested_post`. + + .. versionadded:: 22.4 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().decline_suggested_post( + chat_id=self.id, + message_id=message_id, + comment=comment, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def repost_story( + self, + business_connection_id: str, + from_story_id: int, + active_period: TimePeriod, + post_to_chat_page: bool | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Story": + """Shortcut for:: + + await bot.repost_story( + from_chat_id=update.effective_chat.id, + *args, **kwargs + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.repost_story`. + + .. versionadded:: 22.6 + + Returns: + :class:`Story`: On success, :class:`Story` is returned. + + """ + return await self.get_bot().repost_story( + business_connection_id=business_connection_id, + from_chat_id=self.id, + from_story_id=from_story_id, + active_period=active_period, + post_to_chat_page=post_to_chat_page, + protect_content=protect_content, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def get_gifts( + self, + exclude_unsaved: bool | None = None, + exclude_saved: bool | None = None, + exclude_unlimited: bool | None = None, + exclude_limited_upgradable: bool | None = None, + exclude_limited_non_upgradable: bool | None = None, + exclude_from_blockchain: bool | None = None, + exclude_unique: bool | None = None, + sort_by_price: bool | None = None, + offset: str | None = None, + limit: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "OwnedGifts": + """Shortcut for:: + + await bot.get_chat_gifts(chat_id=update.effective_chat.id) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.get_chat_gifts`. + + .. versionadded:: 22.6 + + Returns: + :class:`telegram.OwnedGifts`: On success, returns the gifts owned by the chat. + """ + return await self.get_bot().get_chat_gifts( + chat_id=self.id, + exclude_unsaved=exclude_unsaved, + exclude_saved=exclude_saved, + exclude_unlimited=exclude_unlimited, + exclude_limited_upgradable=exclude_limited_upgradable, + exclude_limited_non_upgradable=exclude_limited_non_upgradable, + exclude_from_blockchain=exclude_from_blockchain, + exclude_unique=exclude_unique, + sort_by_price=sort_by_price, + offset=offset, + limit=limit, + 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. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`id` is equal. + + .. versionchanged:: 20.0 + + * Removed the deprecated methods ``kick_member`` and ``get_members_count``. + * The following are now keyword-only arguments in Bot methods: + ``location``, ``filename``, ``contact``, ``{read, write, connect, pool}_timeout``, + ``api_kwargs``. Use a named argument for those, + and notice that some positional arguments changed position as a result. + + .. versionchanged:: 20.0 + Removed the attribute ``all_members_are_administrators``. As long as Telegram provides + this field for backwards compatibility, it is available through + :attr:`~telegram.TelegramObject.api_kwargs`. + + .. versionchanged:: 21.3 + As per Bot API 7.3, most of the arguments and attributes of this class have now moved to + :class:`telegram.ChatFullInfo`. + + Args: + id (:obj:`int`): Unique identifier for this chat. + type (:obj:`str`): Type of chat, can be either :attr:`PRIVATE`, :attr:`GROUP`, + :attr:`SUPERGROUP` or :attr:`CHANNEL`. + title (:obj:`str`, optional): Title, for supergroups, channels and group chats. + username (:obj:`str`, optional): Username, for private chats, supergroups and channels if + available. + first_name (:obj:`str`, optional): First name of the other party in a private chat. + last_name (:obj:`str`, optional): Last name of the other party in a private chat. + is_forum (:obj:`bool`, optional): :obj:`True`, if the supergroup chat is a forum + (has topics_ enabled). + + .. versionadded:: 20.0 + is_direct_messages (:obj:`bool`, optional): :obj:`True`, if the chat is the direct messages + chat of a channel. + + .. versionadded:: 22.4 + + Attributes: + id (:obj:`int`): Unique identifier for this chat. + type (:obj:`str`): Type of chat, can be either :attr:`PRIVATE`, :attr:`GROUP`, + :attr:`SUPERGROUP` or :attr:`CHANNEL`. + title (:obj:`str`): Optional. Title, for supergroups, channels and group chats. + username (:obj:`str`): Optional. Username, for private chats, supergroups and channels if + available. + first_name (:obj:`str`): Optional. First name of the other party in a private chat. + last_name (:obj:`str`): Optional. Last name of the other party in a private chat. + is_forum (:obj:`bool`): Optional. :obj:`True`, if the supergroup chat is a forum + (has topics_ enabled). + + .. versionadded:: 20.0 + is_direct_messages (:obj:`bool`): Optional. :obj:`True`, if the chat is the direct messages + chat of a channel. + + .. versionadded:: 22.4 + + .. _topics: https://telegram.org/blog/topics-in-groups-collectible-usernames#topics-in-groups + """ + + __slots__ = () diff --git a/telegram/_chatadministratorrights.py b/src/telegram/_chatadministratorrights.py similarity index 57% rename from telegram/_chatadministratorrights.py rename to src/telegram/_chatadministratorrights.py index cea12dd15e5..31fbda4b721 100644 --- a/telegram/_chatadministratorrights.py +++ b/src/telegram/_chatadministratorrights.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # 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 the class which represents a Telegram ChatAdministratorRights.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -31,57 +30,92 @@ class ChatAdministratorRights(TelegramObject): :attr:`can_delete_messages`, :attr:`can_manage_video_chats`, :attr:`can_restrict_members`, :attr:`can_promote_members`, :attr:`can_change_info`, :attr:`can_invite_users`, :attr:`can_post_messages`, :attr:`can_edit_messages`, :attr:`can_pin_messages`, - :attr:`can_manage_topics` are equal. + :attr:`can_manage_topics`, :attr:`can_post_stories`, :attr:`can_delete_stories`, + :attr:`can_edit_stories` and :attr:`can_manage_direct_messages` are equal. + + .. versionadded:: 20.0 .. versionchanged:: 20.0 :attr:`can_manage_topics` is considered as well when comparing objects of this type in terms of equality. - .. versionadded:: 20.0 + .. versionchanged:: 20.6 + :attr:`can_post_stories`, :attr:`can_edit_stories`, and :attr:`can_delete_stories` are + considered as well when comparing objects of this type in terms of equality. + + .. versionchanged:: 21.1 + As of this version, :attr:`can_post_stories`, :attr:`can_edit_stories`, + and :attr:`can_delete_stories` is now required. Thus, the order of arguments had to be + changed. + + .. versionchanged:: 22.4 + :attr:`can_manage_direct_messages` is considered as well when comparing objects of + this type in terms of equality. Args: is_anonymous (:obj:`bool`): :obj:`True`, if the user's presence in the chat is hidden. can_manage_chat (:obj:`bool`): :obj:`True`, if the administrator can access the chat event - log, chat statistics, message statistics in channels, see channel members, see - anonymous administrators in supergroups and ignore slow mode. Implied by any other - administrator privilege. + log, get boost list, see hidden supergroup and channel members, report spam messages + and ignore slow mode. Implied by any other administrator privilege. can_delete_messages (:obj:`bool`): :obj:`True`, if the administrator can delete messages of other users. can_manage_video_chats (:obj:`bool`): :obj:`True`, if the administrator can manage video chats. can_restrict_members (:obj:`bool`): :obj:`True`, if the administrator can restrict, ban or - unban chat members. + unban chat members, or access supergroup statistics. can_promote_members (:obj:`bool`): :obj:`True`, if the administrator can add new administrators with a subset of their own privileges or demote administrators that they have promoted, directly or indirectly (promoted by administrators that were appointed by the user). can_change_info (:obj:`bool`): :obj:`True`, if the user is allowed to change the chat title - ,photo and other settings. + , photo and other settings. can_invite_users (:obj:`bool`): :obj:`True`, if the user is allowed to invite new users to the chat. can_post_messages (:obj:`bool`, optional): :obj:`True`, if the administrator can post - messages in the channel; channels only. + messages in the channel, or access channel statistics; for channels only. can_edit_messages (:obj:`bool`, optional): :obj:`True`, if the administrator can edit - messages of other users. + messages of other users and can pin messages; for channels only. can_pin_messages (:obj:`bool`, optional): :obj:`True`, if the user is allowed to pin - messages; groups and supergroups only. + messages; for groups and supergroups only. + can_post_stories (:obj:`bool`): :obj:`True`, if the administrator can post + stories to the chat. + + .. versionadded:: 20.6 + .. versionchanged:: 21.0 + |non_optional_story_argument| + can_edit_stories (:obj:`bool`): :obj:`True`, if the administrator can edit stories posted + by other users, post stories to the chat page, pin chat stories, and access the chat's + story archive + + .. versionadded:: 20.6 + .. versionchanged:: 21.0 + |non_optional_story_argument| + can_delete_stories (:obj:`bool`): :obj:`True`, if the administrator can delete + stories posted by other users. + + .. versionadded:: 20.6 + .. versionchanged:: 21.0 + |non_optional_story_argument| can_manage_topics (:obj:`bool`, optional): :obj:`True`, if the user is allowed - to create, rename, close, and reopen forum topics; supergroups only. + to create, rename, close, and reopen forum topics; for supergroups only. .. versionadded:: 20.0 + can_manage_direct_messages (:obj:`bool`, optional): :obj:`True`, if the administrator can + manage direct messages of the channel and decline suggested posts; for channels only. + + .. versionadded:: 22.4 Attributes: is_anonymous (:obj:`bool`): :obj:`True`, if the user's presence in the chat is hidden. can_manage_chat (:obj:`bool`): :obj:`True`, if the administrator can access the chat event - log, chat statistics, message statistics in channels, see channel members, see - anonymous administrators in supergroups and ignore slow mode. Implied by any other - administrator privilege. + log, get boost list, see hidden supergroup and channel members, report spam messages + and ignore slow mode. Implied by any other administrator privilege. can_delete_messages (:obj:`bool`): :obj:`True`, if the administrator can delete messages of other users. can_manage_video_chats (:obj:`bool`): :obj:`True`, if the administrator can manage video chats. can_restrict_members (:obj:`bool`): :obj:`True`, if the administrator can restrict, ban or - unban chat members. + unban chat members, or access supergroup statistics. can_promote_members (:obj:`bool`): :obj:`True`, if the administrator can add new administrators with a subset of their own privileges or demote administrators that he has promoted, directly or indirectly (promoted by administrators that were appointed by @@ -91,30 +125,57 @@ class ChatAdministratorRights(TelegramObject): can_invite_users (:obj:`bool`): :obj:`True`, if the user is allowed to invite new users to the chat. can_post_messages (:obj:`bool`): Optional. :obj:`True`, if the administrator can post - messages in the channel; channels only. + messages in the channel, or access channel statistics; for channels only. can_edit_messages (:obj:`bool`): Optional. :obj:`True`, if the administrator can edit - messages of other users. + messages of other users and can pin messages; for channels only. can_pin_messages (:obj:`bool`): Optional. :obj:`True`, if the user is allowed to pin - messages; groups and supergroups only. + messages; for groups and supergroups only. + can_post_stories (:obj:`bool`): :obj:`True`, if the administrator can post + stories to the chat. + + .. versionadded:: 20.6 + .. versionchanged:: 21.0 + |non_optional_story_argument| + can_edit_stories (:obj:`bool`): :obj:`True`, if the administrator can edit stories posted + by other users, post stories to the chat page, pin chat stories, and access the chat's + story archive + + .. versionadded:: 20.6 + .. versionchanged:: 21.0 + |non_optional_story_argument| + can_delete_stories (:obj:`bool`): :obj:`True`, if the administrator can delete + stories posted by other users. + + .. versionadded:: 20.6 + .. versionchanged:: 21.0 + |non_optional_story_argument| can_manage_topics (:obj:`bool`): Optional. :obj:`True`, if the user is allowed - to create, rename, close, and reopen forum topics; supergroups only. + to create, rename, close, and reopen forum topics; for supergroups only. .. versionadded:: 20.0 + can_manage_direct_messages (:obj:`bool`): Optional. :obj:`True`, if the administrator can + manage direct messages of the channel and decline suggested posts; for channels only. + + .. versionadded:: 22.4 """ __slots__ = ( - "is_anonymous", - "can_manage_chat", - "can_delete_messages", - "can_manage_video_chats", - "can_restrict_members", - "can_promote_members", "can_change_info", - "can_invite_users", - "can_post_messages", + "can_delete_messages", + "can_delete_stories", "can_edit_messages", - "can_pin_messages", + "can_edit_stories", + "can_invite_users", + "can_manage_chat", + "can_manage_direct_messages", "can_manage_topics", + "can_manage_video_chats", + "can_pin_messages", + "can_post_messages", + "can_post_stories", + "can_promote_members", + "can_restrict_members", + "is_anonymous", ) def __init__( @@ -127,12 +188,16 @@ def __init__( can_promote_members: bool, can_change_info: bool, can_invite_users: bool, - can_post_messages: Optional[bool] = None, - can_edit_messages: Optional[bool] = None, - can_pin_messages: Optional[bool] = None, - can_manage_topics: Optional[bool] = None, + can_post_stories: bool, + can_edit_stories: bool, + can_delete_stories: bool, + can_post_messages: bool | None = None, + can_edit_messages: bool | None = None, + can_pin_messages: bool | None = None, + can_manage_topics: bool | None = None, + can_manage_direct_messages: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(api_kwargs=api_kwargs) # Required @@ -144,11 +209,15 @@ def __init__( self.can_promote_members: bool = can_promote_members self.can_change_info: bool = can_change_info self.can_invite_users: bool = can_invite_users + self.can_post_stories: bool = can_post_stories + self.can_edit_stories: bool = can_edit_stories + self.can_delete_stories: bool = can_delete_stories # Optionals - self.can_post_messages: Optional[bool] = can_post_messages - self.can_edit_messages: Optional[bool] = can_edit_messages - self.can_pin_messages: Optional[bool] = can_pin_messages - self.can_manage_topics: Optional[bool] = can_manage_topics + self.can_post_messages: bool | None = can_post_messages + self.can_edit_messages: bool | None = can_edit_messages + self.can_pin_messages: bool | None = can_pin_messages + self.can_manage_topics: bool | None = can_manage_topics + self.can_manage_direct_messages: bool | None = can_manage_direct_messages self._id_attrs = ( self.is_anonymous, @@ -163,6 +232,10 @@ def __init__( self.can_edit_messages, self.can_pin_messages, self.can_manage_topics, + self.can_post_stories, + self.can_edit_stories, + self.can_delete_stories, + self.can_manage_direct_messages, ) self._freeze() @@ -176,7 +249,7 @@ def all_rights(cls) -> "ChatAdministratorRights": .. versionadded:: 20.0 """ - return cls(True, True, True, True, True, True, True, True, True, True, True, True) + return cls(*(True,) * len(cls.__slots__)) @classmethod def no_rights(cls) -> "ChatAdministratorRights": @@ -186,6 +259,4 @@ def no_rights(cls) -> "ChatAdministratorRights": .. versionadded:: 20.0 """ - return cls( - False, False, False, False, False, False, False, False, False, False, False, False - ) + return cls(*(False,) * len(cls.__slots__)) diff --git a/src/telegram/_chatbackground.py b/src/telegram/_chatbackground.py new file mode 100644 index 00000000000..5b71259d58e --- /dev/null +++ b/src/telegram/_chatbackground.py @@ -0,0 +1,533 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 related to chat backgrounds.""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Final + +from telegram import constants +from telegram._files.document import Document +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional, parse_sequence_arg +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class BackgroundFill(TelegramObject): + """Base class for Telegram BackgroundFill Objects. It can be one of: + + * :class:`telegram.BackgroundFillSolid` + * :class:`telegram.BackgroundFillGradient` + * :class:`telegram.BackgroundFillFreeformGradient` + + 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.2 + + Args: + type (:obj:`str`): Type of the background fill. Can be one of: + :attr:`~telegram.BackgroundFill.SOLID`, :attr:`~telegram.BackgroundFill.GRADIENT` + or :attr:`~telegram.BackgroundFill.FREEFORM_GRADIENT`. + + Attributes: + type (:obj:`str`): Type of the background fill. Can be one of: + :attr:`~telegram.BackgroundFill.SOLID`, :attr:`~telegram.BackgroundFill.GRADIENT` + or :attr:`~telegram.BackgroundFill.FREEFORM_GRADIENT`. + """ + + __slots__ = ("type",) + + SOLID: Final[constants.BackgroundFillType] = constants.BackgroundFillType.SOLID + """:const:`telegram.constants.BackgroundFillType.SOLID`""" + GRADIENT: Final[constants.BackgroundFillType] = constants.BackgroundFillType.GRADIENT + """:const:`telegram.constants.BackgroundFillType.GRADIENT`""" + FREEFORM_GRADIENT: Final[constants.BackgroundFillType] = ( + constants.BackgroundFillType.FREEFORM_GRADIENT + ) + """:const:`telegram.constants.BackgroundFillType.FREEFORM_GRADIENT`""" + + def __init__( + self, + type: str, # pylint: disable=redefined-builtin + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required by all subclasses + self.type: str = enum.get_member(constants.BackgroundFillType, type, type) + + self._id_attrs = (self.type,) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BackgroundFill": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + _class_mapping: dict[str, type[BackgroundFill]] = { + cls.SOLID: BackgroundFillSolid, + cls.GRADIENT: BackgroundFillGradient, + cls.FREEFORM_GRADIENT: BackgroundFillFreeformGradient, + } + + if cls is BackgroundFill 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 BackgroundFillSolid(BackgroundFill): + """ + The background is filled using the selected color. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`color` is equal. + + .. versionadded:: 21.2 + + Args: + color (:obj:`int`): The color of the background fill in the `RGB24` format. + + Attributes: + type (:obj:`str`): Type of the background fill. Always + :attr:`~telegram.BackgroundFill.SOLID`. + color (:obj:`int`): The color of the background fill in the `RGB24` format. + """ + + __slots__ = ("color",) + + def __init__( + self, + color: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=self.SOLID, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.color: int = color + + self._id_attrs = (self.color,) + + +class BackgroundFillGradient(BackgroundFill): + """ + The background is a gradient fill. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`top_color`, :attr:`bottom_color` + and :attr:`rotation_angle` are equal. + + .. versionadded:: 21.2 + + Args: + top_color (:obj:`int`): Top color of the gradient in the `RGB24` format. + bottom_color (:obj:`int`): Bottom color of the gradient in the `RGB24` format. + rotation_angle (:obj:`int`): Clockwise rotation angle of the background + fill in degrees; + 0-:tg-const:`telegram.constants.BackgroundFillLimit.MAX_ROTATION_ANGLE`. + + + Attributes: + type (:obj:`str`): Type of the background fill. Always + :attr:`~telegram.BackgroundFill.GRADIENT`. + top_color (:obj:`int`): Top color of the gradient in the `RGB24` format. + bottom_color (:obj:`int`): Bottom color of the gradient in the `RGB24` format. + rotation_angle (:obj:`int`): Clockwise rotation angle of the background + fill in degrees; + 0-:tg-const:`telegram.constants.BackgroundFillLimit.MAX_ROTATION_ANGLE`. + """ + + __slots__ = ("bottom_color", "rotation_angle", "top_color") + + def __init__( + self, + top_color: int, + bottom_color: int, + rotation_angle: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=self.GRADIENT, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.top_color: int = top_color + self.bottom_color: int = bottom_color + self.rotation_angle: int = rotation_angle + + self._id_attrs = (self.top_color, self.bottom_color, self.rotation_angle) + + +class BackgroundFillFreeformGradient(BackgroundFill): + """ + The background is a freeform gradient that rotates after every message in the chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`colors` is equal. + + .. versionadded:: 21.2 + + Args: + colors (Sequence[:obj:`int`]): A list of the 3 or 4 base colors that are used to + generate the freeform gradient in the `RGB24` format + + Attributes: + type (:obj:`str`): Type of the background fill. Always + :attr:`~telegram.BackgroundFill.FREEFORM_GRADIENT`. + colors (Sequence[:obj:`int`]): A list of the 3 or 4 base colors that are used to + generate the freeform gradient in the `RGB24` format + """ + + __slots__ = ("colors",) + + def __init__( + self, + colors: Sequence[int], + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=self.FREEFORM_GRADIENT, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.colors: tuple[int, ...] = parse_sequence_arg(colors) + + self._id_attrs = (self.colors,) + + +class BackgroundType(TelegramObject): + """Base class for Telegram BackgroundType Objects. It can be one of: + + * :class:`telegram.BackgroundTypeFill` + * :class:`telegram.BackgroundTypeWallpaper` + * :class:`telegram.BackgroundTypePattern` + * :class:`telegram.BackgroundTypeChatTheme`. + + 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.2 + + Args: + type (:obj:`str`): Type of the background. Can be one of: + :attr:`~telegram.BackgroundType.FILL`, :attr:`~telegram.BackgroundType.WALLPAPER` + :attr:`~telegram.BackgroundType.PATTERN` or + :attr:`~telegram.BackgroundType.CHAT_THEME`. + + Attributes: + type (:obj:`str`): Type of the background. Can be one of: + :attr:`~telegram.BackgroundType.FILL`, :attr:`~telegram.BackgroundType.WALLPAPER` + :attr:`~telegram.BackgroundType.PATTERN` or + :attr:`~telegram.BackgroundType.CHAT_THEME`. + + """ + + __slots__ = ("type",) + + FILL: Final[constants.BackgroundTypeType] = constants.BackgroundTypeType.FILL + """:const:`telegram.constants.BackgroundTypeType.FILL`""" + WALLPAPER: Final[constants.BackgroundTypeType] = constants.BackgroundTypeType.WALLPAPER + """:const:`telegram.constants.BackgroundTypeType.WALLPAPER`""" + PATTERN: Final[constants.BackgroundTypeType] = constants.BackgroundTypeType.PATTERN + """:const:`telegram.constants.BackgroundTypeType.PATTERN`""" + CHAT_THEME: Final[constants.BackgroundTypeType] = constants.BackgroundTypeType.CHAT_THEME + """:const:`telegram.constants.BackgroundTypeType.CHAT_THEME`""" + + def __init__( + self, + type: str, # pylint: disable=redefined-builtin + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required by all subclasses + self.type: str = enum.get_member(constants.BackgroundTypeType, type, type) + + self._id_attrs = (self.type,) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BackgroundType": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + _class_mapping: dict[str, type[BackgroundType]] = { + cls.FILL: BackgroundTypeFill, + cls.WALLPAPER: BackgroundTypeWallpaper, + cls.PATTERN: BackgroundTypePattern, + cls.CHAT_THEME: BackgroundTypeChatTheme, + } + + if cls is BackgroundType and data.get("type") in _class_mapping: + return _class_mapping[data.pop("type")].de_json(data=data, bot=bot) + + if "fill" in data: + data["fill"] = de_json_optional(data.get("fill"), BackgroundFill, bot) + + if "document" in data: + data["document"] = de_json_optional(data.get("document"), Document, bot) + + return super().de_json(data=data, bot=bot) + + +class BackgroundTypeFill(BackgroundType): + """ + The background is automatically filled based on the selected colors. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`fill` and :attr:`dark_theme_dimming` are equal. + + .. versionadded:: 21.2 + + Args: + fill (:class:`telegram.BackgroundFill`): The background fill. + dark_theme_dimming (:obj:`int`): Dimming of the background in dark themes, as a + percentage; + 0-:tg-const:`telegram.constants.BackgroundTypeLimit.MAX_DIMMING`. + + Attributes: + type (:obj:`str`): Type of the background. Always + :attr:`~telegram.BackgroundType.FILL`. + fill (:class:`telegram.BackgroundFill`): The background fill. + dark_theme_dimming (:obj:`int`): Dimming of the background in dark themes, as a + percentage; + 0-:tg-const:`telegram.constants.BackgroundTypeLimit.MAX_DIMMING`. + """ + + __slots__ = ("dark_theme_dimming", "fill") + + def __init__( + self, + fill: BackgroundFill, + dark_theme_dimming: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=self.FILL, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.fill: BackgroundFill = fill + self.dark_theme_dimming: int = dark_theme_dimming + + self._id_attrs = (self.fill, self.dark_theme_dimming) + + +class BackgroundTypeWallpaper(BackgroundType): + """ + The background is a wallpaper in the `JPEG` format. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`document` and :attr:`dark_theme_dimming` are equal. + + .. versionadded:: 21.2 + + Args: + document (:class:`telegram.Document`): Document with the wallpaper + dark_theme_dimming (:obj:`int`): Dimming of the background in dark themes, as a + percentage; + 0-:tg-const:`telegram.constants.BackgroundTypeLimit.MAX_DIMMING`. + is_blurred (:obj:`bool`, optional): :obj:`True`, if the wallpaper is downscaled to fit + in a 450x450 square and then box-blurred with radius 12 + is_moving (:obj:`bool`, optional): :obj:`True`, if the background moves slightly + when the device is tilted + + Attributes: + type (:obj:`str`): Type of the background. Always + :attr:`~telegram.BackgroundType.WALLPAPER`. + document (:class:`telegram.Document`): Document with the wallpaper + dark_theme_dimming (:obj:`int`): Dimming of the background in dark themes, as a + percentage; + 0-:tg-const:`telegram.constants.BackgroundTypeLimit.MAX_DIMMING`. + is_blurred (:obj:`bool`): Optional. :obj:`True`, if the wallpaper is downscaled to fit + in a 450x450 square and then box-blurred with radius 12 + is_moving (:obj:`bool`): Optional. :obj:`True`, if the background moves slightly + when the device is tilted + """ + + __slots__ = ("dark_theme_dimming", "document", "is_blurred", "is_moving") + + def __init__( + self, + document: Document, + dark_theme_dimming: int, + is_blurred: bool | None = None, + is_moving: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=self.WALLPAPER, api_kwargs=api_kwargs) + + with self._unfrozen(): + # Required + self.document: Document = document + self.dark_theme_dimming: int = dark_theme_dimming + # Optionals + self.is_blurred: bool | None = is_blurred + self.is_moving: bool | None = is_moving + + self._id_attrs = (self.document, self.dark_theme_dimming) + + +class BackgroundTypePattern(BackgroundType): + """ + The background is a ``.PNG`` or ``.TGV`` (gzipped subset of ``SVG`` with ``MIME`` type + ``"application/x-tgwallpattern"``) pattern to be combined with the background fill + chosen by the user. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`document` and :attr:`fill` and :attr:`intensity` are equal. + + .. versionadded:: 21.2 + + Args: + document (:class:`telegram.Document`): Document with the pattern. + fill (:class:`telegram.BackgroundFill`): The background fill that is combined with + the pattern. + intensity (:obj:`int`): Intensity of the pattern when it is shown above the filled + background; + 0-:tg-const:`telegram.constants.BackgroundTypeLimit.MAX_INTENSITY`. + is_inverted (:obj:`int`, optional): :obj:`True`, if the background fill must be applied + only to the pattern itself. All other pixels are black in this case. For dark + themes only. + is_moving (:obj:`bool`, optional): :obj:`True`, if the background moves slightly + when the device is tilted. + + Attributes: + type (:obj:`str`): Type of the background. Always + :attr:`~telegram.BackgroundType.PATTERN`. + document (:class:`telegram.Document`): Document with the pattern. + fill (:class:`telegram.BackgroundFill`): The background fill that is combined with + the pattern. + intensity (:obj:`int`): Intensity of the pattern when it is shown above the filled + background; + 0-:tg-const:`telegram.constants.BackgroundTypeLimit.MAX_INTENSITY`. + is_inverted (:obj:`int`): Optional. :obj:`True`, if the background fill must be applied + only to the pattern itself. All other pixels are black in this case. For dark + themes only. + is_moving (:obj:`bool`): Optional. :obj:`True`, if the background moves slightly + when the device is tilted. + """ + + __slots__ = ( + "document", + "fill", + "intensity", + "is_inverted", + "is_moving", + ) + + def __init__( + self, + document: Document, + fill: BackgroundFill, + intensity: int, + is_inverted: bool | None = None, + is_moving: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=self.PATTERN, api_kwargs=api_kwargs) + + with self._unfrozen(): + # Required + self.document: Document = document + self.fill: BackgroundFill = fill + self.intensity: int = intensity + # Optionals + self.is_inverted: bool | None = is_inverted + self.is_moving: bool | None = is_moving + + self._id_attrs = (self.document, self.fill, self.intensity) + + +class BackgroundTypeChatTheme(BackgroundType): + """ + The background is taken directly from a built-in chat theme. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`theme_name` is equal. + + .. versionadded:: 21.2 + + Args: + theme_name (:obj:`str`): Name of the chat theme, which is usually an emoji. + + Attributes: + type (:obj:`str`): Type of the background. Always + :attr:`~telegram.BackgroundType.CHAT_THEME`. + theme_name (:obj:`str`): Name of the chat theme, which is usually an emoji. + """ + + __slots__ = ("theme_name",) + + def __init__( + self, + theme_name: str, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=self.CHAT_THEME, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.theme_name: str = theme_name + + self._id_attrs = (self.theme_name,) + + +class ChatBackground(TelegramObject): + """ + This object represents a chat background. + + 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.2 + + Args: + type (:class:`telegram.BackgroundType`): Type of the background. + + Attributes: + type (:class:`telegram.BackgroundType`): Type of the background. + """ + + __slots__ = ("type",) + + def __init__( + self, + type: BackgroundType, # pylint: disable=redefined-builtin + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.type: BackgroundType = type + + self._id_attrs = (self.type,) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatBackground": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["type"] = de_json_optional(data.get("type"), BackgroundType, bot) + + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_chatboost.py b/src/telegram/_chatboost.py new file mode 100644 index 00000000000..2386f2e6a5c --- /dev/null +++ b/src/telegram/_chatboost.py @@ -0,0 +1,440 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 the classes that represent Telegram ChatBoosts.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING, Final + +from telegram import constants +from telegram._chat import Chat +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, 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 ChatBoostAdded(TelegramObject): + """ + This object represents a service message about a user boosting a chat. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their + :attr:`boost_count` are equal. + + .. versionadded:: 21.0 + + Args: + boost_count (:obj:`int`): Number of boosts added by the user. + + Attributes: + boost_count (:obj:`int`): Number of boosts added by the user. + + """ + + __slots__ = ("boost_count",) + + def __init__( + self, + boost_count: int, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.boost_count: int = boost_count + self._id_attrs = (self.boost_count,) + + self._freeze() + + +class ChatBoostSource(TelegramObject): + """ + Base class for Telegram ChatBoostSource objects. It can be one of: + + * :class:`telegram.ChatBoostSourcePremium` + * :class:`telegram.ChatBoostSourceGiftCode` + * :class:`telegram.ChatBoostSourceGiveaway` + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`source` is equal. + + .. versionadded:: 20.8 + + Args: + source (:obj:`str`): The source of the chat boost. Can be one of: + :attr:`~telegram.ChatBoostSource.PREMIUM`, :attr:`~telegram.ChatBoostSource.GIFT_CODE`, + or :attr:`~telegram.ChatBoostSource.GIVEAWAY`. + + Attributes: + source (:obj:`str`): The source of the chat boost. Can be one of: + :attr:`~telegram.ChatBoostSource.PREMIUM`, :attr:`~telegram.ChatBoostSource.GIFT_CODE`, + or :attr:`~telegram.ChatBoostSource.GIVEAWAY`. + """ + + __slots__ = ("source",) + + PREMIUM: Final[str] = constants.ChatBoostSources.PREMIUM + """:const:`telegram.constants.ChatBoostSources.PREMIUM`""" + GIFT_CODE: Final[str] = constants.ChatBoostSources.GIFT_CODE + """:const:`telegram.constants.ChatBoostSources.GIFT_CODE`""" + GIVEAWAY: Final[str] = constants.ChatBoostSources.GIVEAWAY + """:const:`telegram.constants.ChatBoostSources.GIVEAWAY`""" + + def __init__(self, source: str, *, api_kwargs: JSONDict | None = None): + super().__init__(api_kwargs=api_kwargs) + + # Required by all subclasses: + self.source: str = enum.get_member(constants.ChatBoostSources, source, source) + + self._id_attrs = (self.source,) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatBoostSource": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + _class_mapping: dict[str, type[ChatBoostSource]] = { + cls.PREMIUM: ChatBoostSourcePremium, + cls.GIFT_CODE: ChatBoostSourceGiftCode, + cls.GIVEAWAY: ChatBoostSourceGiveaway, + } + + if cls is ChatBoostSource and data.get("source") in _class_mapping: + return _class_mapping[data.pop("source")].de_json(data=data, bot=bot) + + if "user" in data: + data["user"] = de_json_optional(data.get("user"), User, bot) + + return super().de_json(data=data, bot=bot) + + +class ChatBoostSourcePremium(ChatBoostSource): + """ + The boost was obtained by subscribing to Telegram Premium or by gifting a Telegram Premium + subscription to another user. + + .. versionadded:: 20.8 + + Args: + user (:class:`telegram.User`): User that boosted the chat. + + Attributes: + source (:obj:`str`): The source of the chat boost. Always + :attr:`~telegram.ChatBoostSource.PREMIUM`. + user (:class:`telegram.User`): User that boosted the chat. + """ + + __slots__ = ("user",) + + def __init__(self, user: User, *, api_kwargs: JSONDict | None = None): + super().__init__(source=self.PREMIUM, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.user: User = user + + +class ChatBoostSourceGiftCode(ChatBoostSource): + """ + The boost was obtained by the creation of Telegram Premium gift codes to boost a chat. Each + such code boosts the chat 4 times for the duration of the corresponding Telegram Premium + subscription. + + .. versionadded:: 20.8 + + Args: + user (:class:`telegram.User`): User for which the gift code was created. + + Attributes: + source (:obj:`str`): The source of the chat boost. Always + :attr:`~telegram.ChatBoostSource.GIFT_CODE`. + user (:class:`telegram.User`): User for which the gift code was created. + """ + + __slots__ = ("user",) + + def __init__(self, user: User, *, api_kwargs: JSONDict | None = None): + super().__init__(source=self.GIFT_CODE, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.user: User = user + + +class ChatBoostSourceGiveaway(ChatBoostSource): + """ + The boost was obtained by the creation of a Telegram Premium giveaway or a Telegram Star. + This boosts the chat 4 times for the duration of the corresponding Telegram Premium + subscription for Telegram Premium giveaways and :attr:`prize_star_count` / 500 times for + one year for Telegram Star giveaways. + + .. versionadded:: 20.8 + + Args: + giveaway_message_id (:obj:`int`): Identifier of a message in the chat with the giveaway; + the message could have been deleted already. May be 0 if the message isn't sent yet. + user (:class:`telegram.User`, optional): User that won the prize in the giveaway if any; + for Telegram Premium giveaways only. + prize_star_count (:obj:`int`, optional): The number of Telegram Stars to be split between + giveaway winners; for Telegram Star giveaways only. + + .. versionadded:: 21.6 + is_unclaimed (:obj:`bool`, optional): :obj:`True`, if the giveaway was completed, but + there was no user to win the prize. + + Attributes: + source (:obj:`str`): Source of the boost. Always + :attr:`~telegram.ChatBoostSource.GIVEAWAY`. + giveaway_message_id (:obj:`int`): Identifier of a message in the chat with the giveaway; + the message could have been deleted already. May be 0 if the message isn't sent yet. + user (:class:`telegram.User`): Optional. User that won the prize in the giveaway if any. + prize_star_count (:obj:`int`): Optional. The number of Telegram Stars to be split between + giveaway winners; for Telegram Star giveaways only. + + .. versionadded:: 21.6 + is_unclaimed (:obj:`bool`): Optional. :obj:`True`, if the giveaway was completed, but + there was no user to win the prize. + """ + + __slots__ = ("giveaway_message_id", "is_unclaimed", "prize_star_count", "user") + + def __init__( + self, + giveaway_message_id: int, + user: User | None = None, + is_unclaimed: bool | None = None, + prize_star_count: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(source=self.GIVEAWAY, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.giveaway_message_id: int = giveaway_message_id + self.user: User | None = user + self.prize_star_count: int | None = prize_star_count + self.is_unclaimed: bool | None = is_unclaimed + + +class ChatBoost(TelegramObject): + """ + This object contains information about a chat boost. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`boost_id`, :attr:`add_date`, :attr:`expiration_date`, + and :attr:`source` are equal. + + .. versionadded:: 20.8 + + Args: + boost_id (:obj:`str`): Unique identifier of the boost. + add_date (:obj:`datetime.datetime`): Point in time when the chat was boosted. + expiration_date (:obj:`datetime.datetime`): Point in time when the boost + will automatically expire, unless the booster's Telegram Premium subscription is + prolonged. + source (:class:`telegram.ChatBoostSource`): Source of the added boost. + + Attributes: + boost_id (:obj:`str`): Unique identifier of the boost. + add_date (:obj:`datetime.datetime`): Point in time when the chat was boosted. + |datetime_localization| + expiration_date (:obj:`datetime.datetime`): Point in time when the boost + will automatically expire, unless the booster's Telegram Premium subscription is + prolonged. |datetime_localization| + source (:class:`telegram.ChatBoostSource`): Source of the added boost. + """ + + __slots__ = ("add_date", "boost_id", "expiration_date", "source") + + def __init__( + self, + boost_id: str, + add_date: dtm.datetime, + expiration_date: dtm.datetime, + source: ChatBoostSource, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.boost_id: str = boost_id + self.add_date: dtm.datetime = add_date + self.expiration_date: dtm.datetime = expiration_date + self.source: ChatBoostSource = source + + self._id_attrs = (self.boost_id, self.add_date, self.expiration_date, self.source) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatBoost": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["source"] = de_json_optional(data.get("source"), ChatBoostSource, bot) + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["add_date"] = from_timestamp(data.get("add_date"), tzinfo=loc_tzinfo) + data["expiration_date"] = from_timestamp(data.get("expiration_date"), tzinfo=loc_tzinfo) + + return super().de_json(data=data, bot=bot) + + +class ChatBoostUpdated(TelegramObject): + """This object represents a boost added to a chat or changed. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`chat`, and :attr:`boost` are equal. + + .. versionadded:: 20.8 + + Args: + chat (:class:`telegram.Chat`): Chat which was boosted. + boost (:class:`telegram.ChatBoost`): Information about the chat boost. + + Attributes: + chat (:class:`telegram.Chat`): Chat which was boosted. + boost (:class:`telegram.ChatBoost`): Information about the chat boost. + """ + + __slots__ = ("boost", "chat") + + def __init__( + self, + chat: Chat, + boost: ChatBoost, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.chat: Chat = chat + self.boost: ChatBoost = boost + + self._id_attrs = (self.chat.id, self.boost) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatBoostUpdated": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["boost"] = de_json_optional(data.get("boost"), ChatBoost, bot) + + return super().de_json(data=data, bot=bot) + + +class ChatBoostRemoved(TelegramObject): + """ + This object represents a boost removed from a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`chat`, :attr:`boost_id`, :attr:`remove_date`, and + :attr:`source` are equal. + + Args: + chat (:class:`telegram.Chat`): Chat which was boosted. + boost_id (:obj:`str`): Unique identifier of the boost. + remove_date (:obj:`datetime.datetime`): Point in time when the boost was removed. + source (:class:`telegram.ChatBoostSource`): Source of the removed boost. + + Attributes: + chat (:class:`telegram.Chat`): Chat which was boosted. + boost_id (:obj:`str`): Unique identifier of the boost. + remove_date (:obj:`datetime.datetime`): Point in time when the boost was removed. + |datetime_localization| + source (:class:`telegram.ChatBoostSource`): Source of the removed boost. + """ + + __slots__ = ("boost_id", "chat", "remove_date", "source") + + def __init__( + self, + chat: Chat, + boost_id: str, + remove_date: dtm.datetime, + source: ChatBoostSource, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.chat: Chat = chat + self.boost_id: str = boost_id + self.remove_date: dtm.datetime = remove_date + self.source: ChatBoostSource = source + + self._id_attrs = (self.chat, self.boost_id, self.remove_date, self.source) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatBoostRemoved": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["source"] = de_json_optional(data.get("source"), ChatBoostSource, bot) + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["remove_date"] = from_timestamp(data.get("remove_date"), tzinfo=loc_tzinfo) + + return super().de_json(data=data, bot=bot) + + +class UserChatBoosts(TelegramObject): + """This object represents a list of boosts added to a chat by a user. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`boosts` are equal. + + .. versionadded:: 20.8 + + Args: + boosts (Sequence[:class:`telegram.ChatBoost`]): List of boosts added to the chat by the + user. + + Attributes: + boosts (tuple[:class:`telegram.ChatBoost`]): List of boosts added to the chat by the user. + """ + + __slots__ = ("boosts",) + + def __init__( + self, + boosts: Sequence[ChatBoost], + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.boosts: tuple[ChatBoost, ...] = parse_sequence_arg(boosts) + + self._id_attrs = (self.boosts,) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "UserChatBoosts": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["boosts"] = de_list_optional(data.get("boosts"), ChatBoost, bot) + + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_chatfullinfo.py b/src/telegram/_chatfullinfo.py new file mode 100644 index 00000000000..2225ab3cd47 --- /dev/null +++ b/src/telegram/_chatfullinfo.py @@ -0,0 +1,660 @@ +#!/usr/bin/env python +# pylint: disable=redefined-builtin +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 ChatFullInfo.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from telegram._birthdate import Birthdate +from telegram._chat import Chat, _ChatBase +from telegram._chatlocation import ChatLocation +from telegram._chatpermissions import ChatPermissions +from telegram._files.chatphoto import ChatPhoto +from telegram._gifts import AcceptedGiftTypes +from telegram._reaction import ReactionType +from telegram._uniquegift import UniqueGiftColors +from telegram._userrating import UserRating +from telegram._utils.argumentparsing import ( + de_json_optional, + de_list_optional, + parse_sequence_arg, + to_timedelta, +) +from telegram._utils.datetime import ( + extract_tzinfo_from_defaults, + from_timestamp, + get_timedelta_value, +) +from telegram._utils.types import JSONDict, TimePeriod + +if TYPE_CHECKING: + from telegram import Bot, BusinessIntro, BusinessLocation, BusinessOpeningHours, Message + + +class ChatFullInfo(_ChatBase): + """ + This object contains full information about a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`~telegram.Chat.id` is equal. + + .. versionadded:: 21.2 + + .. versionchanged:: 21.3 + Explicit support for all shortcut methods known from :class:`telegram.Chat` on this + object. Previously those were only available because this class inherited from + :class:`telegram.Chat`. + + .. versionremoved:: 22.3 + Removed argument and attribute ``can_send_gift`` deprecated by API 9.0. + + Args: + id (:obj:`int`): Unique identifier for this chat. + type (:obj:`str`): Type of chat, can be either :attr:`PRIVATE`, :attr:`GROUP`, + :attr:`SUPERGROUP` or :attr:`CHANNEL`. + accent_color_id (:obj:`int`, optional): Identifier of the + :class:`accent color ` for the chat name and + backgrounds of the chat photo, reply header, and link preview. See `accent colors`_ + for more details. + + .. versionadded:: 20.8 + max_reaction_count (:obj:`int`): The maximum number of reactions that can be set on a + message in the chat. + + .. versionadded:: 21.2 + accepted_gift_types (:class:`telegram.AcceptedGiftTypes`): Information about types of + gifts that are accepted by the chat or by the corresponding user for private chats. + + .. versionadded:: 22.1 + title (:obj:`str`, optional): Title, for supergroups, channels and group chats. + username (:obj:`str`, optional): Username, for private chats, supergroups and channels if + available. + first_name (:obj:`str`, optional): First name of the other party in a private chat. + last_name (:obj:`str`, optional): Last name of the other party in a private chat. + is_forum (:obj:`bool`, optional): :obj:`True`, if the supergroup chat is a forum + (has topics_ enabled). + + .. versionadded:: 20.0 + photo (:class:`telegram.ChatPhoto`, optional): Chat photo. + active_usernames (Sequence[:obj:`str`], optional): If set, the list of all `active chat + usernames `_; for private chats, supergroups and channels. + + .. versionadded:: 20.0 + birthdate (:class:`telegram.Birthdate`, optional): For private chats, + the date of birth of the user. + + .. versionadded:: 21.1 + business_intro (:class:`telegram.BusinessIntro`, optional): For private chats with + business accounts, the intro of the business. + + .. versionadded:: 21.1 + business_location (:class:`telegram.BusinessLocation`, optional): For private chats with + business accounts, the location of the business. + + .. versionadded:: 21.1 + business_opening_hours (:class:`telegram.BusinessOpeningHours`, optional): For private + chats with business accounts, the opening hours of the business. + + .. versionadded:: 21.1 + personal_chat (:class:`telegram.Chat`, optional): For private chats, the personal channel + of the user. + + .. versionadded:: 21.1 + available_reactions (Sequence[:class:`telegram.ReactionType`], optional): List of available + reactions allowed in the chat. If omitted, then all of + :const:`telegram.constants.ReactionEmoji` are allowed. + + .. versionadded:: 20.8 + background_custom_emoji_id (:obj:`str`, optional): Custom emoji identifier of emoji chosen + by the chat for the reply header and link preview background. + + .. versionadded:: 20.8 + profile_accent_color_id (:obj:`int`, optional): Identifier of the + :class:`accent color ` for the chat's profile + background. See profile `accent colors`_ for more details. + + .. versionadded:: 20.8 + profile_background_custom_emoji_id (:obj:`str`, optional): Custom emoji identifier of + the emoji chosen by the chat for its profile background. + + .. versionadded:: 20.8 + emoji_status_custom_emoji_id (:obj:`str`, optional): Custom emoji identifier of emoji + status of the chat or the other party in a private chat. + + .. 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, as a datetime object, + if any. + + |datetime_localization| + + .. versionadded:: 20.5 + bio (:obj:`str`, optional): Bio of the other party in a private chat. + has_private_forwards (:obj:`bool`, optional): :obj:`True`, if privacy settings of the other + party in the private chat allows to use ``tg://user?id=`` links only in chats + with the user. + + .. versionadded:: 13.9 + has_restricted_voice_and_video_messages (:obj:`bool`, optional): :obj:`True`, if the + privacy settings of the other party restrict sending voice and video note messages + in the private chat. + + .. versionadded:: 20.0 + join_to_send_messages (:obj:`bool`, optional): :obj:`True`, if users need to join the + supergroup before they can send messages. + + .. versionadded:: 20.0 + join_by_request (:obj:`bool`, optional): :obj:`True`, if all users directly joining the + supergroup without using an invite link need to be approved by supergroup + administrators. + + .. versionadded:: 20.0 + description (:obj:`str`, optional): Description, for groups, supergroups and channel chats. + invite_link (:obj:`str`, optional): Primary invite link, for groups, supergroups and + channel. + pinned_message (:class:`telegram.Message`, optional): The most recent pinned message + (by sending date). + permissions (:class:`telegram.ChatPermissions`): Optional. Default chat member permissions, + for groups and supergroups. + slow_mode_delay (:obj:`int` | :class:`datetime.timedelta`, optional): For supergroups, + the minimum allowed delay between consecutive messages sent by each unprivileged user. + + .. versionchanged:: v22.2 + |time-period-input| + unrestrict_boost_count (:obj:`int`, optional): For supergroups, the minimum number of + boosts that a non-administrator user needs to add in order to ignore slow mode and chat + permissions. + + .. versionadded:: 21.0 + message_auto_delete_time (:obj:`int` | :class:`datetime.timedelta`, optional): The time + after which all messages sent to the chat will be automatically deleted; in seconds. + + .. versionadded:: 13.4 + + .. versionchanged:: v22.2 + |time-period-input| + has_aggressive_anti_spam_enabled (:obj:`bool`, optional): :obj:`True`, if aggressive + anti-spam checks are enabled in the supergroup. The field is only available to chat + administrators. + + .. versionadded:: 20.0 + has_hidden_members (:obj:`bool`, optional): :obj:`True`, if non-administrators can only + get the list of bots and administrators in the chat. + + .. versionadded:: 20.0 + has_protected_content (:obj:`bool`, optional): :obj:`True`, if messages from the chat can't + be forwarded to other chats. + + .. versionadded:: 13.9 + has_visible_history (:obj:`bool`, optional): :obj:`True`, if new chat members will have + access to old messages; available only to chat administrators. + + .. versionadded:: 20.8 + sticker_set_name (:obj:`str`, optional): For supergroups, name of group sticker set. + can_set_sticker_set (:obj:`bool`, optional): :obj:`True`, if the bot can change group the + sticker set. + custom_emoji_sticker_set_name (:obj:`str`, optional): For supergroups, the name of the + group's custom emoji sticker set. Custom emoji from this set can be used by all users + and bots in the group. + + .. versionadded:: 21.0 + linked_chat_id (:obj:`int`, optional): Unique identifier for the linked chat, i.e. the + discussion group identifier for a channel and vice versa; for supergroups and channel + 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 + is_direct_messages (:obj:`bool`, optional): :obj:`True`, if the chat is the direct messages + chat of a channel. + + .. versionadded:: 22.4 + parent_chat (:obj:`telegram.Chat`, optional): Information about the corresponding channel + chat; for direct messages chats only. + + .. versionadded:: 22.4 + rating (:class:`telegram.UserRating`, optional): For private chats, the rating of the user + if any. + + .. versionadded:: 22.6 + unique_gift_colors (:class:`telegram.UniqueGiftColors`, optional): The color scheme based + on a unique gift that must be used for the chat's name, message replies and link + previews + + .. versionadded:: 22.6 + paid_message_star_count (:obj:`int`, optional): The number of Telegram Stars a general user + have to pay to send a message to the chat + + .. versionadded:: 22.6 + + Attributes: + id (:obj:`int`): Unique identifier for this chat. + type (:obj:`str`): Type of chat, can be either :attr:`PRIVATE`, :attr:`GROUP`, + :attr:`SUPERGROUP` or :attr:`CHANNEL`. + accent_color_id (:obj:`int`): Optional. Identifier of the + :class:`accent color ` for the chat name and + backgrounds of the chat photo, reply header, and link preview. See `accent colors`_ + for more details. + + .. versionadded:: 20.8 + max_reaction_count (:obj:`int`): The maximum number of reactions that can be set on a + message in the chat. + + .. versionadded:: 21.2 + accepted_gift_types (:class:`telegram.AcceptedGiftTypes`): Information about types of + gifts that are accepted by the chat or by the corresponding user for private chats. + + .. versionadded:: 22.1 + title (:obj:`str`, optional): Title, for supergroups, channels and group chats. + username (:obj:`str`, optional): Username, for private chats, supergroups and channels if + available. + first_name (:obj:`str`, optional): First name of the other party in a private chat. + last_name (:obj:`str`, optional): Last name of the other party in a private chat. + is_forum (:obj:`bool`, optional): :obj:`True`, if the supergroup chat is a forum + (has topics_ enabled). + + .. versionadded:: 20.0 + photo (:class:`telegram.ChatPhoto`): Optional. Chat photo. + active_usernames (tuple[:obj:`str`]): Optional. If set, the list of all `active chat + usernames `_; for private chats, supergroups and channels. + + This list is empty if the chat has no active usernames or this chat instance was not + obtained via :meth:`~telegram.Bot.get_chat`. + + .. versionadded:: 20.0 + birthdate (:class:`telegram.Birthdate`): Optional. For private chats, + the date of birth of the user. + + .. versionadded:: 21.1 + business_intro (:class:`telegram.BusinessIntro`): Optional. For private chats with + business accounts, the intro of the business. + + .. versionadded:: 21.1 + business_location (:class:`telegram.BusinessLocation`): Optional. For private chats with + business accounts, the location of the business. + + .. versionadded:: 21.1 + business_opening_hours (:class:`telegram.BusinessOpeningHours`): Optional. For private + chats with business accounts, the opening hours of the business. + + .. versionadded:: 21.1 + personal_chat (:class:`telegram.Chat`): Optional. For private chats, the personal channel + of the user. + + .. versionadded:: 21.1 + available_reactions (tuple[:class:`telegram.ReactionType`]): Optional. List of available + reactions allowed in the chat. If omitted, then all of + :const:`telegram.constants.ReactionEmoji` are allowed. + + .. versionadded:: 20.8 + background_custom_emoji_id (:obj:`str`): Optional. Custom emoji identifier of emoji chosen + by the chat for the reply header and link preview background. + + .. versionadded:: 20.8 + profile_accent_color_id (:obj:`int`): Optional. Identifier of the + :class:`accent color ` for the chat's profile + background. See profile `accent colors`_ for more details. + + .. versionadded:: 20.8 + profile_background_custom_emoji_id (:obj:`str`): Optional. Custom emoji identifier of + the emoji chosen by the chat for its profile background. + + .. versionadded:: 20.8 + emoji_status_custom_emoji_id (:obj:`str`): Optional. Custom emoji identifier of emoji + status of the chat or the other party in a private chat. + + .. 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, as a datetime object, + if any. + + |datetime_localization| + + .. versionadded:: 20.5 + bio (:obj:`str`): Optional. Bio of the other party in a private chat. + has_private_forwards (:obj:`bool`): Optional. :obj:`True`, if privacy settings of the other + party in the private chat allows to use ``tg://user?id=`` links only in chats + with the user. + + .. versionadded:: 13.9 + has_restricted_voice_and_video_messages (:obj:`bool`): Optional. :obj:`True`, if the + privacy settings of the other party restrict sending voice and video note messages + in the private chat. + + .. versionadded:: 20.0 + join_to_send_messages (:obj:`bool`): Optional. :obj:`True`, if users need to join + the supergroup before they can send messages. + + .. versionadded:: 20.0 + join_by_request (:obj:`bool`): Optional. :obj:`True`, if all users directly joining the + supergroup without using an invite link need to be approved by supergroup + administrators. + + .. versionadded:: 20.0 + description (:obj:`str`): Optional. Description, for groups, supergroups and channel chats. + invite_link (:obj:`str`): Optional. Primary invite link, for groups, supergroups and + channel. + pinned_message (:class:`telegram.Message`): Optional. The most recent pinned message + (by sending date). + permissions (:class:`telegram.ChatPermissions`): Optional. Default chat member permissions, + for groups and supergroups. + slow_mode_delay (:obj:`int` | :class:`datetime.timedelta`): Optional. For supergroups, + the minimum allowed delay between consecutive messages sent by each unprivileged user. + + .. deprecated:: v22.2 + |time-period-int-deprecated| + unrestrict_boost_count (:obj:`int`): Optional. For supergroups, the minimum number of + boosts that a non-administrator user needs to add in order to ignore slow mode and chat + permissions. + + .. versionadded:: 21.0 + message_auto_delete_time (:obj:`int` | :class:`datetime.timedelta`): Optional. The time + after which all messages sent to the chat will be automatically deleted; in seconds. + + .. versionadded:: 13.4 + + .. deprecated:: v22.2 + |time-period-int-deprecated| + has_aggressive_anti_spam_enabled (:obj:`bool`): Optional. :obj:`True`, if aggressive + anti-spam checks are enabled in the supergroup. The field is only available to chat + administrators. + + .. versionadded:: 20.0 + has_hidden_members (:obj:`bool`): Optional. :obj:`True`, if non-administrators can only + get the list of bots and administrators in the chat. + + .. versionadded:: 20.0 + has_protected_content (:obj:`bool`): Optional. :obj:`True`, if messages from the chat can't + be forwarded to other chats. + + .. versionadded:: 13.9 + has_visible_history (:obj:`bool`): Optional. :obj:`True`, if new chat members will have + access to old messages; available only to chat administrators. + + .. versionadded:: 20.8 + sticker_set_name (:obj:`str`): Optional. For supergroups, name of Group sticker set. + can_set_sticker_set (:obj:`bool`): Optional. :obj:`True`, if the bot can change group the + sticker set. + custom_emoji_sticker_set_name (:obj:`str`): Optional. For supergroups, the name of the + group's custom emoji sticker set. Custom emoji from this set can be used by all users + and bots in the group. + + .. versionadded:: 21.0 + linked_chat_id (:obj:`int`): Optional. Unique identifier for the linked chat, i.e. the + discussion group identifier for a channel and vice versa; for supergroups and channel + 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 + is_direct_messages (:obj:`bool`): Optional. :obj:`True`, if the chat is the direct messages + chat of a channel. + + .. versionadded:: 22.4 + parent_chat (:obj:`telegram.Chat`): Optional. Information about the corresponding channel + chat; for direct messages chats only. + + .. versionadded:: 22.4 + rating (:class:`telegram.UserRating`): Optional. For private chats, the rating of the user + if any. + + .. versionadded:: 22.6 + unique_gift_colors (:class:`telegram.UniqueGiftColors`): Optional. The color scheme based + on a unique gift that must be used for the chat's name, message replies and link + previews + + .. versionadded:: 22.6 + paid_message_star_count (:obj:`int`): Optional. The number of Telegram Stars a general user + have to pay to send a message to the chat + + .. versionadded:: 22.6 + + .. _accent colors: https://core.telegram.org/bots/api#accent-colors + .. _topics: https://telegram.org/blog/topics-in-groups-collectible-usernames#topics-in-groups + """ + + __slots__ = ( + "_message_auto_delete_time", + "_slow_mode_delay", + "accent_color_id", + "accepted_gift_types", + "active_usernames", + "available_reactions", + "background_custom_emoji_id", + "bio", + "birthdate", + "business_intro", + "business_location", + "business_opening_hours", + "can_send_paid_media", + "can_set_sticker_set", + "custom_emoji_sticker_set_name", + "description", + "emoji_status_custom_emoji_id", + "emoji_status_expiration_date", + "has_aggressive_anti_spam_enabled", + "has_hidden_members", + "has_private_forwards", + "has_protected_content", + "has_restricted_voice_and_video_messages", + "has_visible_history", + "invite_link", + "join_by_request", + "join_to_send_messages", + "linked_chat_id", + "location", + "max_reaction_count", + "paid_message_star_count", + "parent_chat", + "permissions", + "personal_chat", + "photo", + "pinned_message", + "profile_accent_color_id", + "profile_background_custom_emoji_id", + "rating", + "sticker_set_name", + "unique_gift_colors", + "unrestrict_boost_count", + ) + + def __init__( + self, + id: int, + type: str, + accent_color_id: int, + max_reaction_count: int, + accepted_gift_types: AcceptedGiftTypes, + title: str | None = None, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + is_forum: bool | None = None, + photo: ChatPhoto | None = None, + active_usernames: Sequence[str] | None = None, + birthdate: Birthdate | None = None, + business_intro: "BusinessIntro | None" = None, + business_location: "BusinessLocation | None" = None, + business_opening_hours: "BusinessOpeningHours | None" = None, + personal_chat: "Chat | None" = None, + available_reactions: Sequence[ReactionType] | None = None, + background_custom_emoji_id: str | None = None, + profile_accent_color_id: int | None = None, + profile_background_custom_emoji_id: str | None = None, + emoji_status_custom_emoji_id: str | None = None, + emoji_status_expiration_date: dtm.datetime | None = None, + bio: str | None = None, + has_private_forwards: bool | None = None, + has_restricted_voice_and_video_messages: bool | None = None, + join_to_send_messages: bool | None = None, + join_by_request: bool | None = None, + description: str | None = None, + invite_link: str | None = None, + pinned_message: "Message | None" = None, + permissions: ChatPermissions | None = None, + slow_mode_delay: TimePeriod | None = None, + unrestrict_boost_count: int | None = None, + message_auto_delete_time: TimePeriod | None = None, + has_aggressive_anti_spam_enabled: bool | None = None, + has_hidden_members: bool | None = None, + has_protected_content: bool | None = None, + has_visible_history: bool | None = None, + sticker_set_name: str | None = None, + can_set_sticker_set: bool | None = None, + custom_emoji_sticker_set_name: str | None = None, + linked_chat_id: int | None = None, + location: ChatLocation | None = None, + can_send_paid_media: bool | None = None, + is_direct_messages: bool | None = None, + parent_chat: Chat | None = None, + rating: UserRating | None = None, + unique_gift_colors: UniqueGiftColors | None = None, + paid_message_star_count: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__( + id=id, + type=type, + title=title, + username=username, + first_name=first_name, + last_name=last_name, + is_forum=is_forum, + is_direct_messages=is_direct_messages, + api_kwargs=api_kwargs, + ) + # Required and unique to this class- + with self._unfrozen(): + self.max_reaction_count: int = max_reaction_count + self.photo: ChatPhoto | None = photo + self.bio: str | None = bio + self.has_private_forwards: bool | None = has_private_forwards + self.description: str | None = description + self.invite_link: str | None = invite_link + self.pinned_message: Message | None = pinned_message + self.permissions: ChatPermissions | None = permissions + self._slow_mode_delay: dtm.timedelta | None = to_timedelta(slow_mode_delay) + self._message_auto_delete_time: dtm.timedelta | None = to_timedelta( + message_auto_delete_time + ) + self.has_protected_content: bool | None = has_protected_content + self.has_visible_history: bool | None = has_visible_history + self.sticker_set_name: str | None = sticker_set_name + self.can_set_sticker_set: bool | None = can_set_sticker_set + self.linked_chat_id: int | None = linked_chat_id + self.location: ChatLocation | None = location + self.join_to_send_messages: bool | None = join_to_send_messages + self.join_by_request: bool | None = join_by_request + self.has_restricted_voice_and_video_messages: bool | None = ( + has_restricted_voice_and_video_messages + ) + self.active_usernames: tuple[str, ...] = parse_sequence_arg(active_usernames) + self.emoji_status_custom_emoji_id: str | None = emoji_status_custom_emoji_id + self.emoji_status_expiration_date: dtm.datetime | None = emoji_status_expiration_date + self.has_aggressive_anti_spam_enabled: bool | None = has_aggressive_anti_spam_enabled + self.has_hidden_members: bool | None = has_hidden_members + self.available_reactions: tuple[ReactionType, ...] | None = parse_sequence_arg( + available_reactions + ) + self.accent_color_id: int | None = accent_color_id + self.background_custom_emoji_id: str | None = background_custom_emoji_id + self.profile_accent_color_id: int | None = profile_accent_color_id + self.profile_background_custom_emoji_id: str | None = ( + profile_background_custom_emoji_id + ) + self.unrestrict_boost_count: int | None = unrestrict_boost_count + self.custom_emoji_sticker_set_name: str | None = custom_emoji_sticker_set_name + self.birthdate: Birthdate | None = birthdate + self.personal_chat: Chat | None = personal_chat + self.business_intro: BusinessIntro | None = business_intro + self.business_location: BusinessLocation | None = business_location + self.business_opening_hours: BusinessOpeningHours | None = business_opening_hours + self.can_send_paid_media: bool | None = can_send_paid_media + self.accepted_gift_types: AcceptedGiftTypes = accepted_gift_types + self.parent_chat: Chat | None = parent_chat + self.rating: UserRating | None = rating + self.unique_gift_colors: UniqueGiftColors | None = unique_gift_colors + self.paid_message_star_count: int | None = paid_message_star_count + + @property + def slow_mode_delay(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._slow_mode_delay, attribute="slow_mode_delay") + + @property + def message_auto_delete_time(self) -> int | dtm.timedelta | None: + return get_timedelta_value( + self._message_auto_delete_time, attribute="message_auto_delete_time" + ) + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatFullInfo": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["emoji_status_expiration_date"] = from_timestamp( + data.get("emoji_status_expiration_date"), tzinfo=loc_tzinfo + ) + + data["photo"] = de_json_optional(data.get("photo"), ChatPhoto, bot) + data["accepted_gift_types"] = de_json_optional( + data.get("accepted_gift_types"), AcceptedGiftTypes, bot + ) + + from telegram import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + BusinessIntro, + BusinessLocation, + BusinessOpeningHours, + Message, + ) + + data["pinned_message"] = de_json_optional(data.get("pinned_message"), Message, bot) + data["permissions"] = de_json_optional(data.get("permissions"), ChatPermissions, bot) + data["location"] = de_json_optional(data.get("location"), ChatLocation, bot) + data["available_reactions"] = de_list_optional( + data.get("available_reactions"), ReactionType, bot + ) + data["birthdate"] = de_json_optional(data.get("birthdate"), Birthdate, bot) + data["personal_chat"] = de_json_optional(data.get("personal_chat"), Chat, bot) + data["business_intro"] = de_json_optional(data.get("business_intro"), BusinessIntro, bot) + data["business_location"] = de_json_optional( + data.get("business_location"), BusinessLocation, bot + ) + data["business_opening_hours"] = de_json_optional( + data.get("business_opening_hours"), BusinessOpeningHours, bot + ) + data["parent_chat"] = de_json_optional(data.get("parent_chat"), Chat, bot) + + data["rating"] = de_json_optional(data.get("rating"), UserRating, bot) + data["unique_gift_colors"] = de_json_optional( + data.get("unique_gift_colors"), UniqueGiftColors, bot + ) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_chatinvitelink.py b/src/telegram/_chatinvitelink.py similarity index 70% rename from telegram/_chatinvitelink.py rename to src/telegram/_chatinvitelink.py index 6b9789ac739..880349fd0fb 100644 --- a/telegram/_chatinvitelink.py +++ b/src/telegram/_chatinvitelink.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,13 +17,19 @@ # 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 an invite link for a chat.""" -import datetime -from typing import TYPE_CHECKING, Optional + +import datetime as dtm +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._user import User -from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import de_json_optional, to_timedelta +from telegram._utils.datetime import ( + extract_tzinfo_from_defaults, + from_timestamp, + get_timedelta_value, +) +from telegram._utils.types import JSONDict, TimePeriod if TYPE_CHECKING: from telegram import Bot @@ -69,6 +75,19 @@ class ChatInviteLink(TelegramObject): created using this link. .. versionadded:: 13.8 + subscription_period (:obj:`int` | :class:`datetime.timedelta`, optional): The number of + seconds the subscription will be active for before the next payment. + + .. versionadded:: 21.5 + + .. versionchanged:: v22.2 + |time-period-input| + subscription_price (:obj:`int`, optional): The amount of Telegram Stars a user must pay + initially and after each subsequent subscription period to be a member of the chat + using the link. + + .. versionadded:: 21.5 + Attributes: invite_link (:obj:`str`): The invite link. If the link was created by another chat administrator, then the second part of the link will be replaced with ``'…'``. @@ -96,19 +115,33 @@ class ChatInviteLink(TelegramObject): created using this link. .. versionadded:: 13.8 + subscription_period (:obj:`int` | :class:`datetime.timedelta`): Optional. The number of + seconds the subscription will be active for before the next payment. + + .. versionadded:: 21.5 + + .. deprecated:: v22.2 + |time-period-int-deprecated| + subscription_price (:obj:`int`): Optional. The amount of Telegram Stars a user must pay + initially and after each subsequent subscription period to be a member of the chat + using the link. + + .. versionadded:: 21.5 """ __slots__ = ( - "invite_link", + "_subscription_period", + "creates_join_request", "creator", + "expire_date", + "invite_link", "is_primary", "is_revoked", - "expire_date", "member_limit", "name", - "creates_join_request", "pending_join_request_count", + "subscription_price", ) def __init__( @@ -118,12 +151,14 @@ def __init__( creates_join_request: bool, is_primary: bool, is_revoked: bool, - expire_date: Optional[datetime.datetime] = None, - member_limit: Optional[int] = None, - name: Optional[str] = None, - pending_join_request_count: Optional[int] = None, + expire_date: dtm.datetime | None = None, + member_limit: int | None = None, + name: str | None = None, + pending_join_request_count: int | None = None, + subscription_period: TimePeriod | None = None, + subscription_price: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -134,12 +169,15 @@ def __init__( self.is_revoked: bool = is_revoked # Optionals - self.expire_date: Optional[datetime.datetime] = expire_date - self.member_limit: Optional[int] = member_limit - self.name: Optional[str] = name - self.pending_join_request_count: Optional[int] = ( + self.expire_date: dtm.datetime | None = expire_date + self.member_limit: int | None = member_limit + self.name: str | None = name + self.pending_join_request_count: int | None = ( int(pending_join_request_count) if pending_join_request_count is not None else None ) + self._subscription_period: dtm.timedelta | None = to_timedelta(subscription_period) + self.subscription_price: int | None = subscription_price + self._id_attrs = ( self.invite_link, self.creates_join_request, @@ -150,18 +188,19 @@ def __init__( self._freeze() + @property + def subscription_period(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._subscription_period, attribute="subscription_period") + @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatInviteLink"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatInviteLink": """See :meth:`telegram.TelegramObject.de_json`.""" 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["creator"] = User.de_json(data.get("creator"), bot) + data["creator"] = de_json_optional(data.get("creator"), User, bot) data["expire_date"] = from_timestamp(data.get("expire_date", None), tzinfo=loc_tzinfo) return super().de_json(data=data, bot=bot) diff --git a/telegram/_chatjoinrequest.py b/src/telegram/_chatjoinrequest.py similarity index 84% rename from telegram/_chatjoinrequest.py rename to src/telegram/_chatjoinrequest.py index 5c70f230c0a..75c2087d794 100644 --- a/telegram/_chatjoinrequest.py +++ b/src/telegram/_chatjoinrequest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,13 +17,15 @@ # 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 ChatJoinRequest.""" -import datetime -from typing import TYPE_CHECKING, Optional + +import datetime as dtm +from typing import TYPE_CHECKING from telegram._chat import Chat from telegram._chatinvitelink import ChatInviteLink from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput @@ -63,7 +65,7 @@ class ChatJoinRequest(TelegramObject): request. This number may have more than 32 significant bits and some programming languages may have difficulty/silent defects in interpreting it. But it has at most 52 significant bits, so a 64-bit integer or double-precision float type are safe for - storing this identifier. The bot can use this identifier for 24 hours to send messages + storing this identifier. The bot can use this identifier for 5 minutes to send messages until the join request is processed, assuming no other administrator contacted the user. @@ -83,7 +85,7 @@ class ChatJoinRequest(TelegramObject): request. This number may have more than 32 significant bits and some programming languages may have difficulty/silent defects in interpreting it. But it has at most 52 significant bits, so a 64-bit integer or double-precision float type are safe for - storing this identifier. The bot can use this identifier for 24 hours to send messages + storing this identifier. The bot can use this identifier for 5 minutes to send messages until the join request is processed, assuming no other administrator contacted the user. @@ -92,51 +94,54 @@ class ChatJoinRequest(TelegramObject): invite_link (:class:`telegram.ChatInviteLink`): Optional. Chat invite link that was used by the user to send the join request. + Note: + When a user joins a *public* group via an invite link, this attribute may not + be present. However, this behavior is undocument and may be subject to change. + See `this GitHub thread `_ + for some discussion. + """ - __slots__ = ("chat", "from_user", "date", "bio", "invite_link", "user_chat_id") + __slots__ = ("bio", "chat", "date", "from_user", "invite_link", "user_chat_id") def __init__( self, chat: Chat, from_user: User, - date: datetime.datetime, + date: dtm.datetime, user_chat_id: int, - bio: Optional[str] = None, - invite_link: Optional[ChatInviteLink] = None, + bio: str | None = None, + invite_link: ChatInviteLink | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.chat: Chat = chat self.from_user: User = from_user - self.date: datetime.datetime = date + self.date: dtm.datetime = date self.user_chat_id: int = user_chat_id # Optionals - self.bio: Optional[str] = bio - self.invite_link: Optional[ChatInviteLink] = invite_link + self.bio: str | None = bio + self.invite_link: ChatInviteLink | None = invite_link self._id_attrs = (self.chat, self.from_user, self.date) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatJoinRequest"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatJoinRequest": """See :meth:`telegram.TelegramObject.de_json`.""" 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["chat"] = Chat.de_json(data.get("chat"), bot) - data["from_user"] = User.de_json(data.pop("from", None), bot) + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["from_user"] = de_json_optional(data.pop("from", None), User, bot) data["date"] = from_timestamp(data.get("date", None), tzinfo=loc_tzinfo) - data["invite_link"] = ChatInviteLink.de_json(data.get("invite_link"), bot) + data["invite_link"] = de_json_optional(data.get("invite_link"), ChatInviteLink, bot) return super().de_json(data=data, bot=bot) @@ -147,7 +152,7 @@ async def approve( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -179,7 +184,7 @@ async def decline( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: diff --git a/telegram/_chatlocation.py b/src/telegram/_chatlocation.py similarity index 83% rename from telegram/_chatlocation.py rename to src/telegram/_chatlocation.py index 1811ecb706d..b93cfc8a624 100644 --- a/telegram/_chatlocation.py +++ b/src/telegram/_chatlocation.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,11 +18,12 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a location to which a chat is connected.""" -from typing import TYPE_CHECKING, ClassVar, Optional +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._files.location import Location from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -50,14 +51,14 @@ class ChatLocation(TelegramObject): """ - __slots__ = ("location", "address") + __slots__ = ("address", "location") def __init__( self, location: Location, address: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.location: Location = location @@ -68,23 +69,20 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatLocation"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatLocation": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["location"] = Location.de_json(data.get("location"), bot) + data["location"] = de_json_optional(data.get("location"), Location, bot) return super().de_json(data=data, bot=bot) - MIN_ADDRESS: ClassVar[int] = constants.LocationLimit.MIN_CHAT_LOCATION_ADDRESS + MIN_ADDRESS: Final[int] = constants.LocationLimit.MIN_CHAT_LOCATION_ADDRESS """:const:`telegram.constants.LocationLimit.MIN_CHAT_LOCATION_ADDRESS` .. versionadded:: 20.0 """ - MAX_ADDRESS: ClassVar[int] = constants.LocationLimit.MAX_CHAT_LOCATION_ADDRESS + MAX_ADDRESS: Final[int] = constants.LocationLimit.MAX_CHAT_LOCATION_ADDRESS """:const:`telegram.constants.LocationLimit.MAX_CHAT_LOCATION_ADDRESS` .. versionadded:: 20.0 diff --git a/telegram/_chatmember.py b/src/telegram/_chatmember.py similarity index 75% rename from telegram/_chatmember.py rename to src/telegram/_chatmember.py index e0dbf763ce4..42323ea2cfd 100644 --- a/telegram/_chatmember.py +++ b/src/telegram/_chatmember.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,12 +17,15 @@ # 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 ChatMember.""" -import datetime -from typing import TYPE_CHECKING, ClassVar, Dict, Optional, Type + +import datetime as dtm +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.types import JSONDict @@ -72,19 +75,19 @@ class ChatMember(TelegramObject): """ - __slots__ = ("user", "status") + __slots__ = ("status", "user") - ADMINISTRATOR: ClassVar[str] = constants.ChatMemberStatus.ADMINISTRATOR + ADMINISTRATOR: Final[str] = constants.ChatMemberStatus.ADMINISTRATOR """:const:`telegram.constants.ChatMemberStatus.ADMINISTRATOR`""" - OWNER: ClassVar[str] = constants.ChatMemberStatus.OWNER + OWNER: Final[str] = constants.ChatMemberStatus.OWNER """:const:`telegram.constants.ChatMemberStatus.OWNER`""" - BANNED: ClassVar[str] = constants.ChatMemberStatus.BANNED + BANNED: Final[str] = constants.ChatMemberStatus.BANNED """:const:`telegram.constants.ChatMemberStatus.BANNED`""" - LEFT: ClassVar[str] = constants.ChatMemberStatus.LEFT + LEFT: Final[str] = constants.ChatMemberStatus.LEFT """:const:`telegram.constants.ChatMemberStatus.LEFT`""" - MEMBER: ClassVar[str] = constants.ChatMemberStatus.MEMBER + MEMBER: Final[str] = constants.ChatMemberStatus.MEMBER """:const:`telegram.constants.ChatMemberStatus.MEMBER`""" - RESTRICTED: ClassVar[str] = constants.ChatMemberStatus.RESTRICTED + RESTRICTED: Final[str] = constants.ChatMemberStatus.RESTRICTED """:const:`telegram.constants.ChatMemberStatus.RESTRICTED`""" def __init__( @@ -92,26 +95,23 @@ def __init__( user: User, status: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required by all subclasses self.user: User = user - self.status: str = status + self.status: str = enum.get_member(constants.ChatMemberStatus, status, status) self._id_attrs = (self.user, self.status) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatMember"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatMember": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - _class_mapping: Dict[str, Type["ChatMember"]] = { + _class_mapping: dict[str, type[ChatMember]] = { cls.OWNER: ChatMemberOwner, cls.ADMINISTRATOR: ChatMemberAdministrator, cls.MEMBER: ChatMemberMember, @@ -123,12 +123,18 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatMember"] if cls is ChatMember and data.get("status") in _class_mapping: return _class_mapping[data.pop("status")].de_json(data=data, bot=bot) - data["user"] = User.de_json(data.get("user"), bot) + data["user"] = de_json_optional(data.get("user"), User, bot) if "until_date" in data: # Get the local timezone from the bot if it has defaults loc_tzinfo = extract_tzinfo_from_defaults(bot) - data["until_date"] = from_timestamp(data["until_date"], tzinfo=loc_tzinfo) + data["until_date"] = from_timestamp(data.get("until_date"), tzinfo=loc_tzinfo) + + # This is a deprecated field that TG still returns for backwards compatibility + # Let's filter it out to speed up the de-json process + if cls is ChatMemberRestricted and data.get("can_send_media_messages") is not None: + api_kwargs = {"can_send_media_messages": data.pop("can_send_media_messages")} + return super()._de_json(data=data, bot=bot, api_kwargs=api_kwargs) return super().de_json(data=data, bot=bot) @@ -156,20 +162,20 @@ class ChatMemberOwner(ChatMember): this user. """ - __slots__ = ("is_anonymous", "custom_title") + __slots__ = ("custom_title", "is_anonymous") def __init__( self, user: User, is_anonymous: bool, - custom_title: Optional[str] = None, + custom_title: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(status=ChatMember.OWNER, user=user, api_kwargs=api_kwargs) with self._unfrozen(): self.is_anonymous: bool = is_anonymous - self.custom_title: Optional[str] = custom_title + self.custom_title: str | None = custom_title class ChatMemberAdministrator(ChatMember): @@ -185,15 +191,19 @@ class ChatMemberAdministrator(ChatMember): * The argument :paramref:`can_manage_topics` was added, which changes the position of the optional argument :paramref:`custom_title`. + .. versionchanged:: 21.1 + As of this version, :attr:`can_post_stories`, :attr:`can_edit_stories`, + and :attr:`can_delete_stories` is now required. Thus, the order of arguments had to be + changed. + Args: user (:class:`telegram.User`): Information about the user. can_be_edited (:obj:`bool`): :obj:`True`, if the bot is allowed to edit administrator privileges of that user. is_anonymous (:obj:`bool`): :obj:`True`, if the user's presence in the chat is hidden. - can_manage_chat (:obj:`bool`): :obj:`True`, if the administrator - can access the chat event log, chat statistics, message statistics in - channels, see channel members, see anonymous administrators in supergroups + can_manage_chat (:obj:`bool`): :obj:`True`, if the administrator can access the chat event + log, get boost list, see hidden supergroup and channel members, report spam messages and ignore slow mode. Implied by any other administrator privilege. can_delete_messages (:obj:`bool`): :obj:`True`, if the administrator can delete messages of other users. @@ -212,17 +222,41 @@ class ChatMemberAdministrator(ChatMember): can_invite_users (:obj:`bool`): :obj:`True`, if the user can invite new users to the chat. can_post_messages (:obj:`bool`, optional): :obj:`True`, if the - administrator can post in the channel, channels only. + administrator can post messages in the channel, or access channel statistics; + for channels only. can_edit_messages (:obj:`bool`, optional): :obj:`True`, if the administrator can edit messages of other users and can pin - messages; channels only. + messages; for channels only. can_pin_messages (:obj:`bool`, optional): :obj:`True`, if the user is allowed - to pin messages; groups and supergroups only. + to pin messages; for groups and supergroups only. + can_post_stories (:obj:`bool`): :obj:`True`, if the administrator can post + stories to the chat. + + .. versionadded:: 20.6 + .. versionchanged:: 21.0 + |non_optional_story_argument| + can_edit_stories (:obj:`bool`): :obj:`True`, if the administrator can edit stories posted + by other users, post stories to the chat page, pin chat stories, and access the chat's + story archive + + .. versionadded:: 20.6 + .. versionchanged:: 21.0 + |non_optional_story_argument| + can_delete_stories (:obj:`bool`): :obj:`True`, if the administrator can delete + stories posted by other users. + + .. versionadded:: 20.6 + .. versionchanged:: 21.0 + |non_optional_story_argument| can_manage_topics (:obj:`bool`, optional): :obj:`True`, if the user is allowed - to create, rename, close, and reopen forum topics; supergroups only. + to create, rename, close, and reopen forum topics; for supergroups only. .. versionadded:: 20.0 custom_title (:obj:`str`, optional): Custom title for this user. + can_manage_direct_messages (:obj:`bool`, optional): :obj:`True`, if the administrator can + manage direct messages of the channel and decline suggested posts; for channels only. + + .. versionadded:: 22.4 Attributes: status (:obj:`str`): The member's status in the chat, @@ -232,9 +266,8 @@ class ChatMemberAdministrator(ChatMember): is allowed to edit administrator privileges of that user. is_anonymous (:obj:`bool`): :obj:`True`, if the user's presence in the chat is hidden. - can_manage_chat (:obj:`bool`): :obj:`True`, if the administrator - can access the chat event log, chat statistics, message statistics in - channels, see channel members, see anonymous administrators in supergroups + can_manage_chat (:obj:`bool`): :obj:`True`, if the administrator can access the chat event + log, get boost list, see hidden supergroup and channel members, report spam messages and ignore slow mode. Implied by any other administrator privilege. can_delete_messages (:obj:`bool`): :obj:`True`, if the administrator can delete messages of other users. @@ -243,7 +276,7 @@ class ChatMemberAdministrator(ChatMember): .. versionadded:: 20.0 can_restrict_members (:obj:`bool`): :obj:`True`, if the - administrator can restrict, ban or unban chat members. + administrator can restrict, ban or unban chat members, or access supergroup statistics. can_promote_members (:obj:`bool`): :obj:`True`, if the administrator can add new administrators with a subset of their own privileges or demote administrators that they have promoted, directly or indirectly (promoted by administrators that @@ -253,34 +286,62 @@ class ChatMemberAdministrator(ChatMember): can_invite_users (:obj:`bool`): :obj:`True`, if the user can invite new users to the chat. can_post_messages (:obj:`bool`): Optional. :obj:`True`, if the - administrator can post in the channel, channels only. + administrator can post messages in the channel or access channel statistics; + for channels only. can_edit_messages (:obj:`bool`): Optional. :obj:`True`, if the administrator can edit messages of other users and can pin - messages; channels only. + messages; for channels only. can_pin_messages (:obj:`bool`): Optional. :obj:`True`, if the user is allowed - to pin messages; groups and supergroups only. + to pin messages; for groups and supergroups only. + can_post_stories (:obj:`bool`): :obj:`True`, if the administrator can post + stories to the chat. + + .. versionadded:: 20.6 + .. versionchanged:: 21.0 + |non_optional_story_argument| + can_edit_stories (:obj:`bool`): :obj:`True`, if the administrator can edit stories posted + by other users, post stories to the chat page, pin chat stories, and access the chat's + story archive + + .. versionadded:: 20.6 + .. versionchanged:: 21.0 + |non_optional_story_argument| + can_delete_stories (:obj:`bool`): :obj:`True`, if the administrator can delete + stories posted by other users. + + .. versionadded:: 20.6 + .. versionchanged:: 21.0 + |non_optional_story_argument| can_manage_topics (:obj:`bool`): Optional. :obj:`True`, if the user is allowed - to create, rename, close, and reopen forum topics; supergroups only + to create, rename, close, and reopen forum topics; for supergroups only .. versionadded:: 20.0 custom_title (:obj:`str`): Optional. Custom title for this user. + can_manage_direct_messages (:obj:`bool`, optional): :obj:`True`, if the administrator can + manage direct messages of the channel and decline suggested posts; for channels only. + + .. versionadded:: 22.4 """ __slots__ = ( "can_be_edited", - "is_anonymous", - "can_manage_chat", - "can_delete_messages", - "can_manage_video_chats", - "can_restrict_members", - "can_promote_members", "can_change_info", - "can_invite_users", - "can_post_messages", + "can_delete_messages", + "can_delete_stories", "can_edit_messages", - "can_pin_messages", + "can_edit_stories", + "can_invite_users", + "can_manage_chat", + "can_manage_direct_messages", "can_manage_topics", + "can_manage_video_chats", + "can_pin_messages", + "can_post_messages", + "can_post_stories", + "can_promote_members", + "can_restrict_members", "custom_title", + "is_anonymous", ) def __init__( @@ -295,13 +356,17 @@ def __init__( can_promote_members: bool, can_change_info: bool, can_invite_users: bool, - can_post_messages: Optional[bool] = None, - can_edit_messages: Optional[bool] = None, - can_pin_messages: Optional[bool] = None, - can_manage_topics: Optional[bool] = None, - custom_title: Optional[str] = None, + can_post_stories: bool, + can_edit_stories: bool, + can_delete_stories: bool, + can_post_messages: bool | None = None, + can_edit_messages: bool | None = None, + can_pin_messages: bool | None = None, + can_manage_topics: bool | None = None, + custom_title: str | None = None, + can_manage_direct_messages: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(status=ChatMember.ADMINISTRATOR, user=user, api_kwargs=api_kwargs) with self._unfrozen(): @@ -314,11 +379,16 @@ def __init__( self.can_promote_members: bool = can_promote_members self.can_change_info: bool = can_change_info self.can_invite_users: bool = can_invite_users - self.can_post_messages: Optional[bool] = can_post_messages - self.can_edit_messages: Optional[bool] = can_edit_messages - self.can_pin_messages: Optional[bool] = can_pin_messages - self.can_manage_topics: Optional[bool] = can_manage_topics - self.custom_title: Optional[str] = custom_title + self.can_post_stories: bool = can_post_stories + self.can_edit_stories: bool = can_edit_stories + self.can_delete_stories: bool = can_delete_stories + # Optionals + self.can_post_messages: bool | None = can_post_messages + self.can_edit_messages: bool | None = can_edit_messages + self.can_pin_messages: bool | None = can_pin_messages + self.can_manage_topics: bool | None = can_manage_topics + self.custom_title: str | None = custom_title + self.can_manage_direct_messages: bool | None = can_manage_direct_messages class ChatMemberMember(ChatMember): @@ -330,24 +400,34 @@ class ChatMemberMember(ChatMember): Args: user (:class:`telegram.User`): Information about the user. + until_date (:class:`datetime.datetime`, optional): Date when the user's subscription will + expire. + + .. versionadded:: 21.5 Attributes: status (:obj:`str`): The member's status in the chat, always :tg-const:`telegram.ChatMember.MEMBER`. user (:class:`telegram.User`): Information about the user. + until_date (:class:`datetime.datetime`): Optional. Date when the user's subscription will + expire. + + .. versionadded:: 21.5 """ - __slots__ = () + __slots__ = ("until_date",) def __init__( self, user: User, + until_date: dtm.datetime | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(status=ChatMember.MEMBER, user=user, api_kwargs=api_kwargs) - self._freeze() + with self._unfrozen(): + self.until_date: dtm.datetime | None = until_date class ChatMemberRestricted(ChatMember): @@ -360,6 +440,9 @@ class ChatMemberRestricted(ChatMember): All arguments were made positional and their order was changed. The argument can_manage_topics was added. + .. versionchanged:: 20.5 + Removed deprecated argument and attribute ``can_send_media_messages``. + Args: user (:class:`telegram.User`): Information about the user. is_member (:obj:`bool`): :obj:`True`, if the user is a @@ -372,11 +455,6 @@ class ChatMemberRestricted(ChatMember): to pin messages; groups and supergroups only. can_send_messages (:obj:`bool`): :obj:`True`, if the user is allowed to send text messages, contacts, invoices, locations and venues. - can_send_media_messages (:obj:`bool`): :obj:`True`, if the user is allowed - to send audios, documents, photos, videos, video notes and voice notes. - - .. deprecated:: 20.1 - Bot API 6.5 replaced this argument with granular media settings. can_send_polls (:obj:`bool`): :obj:`True`, if the user is allowed to send polls. can_send_other_messages (:obj:`bool`): :obj:`True`, if the user is allowed @@ -427,11 +505,6 @@ class ChatMemberRestricted(ChatMember): to pin messages; groups and supergroups only. can_send_messages (:obj:`bool`): :obj:`True`, if the user is allowed to send text messages, contacts, locations and venues. - can_send_media_messages (:obj:`bool`): :obj:`True`, if the user is allowed - to send audios, documents, photos, videos, video notes and voice notes. - - .. deprecated:: 20.1 - Bot API 6.5 replaced this attribute with granular media settings. can_send_polls (:obj:`bool`): :obj:`True`, if the user is allowed to send polls. can_send_other_messages (:obj:`bool`): :obj:`True`, if the user is allowed @@ -471,23 +544,22 @@ class ChatMemberRestricted(ChatMember): """ __slots__ = ( - "is_member", + "can_add_web_page_previews", "can_change_info", "can_invite_users", - "can_pin_messages", - "can_send_messages", - "can_send_media_messages", - "can_send_polls", - "can_send_other_messages", - "can_add_web_page_previews", "can_manage_topics", - "until_date", + "can_pin_messages", "can_send_audios", "can_send_documents", + "can_send_messages", + "can_send_other_messages", "can_send_photos", - "can_send_videos", + "can_send_polls", "can_send_video_notes", + "can_send_videos", "can_send_voice_notes", + "is_member", + "until_date", ) def __init__( @@ -498,12 +570,11 @@ def __init__( can_invite_users: bool, can_pin_messages: bool, can_send_messages: bool, - can_send_media_messages: bool, can_send_polls: bool, can_send_other_messages: bool, can_add_web_page_previews: bool, can_manage_topics: bool, - until_date: datetime.datetime, + until_date: dtm.datetime, can_send_audios: bool, can_send_documents: bool, can_send_photos: bool, @@ -511,7 +582,7 @@ def __init__( can_send_video_notes: bool, can_send_voice_notes: bool, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(status=ChatMember.RESTRICTED, user=user, api_kwargs=api_kwargs) with self._unfrozen(): @@ -520,12 +591,11 @@ def __init__( self.can_invite_users: bool = can_invite_users self.can_pin_messages: bool = can_pin_messages self.can_send_messages: bool = can_send_messages - self.can_send_media_messages: bool = can_send_media_messages self.can_send_polls: bool = can_send_polls self.can_send_other_messages: bool = can_send_other_messages self.can_add_web_page_previews: bool = can_add_web_page_previews self.can_manage_topics: bool = can_manage_topics - self.until_date: datetime.datetime = until_date + self.until_date: dtm.datetime = until_date self.can_send_audios: bool = can_send_audios self.can_send_documents: bool = can_send_documents self.can_send_photos: bool = can_send_photos @@ -556,7 +626,7 @@ def __init__( self, user: User, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(status=ChatMember.LEFT, user=user, api_kwargs=api_kwargs) self._freeze() @@ -594,10 +664,10 @@ class ChatMemberBanned(ChatMember): def __init__( self, user: User, - until_date: datetime.datetime, + until_date: dtm.datetime, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(status=ChatMember.BANNED, user=user, api_kwargs=api_kwargs) with self._unfrozen(): - self.until_date: datetime.datetime = until_date + self.until_date: dtm.datetime = until_date diff --git a/telegram/_chatmemberupdated.py b/src/telegram/_chatmemberupdated.py similarity index 77% rename from telegram/_chatmemberupdated.py rename to src/telegram/_chatmemberupdated.py index 9340c7bef2c..2434376d459 100644 --- a/telegram/_chatmemberupdated.py +++ b/src/telegram/_chatmemberupdated.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,14 +17,16 @@ # 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 ChatMemberUpdated.""" -import datetime -from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union + +import datetime as dtm +from typing import TYPE_CHECKING from telegram._chat import Chat from telegram._chatinvitelink import ChatInviteLink from telegram._chatmember import ChatMember from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.types import JSONDict @@ -63,6 +65,11 @@ class ChatMemberUpdated(TelegramObject): chat via a chat folder invite link .. versionadded:: 20.3 + via_join_request (:obj:`bool`, optional): :obj:`True`, if the user joined the chat after + sending a direct join request without using an invite link and being approved by + an administrator + + .. versionadded:: 21.2 Attributes: chat (:class:`telegram.Chat`): Chat the user belongs to. @@ -80,42 +87,50 @@ class ChatMemberUpdated(TelegramObject): chat via a chat folder invite link .. versionadded:: 20.3 + via_join_request (:obj:`bool`): Optional. :obj:`True`, if the user joined the chat after + sending a direct join request without using an invite link and being approved + by an administrator + + .. versionadded:: 21.2 """ __slots__ = ( "chat", - "from_user", "date", - "old_chat_member", - "new_chat_member", + "from_user", "invite_link", + "new_chat_member", + "old_chat_member", "via_chat_folder_invite_link", + "via_join_request", ) def __init__( self, chat: Chat, from_user: User, - date: datetime.datetime, + date: dtm.datetime, old_chat_member: ChatMember, new_chat_member: ChatMember, - invite_link: Optional[ChatInviteLink] = None, - via_chat_folder_invite_link: Optional[bool] = None, + invite_link: ChatInviteLink | None = None, + via_chat_folder_invite_link: bool | None = None, + via_join_request: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.chat: Chat = chat self.from_user: User = from_user - self.date: datetime.datetime = date + self.date: dtm.datetime = date self.old_chat_member: ChatMember = old_chat_member self.new_chat_member: ChatMember = new_chat_member - self.via_chat_folder_invite_link: Optional[bool] = via_chat_folder_invite_link + self.via_chat_folder_invite_link: bool | None = via_chat_folder_invite_link # Optionals - self.invite_link: Optional[ChatInviteLink] = invite_link + self.invite_link: ChatInviteLink | None = invite_link + self.via_join_request: bool | None = via_join_request self._id_attrs = ( self.chat, @@ -128,26 +143,23 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChatMemberUpdated"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatMemberUpdated": """See :meth:`telegram.TelegramObject.de_json`.""" 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["chat"] = Chat.de_json(data.get("chat"), bot) - data["from_user"] = User.de_json(data.pop("from", None), bot) + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["from_user"] = de_json_optional(data.pop("from", None), User, bot) data["date"] = from_timestamp(data.get("date"), tzinfo=loc_tzinfo) - data["old_chat_member"] = ChatMember.de_json(data.get("old_chat_member"), bot) - data["new_chat_member"] = ChatMember.de_json(data.get("new_chat_member"), bot) - data["invite_link"] = ChatInviteLink.de_json(data.get("invite_link"), bot) + data["old_chat_member"] = de_json_optional(data.get("old_chat_member"), ChatMember, bot) + data["new_chat_member"] = de_json_optional(data.get("new_chat_member"), ChatMember, bot) + data["invite_link"] = de_json_optional(data.get("invite_link"), ChatInviteLink, bot) return super().de_json(data=data, bot=bot) - def _get_attribute_difference(self, attribute: str) -> Tuple[object, object]: + def _get_attribute_difference(self, attribute: str) -> tuple[object, object]: try: old = self.old_chat_member[attribute] except KeyError: @@ -162,11 +174,9 @@ def _get_attribute_difference(self, attribute: str) -> Tuple[object, object]: def difference( self, - ) -> Dict[ + ) -> dict[ str, - Tuple[ - Union[str, bool, datetime.datetime, User], Union[str, bool, datetime.datetime, User] - ], + tuple[str | bool | dtm.datetime | User, str | bool | dtm.datetime | User], ]: """Computes the difference between :attr:`old_chat_member` and :attr:`new_chat_member`. @@ -183,7 +193,7 @@ def difference( .. versionadded:: 13.5 Returns: - Dict[:obj:`str`, Tuple[:class:`object`, :class:`object`]]: A dictionary mapping + dict[:obj:`str`, tuple[:class:`object`, :class:`object`]]: A dictionary mapping attribute names to tuples of the form ``(old_value, new_value)`` """ # we first get the names of the attributes that have changed diff --git a/telegram/_chatpermissions.py b/src/telegram/_chatpermissions.py similarity index 64% rename from telegram/_chatpermissions.py rename to src/telegram/_chatpermissions.py index a2385d2becc..cfea9129d7b 100644 --- a/telegram/_chatpermissions.py +++ b/src/telegram/_chatpermissions.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,31 +17,37 @@ # 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 ChatPermission.""" -from typing import Optional + +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict -from telegram._utils.warnings import warn -from telegram.warnings import PTBDeprecationWarning + +if TYPE_CHECKING: + from telegram import Bot class ChatPermissions(TelegramObject): """Describes actions that a non-administrator user is allowed to take in a chat. Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`can_send_messages`, :attr:`can_send_media_messages`, + considered equal, if their :attr:`can_send_messages`, :attr:`can_send_polls`, :attr:`can_send_other_messages`, :attr:`can_add_web_page_previews`, - :attr:`can_change_info`, :attr:`can_invite_users`, :attr:`can_pin_messages`, and + :attr:`can_change_info`, :attr:`can_invite_users`, :attr:`can_pin_messages`, + :attr:`can_send_audios`, :attr:`can_send_documents`, :attr:`can_send_photos`, + :attr:`can_send_videos`, :attr:`can_send_video_notes`, :attr:`can_send_voice_notes`, and :attr:`can_manage_topics` are equal. .. versionchanged:: 20.0 :attr:`can_manage_topics` is considered as well when comparing objects of this type in terms of equality. - .. deprecated:: 20.1 - :attr:`can_send_audios`, :attr:`can_send_documents`, :attr:`can_send_photos`, - :attr:`can_send_videos`, :attr:`can_send_video_notes` and :attr:`can_send_voice_notes` - will be considered as well when comparing objects of this type in terms of equality in - V21. + .. versionchanged:: 20.5 + + * :attr:`can_send_audios`, :attr:`can_send_documents`, :attr:`can_send_photos`, + :attr:`can_send_videos`, :attr:`can_send_video_notes` and :attr:`can_send_voice_notes` + are considered as well when comparing objects of this type in terms of equality. + * Removed deprecated argument and attribute ``can_send_media_messages``. + Note: Though not stated explicitly in the official docs, Telegram changes not only the @@ -51,19 +57,11 @@ class ChatPermissions(TelegramObject): Args: can_send_messages (:obj:`bool`, optional): :obj:`True`, if the user is allowed to send text messages, contacts, locations and venues. - can_send_media_messages (:obj:`bool`, optional): :obj:`True`, if the user is allowed to - send audios, documents, photos, videos, video notes and voice notes, implies - :attr:`can_send_messages`. - - .. deprecated:: 20.1 - Bot API 6.5 replaced this argument with granular media settings. - can_send_polls (:obj:`bool`, optional): :obj:`True`, if the user is allowed to send polls, - implies :attr:`can_send_messages`. + can_send_polls (:obj:`bool`, optional): :obj:`True`, if the user is allowed to send polls. can_send_other_messages (:obj:`bool`, optional): :obj:`True`, if the user is allowed to - send animations, games, stickers and use inline bots, implies - :attr:`can_send_media_messages`. + send animations, games, stickers and use inline bots. can_add_web_page_previews (:obj:`bool`, optional): :obj:`True`, if the user is allowed to - add web page previews to their messages, implies :attr:`can_send_media_messages`. + add web page previews to their messages. can_change_info (:obj:`bool`, optional): :obj:`True`, if the user is allowed to change the chat title, photo and other settings. Ignored in public supergroups. can_invite_users (:obj:`bool`, optional): :obj:`True`, if the user is allowed to invite new @@ -99,19 +97,12 @@ class ChatPermissions(TelegramObject): Attributes: can_send_messages (:obj:`bool`): Optional. :obj:`True`, if the user is allowed to send text messages, contacts, locations and venues. - can_send_media_messages (:obj:`bool`): Optional. :obj:`True`, if the user is allowed to - send audios, documents, photos, videos, video notes and voice notes, implies - :attr:`can_send_messages`. - - .. deprecated:: 20.1 - Bot API 6.5 replaced this attribute with granular media settings. can_send_polls (:obj:`bool`): Optional. :obj:`True`, if the user is allowed to send polls, implies :attr:`can_send_messages`. can_send_other_messages (:obj:`bool`): Optional. :obj:`True`, if the user is allowed to - send animations, games, stickers and use inline bots, implies - :attr:`can_send_media_messages`. + send animations, games, stickers and use inline bots. can_add_web_page_previews (:obj:`bool`): Optional. :obj:`True`, if the user is allowed to - add web page previews to their messages, implies :attr:`can_send_media_messages`. + add web page previews to their messages. can_change_info (:obj:`bool`): Optional. :obj:`True`, if the user is allowed to change the chat title, photo and other settings. Ignored in public supergroups. can_invite_users (:obj:`bool`): Optional. :obj:`True`, if the user is allowed to invite @@ -147,64 +138,60 @@ class ChatPermissions(TelegramObject): """ __slots__ = ( - "can_send_other_messages", - "can_invite_users", - "can_send_polls", - "can_send_messages", - "can_send_media_messages", - "can_change_info", - "can_pin_messages", "can_add_web_page_previews", + "can_change_info", + "can_invite_users", "can_manage_topics", + "can_pin_messages", "can_send_audios", "can_send_documents", + "can_send_messages", + "can_send_other_messages", "can_send_photos", - "can_send_videos", + "can_send_polls", "can_send_video_notes", + "can_send_videos", "can_send_voice_notes", ) def __init__( self, - can_send_messages: Optional[bool] = None, - can_send_media_messages: Optional[bool] = None, - can_send_polls: Optional[bool] = None, - can_send_other_messages: Optional[bool] = None, - can_add_web_page_previews: Optional[bool] = None, - can_change_info: Optional[bool] = None, - can_invite_users: Optional[bool] = None, - can_pin_messages: Optional[bool] = None, - can_manage_topics: Optional[bool] = None, - can_send_audios: Optional[bool] = None, - can_send_documents: Optional[bool] = None, - can_send_photos: Optional[bool] = None, - can_send_videos: Optional[bool] = None, - can_send_video_notes: Optional[bool] = None, - can_send_voice_notes: Optional[bool] = None, + can_send_messages: bool | None = None, + can_send_polls: bool | None = None, + can_send_other_messages: bool | None = None, + can_add_web_page_previews: bool | None = None, + can_change_info: bool | None = None, + can_invite_users: bool | None = None, + can_pin_messages: bool | None = None, + can_manage_topics: bool | None = None, + can_send_audios: bool | None = None, + can_send_documents: bool | None = None, + can_send_photos: bool | None = None, + can_send_videos: bool | None = None, + can_send_video_notes: bool | None = None, + can_send_voice_notes: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required - self.can_send_messages: Optional[bool] = can_send_messages - self.can_send_media_messages: Optional[bool] = can_send_media_messages - self.can_send_polls: Optional[bool] = can_send_polls - self.can_send_other_messages: Optional[bool] = can_send_other_messages - self.can_add_web_page_previews: Optional[bool] = can_add_web_page_previews - self.can_change_info: Optional[bool] = can_change_info - self.can_invite_users: Optional[bool] = can_invite_users - self.can_pin_messages: Optional[bool] = can_pin_messages - self.can_manage_topics: Optional[bool] = can_manage_topics - self.can_send_audios: Optional[bool] = can_send_audios - self.can_send_documents: Optional[bool] = can_send_documents - self.can_send_photos: Optional[bool] = can_send_photos - self.can_send_videos: Optional[bool] = can_send_videos - self.can_send_video_notes: Optional[bool] = can_send_video_notes - self.can_send_voice_notes: Optional[bool] = can_send_voice_notes + self.can_send_messages: bool | None = can_send_messages + self.can_send_polls: bool | None = can_send_polls + self.can_send_other_messages: bool | None = can_send_other_messages + self.can_add_web_page_previews: bool | None = can_add_web_page_previews + self.can_change_info: bool | None = can_change_info + self.can_invite_users: bool | None = can_invite_users + self.can_pin_messages: bool | None = can_pin_messages + self.can_manage_topics: bool | None = can_manage_topics + self.can_send_audios: bool | None = can_send_audios + self.can_send_documents: bool | None = can_send_documents + self.can_send_photos: bool | None = can_send_photos + self.can_send_videos: bool | None = can_send_videos + self.can_send_video_notes: bool | None = can_send_video_notes + self.can_send_voice_notes: bool | None = can_send_voice_notes self._id_attrs = ( self.can_send_messages, - self.can_send_media_messages, self.can_send_polls, self.can_send_other_messages, self.can_add_web_page_previews, @@ -212,23 +199,16 @@ def __init__( self.can_invite_users, self.can_pin_messages, self.can_manage_topics, + self.can_send_audios, + self.can_send_documents, + self.can_send_photos, + self.can_send_videos, + self.can_send_video_notes, + self.can_send_voice_notes, ) self._freeze() - def __eq__(self, other: object) -> bool: - warn( - "In v21, granular media settings will be considered as well when comparing" - " ChatPermissions instances.", - PTBDeprecationWarning, - stacklevel=2, - ) - return super().__eq__(other) - - def __hash__(self) -> int: - # Intend: Added so support the own __eq__ function (which otherwise breaks hashing) - return super().__hash__() - @classmethod def all_permissions(cls) -> "ChatPermissions": """ @@ -239,7 +219,7 @@ def all_permissions(cls) -> "ChatPermissions": .. versionadded:: 20.0 """ - return cls(*(15 * (True,))) + return cls(*(14 * (True,))) @classmethod def no_permissions(cls) -> "ChatPermissions": @@ -249,4 +229,17 @@ def no_permissions(cls) -> "ChatPermissions": .. versionadded:: 20.0 """ - return cls(*(15 * (False,))) + return cls(*(14 * (False,))) + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatPermissions": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + api_kwargs = {} + # This is a deprecated field that TG still returns for backwards compatibility + # Let's filter it out to speed up the de-json process + if data.get("can_send_media_messages") is not None: + api_kwargs["can_send_media_messages"] = data.pop("can_send_media_messages") + + return super()._de_json(data=data, bot=bot, api_kwargs=api_kwargs) diff --git a/src/telegram/_checklists.py b/src/telegram/_checklists.py new file mode 100644 index 00000000000..16b7676eb5b --- /dev/null +++ b/src/telegram/_checklists.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 objects related to Telegram checklists.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING, Optional + +from telegram._chat import Chat +from telegram._messageentity import MessageEntity +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.entities import parse_message_entities, parse_message_entity +from telegram._utils.types import JSONDict +from telegram.constants import ZERO_DATE + +if TYPE_CHECKING: + from telegram import Bot, Message + + +class ChecklistTask(TelegramObject): + """ + Describes a task in a checklist. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their :attr:`id` is equal. + + .. versionadded:: 22.3 + + Args: + id (:obj:`int`): Unique identifier of the task. + text (:obj:`str`): Text of the task. + text_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special + entities that appear in the task text. + completed_by_user (:class:`telegram.User`, optional): User that completed the task; omitted + if the task wasn't completed + completed_by_chat (:class:`telegram.Chat`, optional): Chat that completed the task; omitted + if the task wasn't completed by a chat + + .. versionadded:: 22.6 + completion_date (:class:`datetime.datetime`, optional): Point in time when + the task was completed; :attr:`~telegram.constants.ZERO_DATE` if the task wasn't + completed + + |datetime_localization| + + Attributes: + id (:obj:`int`): Unique identifier of the task. + text (:obj:`str`): Text of the task. + text_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. Special + entities that appear in the task text. + completed_by_user (:class:`telegram.User`): Optional. User that completed the task; omitted + if the task wasn't completed + completed_by_chat (:class:`telegram.Chat`): Optional. Chat that completed the task; omitted + if the task wasn't completed by a chat + + .. versionadded:: 22.6 + completion_date (:class:`datetime.datetime`): Optional. Point in time when + the task was completed; :attr:`~telegram.constants.ZERO_DATE` if the task wasn't + completed + + |datetime_localization| + """ + + __slots__ = ( + "completed_by_chat", + "completed_by_user", + "completion_date", + "id", + "text", + "text_entities", + ) + + def __init__( + self, + id: int, # pylint: disable=redefined-builtin + text: str, + text_entities: Sequence[MessageEntity] | None = None, + completed_by_user: User | None = None, + completion_date: dtm.datetime | None = None, + completed_by_chat: Chat | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.id: int = id + self.text: str = text + self.text_entities: tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) + self.completed_by_user: User | None = completed_by_user + self.completed_by_chat: Chat | None = completed_by_chat + self.completion_date: dtm.datetime | None = completion_date + + self._id_attrs = (self.id,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChecklistTask": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + if (date := data.get("completion_date")) == 0: + data["completion_date"] = ZERO_DATE + else: + data["completion_date"] = from_timestamp(date, tzinfo=loc_tzinfo) + + data["completed_by_user"] = de_json_optional(data.get("completed_by_user"), User, bot) + data["completed_by_chat"] = de_json_optional(data.get("completed_by_chat"), Chat, bot) + data["text_entities"] = de_list_optional(data.get("text_entities"), MessageEntity, bot) + + return super().de_json(data=data, bot=bot) + + def parse_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`text` + from a given :class:`telegram.MessageEntity` of :attr:`text_entities`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``ChecklistTask.text`` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`text_entities`. + + Returns: + :obj:`str`: The text of the given entity. + """ + return parse_message_entity(self.text, entity) + + def parse_entities(self, types: list[str] | None = None) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this checklist task filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + Note: + This method should always be used instead of the :attr:`text_entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + """ + return parse_message_entities(self.text, self.text_entities, types) + + +class Checklist(TelegramObject): + """ + Describes a checklist. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if all their :attr:`tasks` are equal. + + .. versionadded:: 22.3 + + Args: + title (:obj:`str`): Title of the checklist. + title_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special + entities that appear in the checklist title. + tasks (Sequence[:class:`telegram.ChecklistTask`]): List of tasks in the checklist. + others_can_add_tasks (:obj:`bool`, optional): :obj:`True` if users other than the creator + of the list can add tasks to the list + others_can_mark_tasks_as_done (:obj:`bool`, optional): :obj:`True` if users other than the + creator of the list can mark tasks as done or not done + + Attributes: + title (:obj:`str`): Title of the checklist. + title_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. Special + entities that appear in the checklist title. + tasks (Tuple[:class:`telegram.ChecklistTask`]): List of tasks in the checklist. + others_can_add_tasks (:obj:`bool`): Optional. :obj:`True` if users other than the creator + of the list can add tasks to the list + others_can_mark_tasks_as_done (:obj:`bool`): Optional. :obj:`True` if users other than the + creator of the list can mark tasks as done or not done + """ + + __slots__ = ( + "others_can_add_tasks", + "others_can_mark_tasks_as_done", + "tasks", + "title", + "title_entities", + ) + + def __init__( + self, + title: str, + tasks: Sequence[ChecklistTask], + title_entities: Sequence[MessageEntity] | None = None, + others_can_add_tasks: bool | None = None, + others_can_mark_tasks_as_done: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.title: str = title + self.title_entities: tuple[MessageEntity, ...] = parse_sequence_arg(title_entities) + self.tasks: tuple[ChecklistTask, ...] = parse_sequence_arg(tasks) + self.others_can_add_tasks: bool | None = others_can_add_tasks + self.others_can_mark_tasks_as_done: bool | None = others_can_mark_tasks_as_done + + self._id_attrs = (self.tasks,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Checklist": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["title_entities"] = de_list_optional(data.get("title_entities"), MessageEntity, bot) + data["tasks"] = de_list_optional(data.get("tasks"), ChecklistTask, bot) + + return super().de_json(data=data, bot=bot) + + def parse_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`title` + from a given :class:`telegram.MessageEntity` of :attr:`title_entities`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice :attr:`title` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`title_entities`. + + Returns: + :obj:`str`: The text of the given entity. + """ + return parse_message_entity(self.title, entity) + + def parse_entities(self, types: list[str] | None = None) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this checklist's title filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + Note: + This method should always be used instead of the :attr:`title_entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + """ + return parse_message_entities(self.title, self.title_entities, types) + + +class ChecklistTasksDone(TelegramObject): + """ + Describes a service message about checklist tasks marked as done or not done. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their :attr:`marked_as_done_task_ids` and + :attr:`marked_as_not_done_task_ids` are equal. + + .. versionadded:: 22.3 + + Args: + checklist_message (:class:`telegram.Message`, optional): Message containing the checklist + whose tasks were marked as done or not done. Note that the ~:class:`telegram.Message` + object in this field will not contain the :attr:`~telegram.Message.reply_to_message` + field even if it itself is a reply. + marked_as_done_task_ids (Sequence[:obj:`int`], optional): Identifiers of the tasks that + were marked as done + marked_as_not_done_task_ids (Sequence[:obj:`int`], optional): Identifiers of the tasks that + were marked as not done + + Attributes: + checklist_message (:class:`telegram.Message`): Optional. Message containing the checklist + whose tasks were marked as done or not done. Note that the ~:class:`telegram.Message` + object in this field will not contain the :attr:`~telegram.Message.reply_to_message` + field even if it itself is a reply. + marked_as_done_task_ids (Tuple[:obj:`int`]): Optional. Identifiers of the tasks that were + marked as done + marked_as_not_done_task_ids (Tuple[:obj:`int`]): Optional. Identifiers of the tasks that + were marked as not done + """ + + __slots__ = ( + "checklist_message", + "marked_as_done_task_ids", + "marked_as_not_done_task_ids", + ) + + def __init__( + self, + checklist_message: Optional["Message"] = None, + marked_as_done_task_ids: Sequence[int] | None = None, + marked_as_not_done_task_ids: Sequence[int] | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.checklist_message: Message | None = checklist_message + self.marked_as_done_task_ids: tuple[int, ...] = parse_sequence_arg(marked_as_done_task_ids) + self.marked_as_not_done_task_ids: tuple[int, ...] = parse_sequence_arg( + marked_as_not_done_task_ids + ) + + self._id_attrs = (self.marked_as_done_task_ids, self.marked_as_not_done_task_ids) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChecklistTasksDone": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # needs to be imported here to avoid circular import issues + from telegram import Message # pylint: disable=import-outside-toplevel # noqa: PLC0415 + + data["checklist_message"] = de_json_optional(data.get("checklist_message"), Message, bot) + + return super().de_json(data=data, bot=bot) + + +class ChecklistTasksAdded(TelegramObject): + """ + Describes a service message about tasks added to a checklist. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal, if their :attr:`tasks` are equal. + + .. versionadded:: 22.3 + + Args: + checklist_message (:class:`telegram.Message`, optional): Message containing the checklist + to which tasks were added. Note that the ~:class:`telegram.Message` + object in this field will not contain the :attr:`~telegram.Message.reply_to_message` + field even if it itself is a reply. + tasks (Sequence[:class:`telegram.ChecklistTask`]): List of tasks added to the checklist + + Attributes: + checklist_message (:class:`telegram.Message`): Optional. Message containing the checklist + to which tasks were added. Note that the ~:class:`telegram.Message` + object in this field will not contain the :attr:`~telegram.Message.reply_to_message` + field even if it itself is a reply. + tasks (Tuple[:class:`telegram.ChecklistTask`]): List of tasks added to the checklist + """ + + __slots__ = ("checklist_message", "tasks") + + def __init__( + self, + tasks: Sequence[ChecklistTask], + checklist_message: Optional["Message"] = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.checklist_message: Message | None = checklist_message + self.tasks: tuple[ChecklistTask, ...] = parse_sequence_arg(tasks) + + self._id_attrs = (self.tasks,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChecklistTasksAdded": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # needs to be imported here to avoid circular import issues + from telegram import Message # pylint: disable=import-outside-toplevel # noqa: PLC0415 + + data["checklist_message"] = de_json_optional(data.get("checklist_message"), Message, bot) + data["tasks"] = ChecklistTask.de_list(data.get("tasks", []), bot) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_choseninlineresult.py b/src/telegram/_choseninlineresult.py similarity index 83% rename from telegram/_choseninlineresult.py rename to src/telegram/_choseninlineresult.py index d6e29fb94f6..41022e066da 100644 --- a/telegram/_choseninlineresult.py +++ b/src/telegram/_choseninlineresult.py @@ -2,7 +2,7 @@ # pylint: disable=too-many-arguments # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,11 +19,12 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram ChosenInlineResult.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._files.location import Location from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -65,17 +66,17 @@ class ChosenInlineResult(TelegramObject): """ - __slots__ = ("location", "result_id", "from_user", "inline_message_id", "query") + __slots__ = ("from_user", "inline_message_id", "location", "query", "result_id") def __init__( self, result_id: str, from_user: User, query: str, - location: Optional[Location] = None, - inline_message_id: Optional[str] = None, + location: "Location | None" = None, + inline_message_id: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -84,24 +85,21 @@ def __init__( self.from_user: User = from_user self.query: str = query # Optionals - self.location: Optional[Location] = location - self.inline_message_id: Optional[str] = inline_message_id + self.location: Location | None = location + self.inline_message_id: str | None = inline_message_id self._id_attrs = (self.result_id,) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ChosenInlineResult"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChosenInlineResult": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - # Required - data["from_user"] = User.de_json(data.pop("from", None), bot) + data["from_user"] = de_json_optional(data.pop("from", None), User, bot) # Optionals - data["location"] = Location.de_json(data.get("location"), bot) + data["location"] = de_json_optional(data.get("location"), Location, bot) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_copytextbutton.py b/src/telegram/_copytextbutton.py new file mode 100644 index 00000000000..0bd5939081a --- /dev/null +++ b/src/telegram/_copytextbutton.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 CopyTextButton.""" + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class CopyTextButton(TelegramObject): + """ + This object represents an inline keyboard button that copies specified text to the clipboard. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`text` is equal. + + .. versionadded:: 21.7 + + Args: + text (:obj:`str`): The text to be copied to the clipboard; + :tg-const:`telegram.constants.InlineKeyboardButtonLimit.MIN_COPY_TEXT`- + :tg-const:`telegram.constants.InlineKeyboardButtonLimit.MAX_COPY_TEXT` characters + + Attributes: + text (:obj:`str`): The text to be copied to the clipboard; + :tg-const:`telegram.constants.InlineKeyboardButtonLimit.MIN_COPY_TEXT`- + :tg-const:`telegram.constants.InlineKeyboardButtonLimit.MAX_COPY_TEXT` characters + + """ + + __slots__ = ("text",) + + def __init__(self, text: str, *, api_kwargs: JSONDict | None = None): + super().__init__(api_kwargs=api_kwargs) + self.text: str = text + + self._id_attrs = (self.text,) + + self._freeze() diff --git a/telegram/_dice.py b/src/telegram/_dice.py similarity index 83% rename from telegram/_dice.py rename to src/telegram/_dice.py index 5182f802e5a..4f8893e6745 100644 --- a/telegram/_dice.py +++ b/src/telegram/_dice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,8 @@ # 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 Dice.""" -from typing import ClassVar, List, Optional + +from typing import Final from telegram import constants from telegram._telegramobject import TelegramObject @@ -89,7 +90,7 @@ class Dice(TelegramObject): __slots__ = ("emoji", "value") - def __init__(self, value: int, emoji: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, value: int, emoji: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self.value: int = value self.emoji: str = emoji @@ -98,62 +99,62 @@ def __init__(self, value: int, emoji: str, *, api_kwargs: Optional[JSONDict] = N self._freeze() - DICE: ClassVar[str] = constants.DiceEmoji.DICE # skipcq: PTC-W0052 + DICE: Final[str] = constants.DiceEmoji.DICE """:const:`telegram.constants.DiceEmoji.DICE`""" - DARTS: ClassVar[str] = constants.DiceEmoji.DARTS + DARTS: Final[str] = constants.DiceEmoji.DARTS """:const:`telegram.constants.DiceEmoji.DARTS`""" - BASKETBALL: ClassVar[str] = constants.DiceEmoji.BASKETBALL + BASKETBALL: Final[str] = constants.DiceEmoji.BASKETBALL """:const:`telegram.constants.DiceEmoji.BASKETBALL`""" - FOOTBALL: ClassVar[str] = constants.DiceEmoji.FOOTBALL + FOOTBALL: Final[str] = constants.DiceEmoji.FOOTBALL """:const:`telegram.constants.DiceEmoji.FOOTBALL`""" - SLOT_MACHINE: ClassVar[str] = constants.DiceEmoji.SLOT_MACHINE + SLOT_MACHINE: Final[str] = constants.DiceEmoji.SLOT_MACHINE """:const:`telegram.constants.DiceEmoji.SLOT_MACHINE`""" - BOWLING: ClassVar[str] = constants.DiceEmoji.BOWLING + BOWLING: Final[str] = constants.DiceEmoji.BOWLING """ :const:`telegram.constants.DiceEmoji.BOWLING` .. versionadded:: 13.4 """ - ALL_EMOJI: ClassVar[List[str]] = list(constants.DiceEmoji) - """List[:obj:`str`]: A list of all available dice emoji.""" + ALL_EMOJI: Final[list[str]] = list(constants.DiceEmoji) + """list[:obj:`str`]: A list of all available dice emoji.""" - MIN_VALUE: ClassVar[int] = constants.DiceLimit.MIN_VALUE + MIN_VALUE: Final[int] = constants.DiceLimit.MIN_VALUE """:const:`telegram.constants.DiceLimit.MIN_VALUE` .. versionadded:: 20.0 """ - MAX_VALUE_BOWLING: ClassVar[int] = constants.DiceLimit.MAX_VALUE_BOWLING + MAX_VALUE_BOWLING: Final[int] = constants.DiceLimit.MAX_VALUE_BOWLING """:const:`telegram.constants.DiceLimit.MAX_VALUE_BOWLING` .. versionadded:: 20.0 """ - MAX_VALUE_DARTS: ClassVar[int] = constants.DiceLimit.MAX_VALUE_DARTS + MAX_VALUE_DARTS: Final[int] = constants.DiceLimit.MAX_VALUE_DARTS """:const:`telegram.constants.DiceLimit.MAX_VALUE_DARTS` .. versionadded:: 20.0 """ - MAX_VALUE_DICE: ClassVar[int] = constants.DiceLimit.MAX_VALUE_DICE + MAX_VALUE_DICE: Final[int] = constants.DiceLimit.MAX_VALUE_DICE """:const:`telegram.constants.DiceLimit.MAX_VALUE_DICE` .. versionadded:: 20.0 """ - MAX_VALUE_BASKETBALL: ClassVar[int] = constants.DiceLimit.MAX_VALUE_BASKETBALL + MAX_VALUE_BASKETBALL: Final[int] = constants.DiceLimit.MAX_VALUE_BASKETBALL """:const:`telegram.constants.DiceLimit.MAX_VALUE_BASKETBALL` .. versionadded:: 20.0 """ - MAX_VALUE_FOOTBALL: ClassVar[int] = constants.DiceLimit.MAX_VALUE_FOOTBALL + MAX_VALUE_FOOTBALL: Final[int] = constants.DiceLimit.MAX_VALUE_FOOTBALL """:const:`telegram.constants.DiceLimit.MAX_VALUE_FOOTBALL` .. versionadded:: 20.0 """ - MAX_VALUE_SLOT_MACHINE: ClassVar[int] = constants.DiceLimit.MAX_VALUE_SLOT_MACHINE + MAX_VALUE_SLOT_MACHINE: Final[int] = constants.DiceLimit.MAX_VALUE_SLOT_MACHINE """:const:`telegram.constants.DiceLimit.MAX_VALUE_SLOT_MACHINE` .. versionadded:: 20.0 diff --git a/src/telegram/_directmessagepricechanged.py b/src/telegram/_directmessagepricechanged.py new file mode 100644 index 00000000000..0e9f750d446 --- /dev/null +++ b/src/telegram/_directmessagepricechanged.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 Direct Message Price.""" + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class DirectMessagePriceChanged(TelegramObject): + """ + Describes a service message about a change in the price of direct messages sent to a channel + chat. + + .. versionadded:: 22.3 + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`are_direct_messages_enabled`, and + :attr:`direct_message_star_count` are equal. + + Args: + are_direct_messages_enabled (:obj:`bool`): + :obj:`True`, if direct messages are enabled for the channel chat; :obj:`False` + otherwise. + direct_message_star_count (:obj:`int`, optional): + The new number of Telegram Stars that must be paid by users for each direct message + sent to the channel. Does not apply to users who have been exempted by administrators. + Defaults to ``0``. + + Attributes: + are_direct_messages_enabled (:obj:`bool`): + :obj:`True`, if direct messages are enabled for the channel chat; :obj:`False` + otherwise. + direct_message_star_count (:obj:`int`): + Optional. The new number of Telegram Stars that must be paid by users for each direct + message sent to the channel. Does not apply to users who have been exempted by + administrators. Defaults to ``0``. + """ + + __slots__ = ("are_direct_messages_enabled", "direct_message_star_count") + + def __init__( + self, + are_direct_messages_enabled: bool, + direct_message_star_count: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.are_direct_messages_enabled: bool = are_direct_messages_enabled + self.direct_message_star_count: int | None = direct_message_star_count + + self._id_attrs = (self.are_direct_messages_enabled, self.direct_message_star_count) + + self._freeze() diff --git a/src/telegram/_directmessagestopic.py b/src/telegram/_directmessagestopic.py new file mode 100644 index 00000000000..384c4db71c8 --- /dev/null +++ b/src/telegram/_directmessagestopic.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 the DirectMessagesTopic class.""" + +from typing import TYPE_CHECKING, Optional + +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram._bot import Bot + + +class DirectMessagesTopic(TelegramObject): + """ + This class represents a topic for direct messages in a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`topic_id` and :attr:`user` is equal. + + .. versionadded:: 22.4 + + Args: + topic_id (:obj:`int`): Unique identifier of the topic. This number may have more than 32 + significant bits and some programming languages may have difficulty/silent defects in + interpreting it. But it has at most 52 significant bits, so a 64-bit integer or + double-precision float type are safe for storing this identifier. + user (:class:`telegram.User`, optional): Information about the user that created the topic. + + .. hint:: + According to Telegram, this field is always present as of Bot API 9.2. + + Attributes: + topic_id (:obj:`int`): Unique identifier of the topic. This number may have more than 32 + significant bits and some programming languages may have difficulty/silent defects in + interpreting it. But it has at most 52 significant bits, so a 64-bit integer or + double-precision float type are safe for storing this identifier. + user (:class:`telegram.User`): Optional. Information about the user that created the topic. + + .. hint:: + According to Telegram, this field is always present as of Bot API 9.2. + + """ + + __slots__ = ("topic_id", "user") + + def __init__( + self, topic_id: int, user: User | None = None, *, api_kwargs: JSONDict | None = None + ): + super().__init__(api_kwargs=api_kwargs) + + # Required: + self.topic_id: int = topic_id + + # Optionals: + self.user: User | None = user + + self._id_attrs = (self.topic_id, self.user) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "DirectMessagesTopic": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["user"] = de_json_optional(data.get("user"), User, bot) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_passport/__init__.py b/src/telegram/_files/__init__.py similarity index 100% rename from telegram/_passport/__init__.py rename to src/telegram/_files/__init__.py diff --git a/telegram/_files/_basemedium.py b/src/telegram/_files/_basemedium.py similarity index 92% rename from telegram/_files/_basemedium.py rename to src/telegram/_files/_basemedium.py index c8bbbd333cc..a6feb4325f3 100644 --- a/telegram/_files/_basemedium.py +++ b/src/telegram/_files/_basemedium.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,8 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Common base class for media objects""" -from typing import TYPE_CHECKING, Optional + +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._utils.defaultvalue import DEFAULT_NONE @@ -56,9 +57,9 @@ def __init__( self, file_id: str, file_unique_id: str, - file_size: Optional[int] = None, + file_size: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -66,7 +67,7 @@ def __init__( self.file_id: str = str(file_id) self.file_unique_id: str = str(file_unique_id) # Optionals - self.file_size: Optional[int] = file_size + self.file_size: int | None = file_size self._id_attrs = (self.file_unique_id,) @@ -77,7 +78,7 @@ async def get_file( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "File": """Convenience wrapper over :meth:`telegram.Bot.get_file` diff --git a/telegram/_files/_basethumbedmedium.py b/src/telegram/_files/_basethumbedmedium.py similarity index 66% rename from telegram/_files/_basethumbedmedium.py rename to src/telegram/_files/_basethumbedmedium.py index e0ba2ec9e3b..bf03f37b3c0 100644 --- a/telegram/_files/_basethumbedmedium.py +++ b/src/telegram/_files/_basethumbedmedium.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,15 +17,13 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Common base class for media objects with thumbnails""" -from typing import TYPE_CHECKING, Optional, Type, TypeVar + +from typing import TYPE_CHECKING, TypeVar from telegram._files._basemedium import _BaseMedium from telegram._files.photosize import PhotoSize +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict -from telegram._utils.warnings_transition import ( - warn_about_deprecated_arg_return_new_arg, - warn_about_deprecated_attr_in_property, -) if TYPE_CHECKING: from telegram import Bot @@ -48,11 +46,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. - thumb (:class:`telegram.PhotoSize`, optional): Thumbnail as defined by sender. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. - thumbnail (:class:`telegram.PhotoSize`, optional): Thumbnail as defined by sender. + thumbnail (:class:`telegram.PhotoSize`, optional): Thumbnail as defined by the sender. .. versionadded:: 20.2 @@ -62,7 +56,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 @@ -74,11 +68,10 @@ def __init__( self, file_id: str, file_unique_id: str, - file_size: Optional[int] = None, - thumb: Optional[PhotoSize] = None, - thumbnail: Optional[PhotoSize] = None, + file_size: int | None = None, + thumbnail: PhotoSize | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, @@ -87,40 +80,16 @@ def __init__( api_kwargs=api_kwargs, ) - self.thumbnail: Optional[PhotoSize] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb, - new_arg=thumbnail, - deprecated_arg_name="thumb", - new_arg_name="thumbnail", - bot_api_version="6.6", - stacklevel=3, - ) - - @property - def thumb(self) -> Optional[PhotoSize]: - """:class:`telegram.PhotoSize`: Optional. Thumbnail as defined by sender. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb", new_attr_name="thumbnail", bot_api_version="6.6" - ) - return self.thumbnail + self.thumbnail: PhotoSize | None = thumbnail @classmethod - def de_json( - cls: Type[ThumbedMT_co], data: Optional[JSONDict], bot: "Bot" - ) -> Optional[ThumbedMT_co]: + def de_json(cls: type[ThumbedMT_co], data: JSONDict, bot: "Bot | None" = None) -> ThumbedMT_co: """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - # In case this wasn't already done by the subclass if not isinstance(data.get("thumbnail"), PhotoSize): - data["thumbnail"] = PhotoSize.de_json(data.get("thumbnail"), bot) + data["thumbnail"] = de_json_optional(data.get("thumbnail"), PhotoSize, bot) api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility diff --git a/src/telegram/_files/_inputstorycontent.py b/src/telegram/_files/_inputstorycontent.py new file mode 100644 index 00000000000..d9edd2dfdbf --- /dev/null +++ b/src/telegram/_files/_inputstorycontent.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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.""" + +import datetime as dtm +from typing import Final + +from telegram import constants +from telegram._files.inputfile import InputFile +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.files import parse_file_input +from telegram._utils.types import FileInput, JSONDict + + +class InputStoryContent(TelegramObject): + """This object describes the content of a story to post. Currently, it can be one of: + + * :class:`telegram.InputStoryContentPhoto` + * :class:`telegram.InputStoryContentVideo` + + .. versionadded:: 22.1 + + Args: + type (:obj:`str`): Type of the content. + + Attributes: + type (:obj:`str`): Type of the content. + """ + + __slots__ = ("type",) + + PHOTO: Final[str] = constants.InputStoryContentType.PHOTO + """:const:`telegram.constants.InputStoryContentType.PHOTO`""" + VIDEO: Final[str] = constants.InputStoryContentType.VIDEO + """:const:`telegram.constants.InputStoryContentType.VIDEO`""" + + def __init__( + self, + type: str, # pylint: disable=redefined-builtin + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.InputStoryContentType, type, type) + + self._freeze() + + @staticmethod + def _parse_file_input(file_input: FileInput) -> str | InputFile: + # We use local_mode=True because we don't have access to the actual setting and want + # things to work in local mode. + return parse_file_input(file_input, attach=True, local_mode=True) + + +class InputStoryContentPhoto(InputStoryContent): + """Describes a photo to post as a story. + + .. versionadded:: 22.1 + + Args: + photo (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ + optional): The photo to post as a story. The photo must be of the + size :tg-const:`telegram.constants.InputStoryContentLimit.PHOTO_WIDTH` + x :tg-const:`telegram.constants.InputStoryContentLimit.PHOTO_HEIGHT` and must not + exceed :tg-const:`telegram.constants.InputStoryContentLimit.PHOTOSIZE_UPLOAD` MB. + |uploadinputnopath|. + + Attributes: + type (:obj:`str`): Type of the content, must be :attr:`~telegram.InputStoryContent.PHOTO`. + photo (:class:`telegram.InputFile`): The photo to post as a story. The photo must be of the + size :tg-const:`telegram.constants.InputStoryContentLimit.PHOTO_WIDTH` + x :tg-const:`telegram.constants.InputStoryContentLimit.PHOTO_HEIGHT` and must not + exceed :tg-const:`telegram.constants.InputStoryContentLimit.PHOTOSIZE_UPLOAD` MB. + + """ + + __slots__ = ("photo",) + + def __init__( + self, + photo: FileInput, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=InputStoryContent.PHOTO, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.photo: str | InputFile = self._parse_file_input(photo) + + +class InputStoryContentVideo(InputStoryContent): + """ + Describes a video to post as a story. + + .. versionadded:: 22.1 + + Args: + video (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ + optional): The video to post as a story. The video must be of + the size :tg-const:`telegram.constants.InputStoryContentLimit.VIDEO_WIDTH` + x :tg-const:`telegram.constants.InputStoryContentLimit.VIDEO_HEIGHT`, + streamable, encoded with ``H.265`` codec, with key frames added + each second in the ``MPEG4`` format, and must not exceed + :tg-const:`telegram.constants.InputStoryContentLimit.VIDEOSIZE_UPLOAD` MB. + |uploadinputnopath|. + duration (:class:`datetime.timedelta` | :obj:`int` | :obj:`float`, optional): Precise + duration of the video in seconds; + 0-:tg-const:`telegram.constants.InputStoryContentLimit.MAX_VIDEO_DURATION` + cover_frame_timestamp (:class:`datetime.timedelta` | :obj:`int` | :obj:`float`, optional): + Timestamp in seconds of the frame that will be used as the static cover for the story. + Defaults to ``0.0``. + is_animation (:obj:`bool`, optional): Pass :obj:`True` if the video has no sound + + Attributes: + type (:obj:`str`): Type of the content, must be :attr:`~telegram.InputStoryContent.VIDEO`. + video (:class:`telegram.InputFile`): The video to post as a story. The video must be of + the size :tg-const:`telegram.constants.InputStoryContentLimit.VIDEO_WIDTH` + x :tg-const:`telegram.constants.InputStoryContentLimit.VIDEO_HEIGHT`, + streamable, encoded with ``H.265`` codec, with key frames added + each second in the ``MPEG4`` format, and must not exceed + :tg-const:`telegram.constants.InputStoryContentLimit.VIDEOSIZE_UPLOAD` MB. + duration (:class:`datetime.timedelta`): Optional. Precise duration of the video in seconds; + 0-:tg-const:`telegram.constants.InputStoryContentLimit.MAX_VIDEO_DURATION` + cover_frame_timestamp (:class:`datetime.timedelta`): Optional. Timestamp in seconds of the + frame that will be used as the static cover for the story. Defaults to ``0.0``. + is_animation (:obj:`bool`): Optional. Pass :obj:`True` if the video has no sound + """ + + __slots__ = ("cover_frame_timestamp", "duration", "is_animation", "video") + + def __init__( + self, + video: FileInput, + duration: float | dtm.timedelta | None = None, + cover_frame_timestamp: float | dtm.timedelta | None = None, + is_animation: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=InputStoryContent.VIDEO, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.video: str | InputFile = self._parse_file_input(video) + self.duration: dtm.timedelta | None = to_timedelta(duration) + self.cover_frame_timestamp: dtm.timedelta | None = to_timedelta(cover_frame_timestamp) + self.is_animation: bool | None = is_animation diff --git a/telegram/_files/animation.py b/src/telegram/_files/animation.py similarity index 64% rename from telegram/_files/animation.py rename to src/telegram/_files/animation.py index aa7c9160069..ff7fc4d9432 100644 --- a/telegram/_files/animation.py +++ b/src/telegram/_files/animation.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,11 +17,14 @@ # 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 Animation.""" -from typing import Optional + +import datetime as dtm from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class Animation(_BaseThumbedMedium): @@ -30,21 +33,24 @@ class Animation(_BaseThumbedMedium): Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`file_unique_id` is equal. + .. versionchanged:: 20.5 + |removed_thumb_note| + Args: file_id (:obj:`str`): Identifier for this file, which can be used to download 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. - 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. - thumb (:class:`telegram.PhotoSize`, optional): Animation thumbnail as defined by sender. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. - 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` | :class:`datetime.timedelta`, optional): Duration of the video + in seconds as defined by the sender. + + .. versionchanged:: v22.2 + |time-period-input| + 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. @@ -57,11 +63,15 @@ 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` | :class:`datetime.timedelta`): Duration of the video in seconds + as defined by the sender. + + .. deprecated:: v22.2 + |time-period-int-deprecated| + 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. @@ -70,7 +80,7 @@ class Animation(_BaseThumbedMedium): """ - __slots__ = ("duration", "height", "file_name", "mime_type", "width") + __slots__ = ("_duration", "file_name", "height", "mime_type", "width") def __init__( self, @@ -78,20 +88,18 @@ def __init__( file_unique_id: str, width: int, height: int, - duration: int, - thumb: Optional[PhotoSize] = None, - file_name: Optional[str] = None, - mime_type: Optional[str] = None, - file_size: Optional[int] = None, - thumbnail: Optional[PhotoSize] = None, + duration: TimePeriod, + file_name: str | None = None, + mime_type: str | None = None, + file_size: int | None = None, + thumbnail: PhotoSize | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, file_unique_id=file_unique_id, file_size=file_size, - thumb=thumb, api_kwargs=api_kwargs, thumbnail=thumbnail, ) @@ -99,7 +107,13 @@ def __init__( # Required self.width: int = width self.height: int = height - self.duration: int = duration + self._duration: dtm.timedelta = to_timedelta(duration) # Optional - self.mime_type: Optional[str] = mime_type - self.file_name: Optional[str] = file_name + self.mime_type: str | None = mime_type + self.file_name: str | None = file_name + + @property + def duration(self) -> int | dtm.timedelta: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) diff --git a/telegram/_files/audio.py b/src/telegram/_files/audio.py similarity index 64% rename from telegram/_files/audio.py rename to src/telegram/_files/audio.py index f0e315ce11f..6d1db7d67ab 100644 --- a/telegram/_files/audio.py +++ b/src/telegram/_files/audio.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,11 +17,14 @@ # 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 Audio.""" -from typing import Optional + +import datetime as dtm from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class Audio(_BaseThumbedMedium): @@ -30,24 +33,26 @@ class Audio(_BaseThumbedMedium): Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`file_unique_id` is equal. + .. versionchanged:: 20.5 + |removed_thumb_note| + Args: file_id (:obj:`str`): Identifier for this file, which can be used to download 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. - file_size (:obj:`int`, optional): File size in bytes. - thumb (:class:`telegram.PhotoSize`, optional): Thumbnail of the album cover to - which the music file belongs. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the audio in + seconds as defined by the sender. - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. + .. versionchanged:: v22.2 + |time-period-input| + 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. @@ -58,12 +63,16 @@ 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` | :class:`datetime.timedelta`): Duration of the audio in seconds as + defined by the sender. + + .. deprecated:: v22.2 + |time-period-int-deprecated| + 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. @@ -73,36 +82,40 @@ class Audio(_BaseThumbedMedium): """ - __slots__ = ("duration", "file_name", "mime_type", "performer", "title") + __slots__ = ("_duration", "file_name", "mime_type", "performer", "title") def __init__( self, file_id: str, file_unique_id: str, - duration: int, - performer: Optional[str] = None, - title: Optional[str] = None, - mime_type: Optional[str] = None, - file_size: Optional[int] = None, - thumb: Optional[PhotoSize] = None, - file_name: Optional[str] = None, - thumbnail: Optional[PhotoSize] = None, + duration: TimePeriod, + performer: str | None = None, + title: str | None = None, + mime_type: str | None = None, + file_size: int | None = None, + file_name: str | None = None, + thumbnail: PhotoSize | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, file_unique_id=file_unique_id, file_size=file_size, - thumb=thumb, thumbnail=thumbnail, api_kwargs=api_kwargs, ) with self._unfrozen(): # Required - self.duration: int = duration + self._duration: dtm.timedelta = to_timedelta(duration) # Optional - self.performer: Optional[str] = performer - self.title: Optional[str] = title - self.mime_type: Optional[str] = mime_type - self.file_name: Optional[str] = file_name + self.performer: str | None = performer + self.title: str | None = title + self.mime_type: str | None = mime_type + self.file_name: str | None = file_name + + @property + def duration(self) -> int | dtm.timedelta: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) diff --git a/telegram/_files/chatphoto.py b/src/telegram/_files/chatphoto.py similarity index 95% rename from telegram/_files/chatphoto.py rename to src/telegram/_files/chatphoto.py index afc99e07dae..6fe3eca726c 100644 --- a/telegram/_files/chatphoto.py +++ b/src/telegram/_files/chatphoto.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,8 @@ # 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 ChatPhoto.""" -from typing import TYPE_CHECKING, ClassVar, Optional + +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._telegramobject import TelegramObject @@ -74,10 +75,10 @@ class ChatPhoto(TelegramObject): """ __slots__ = ( + "big_file_id", "big_file_unique_id", "small_file_id", "small_file_unique_id", - "big_file_id", ) def __init__( @@ -87,7 +88,7 @@ def __init__( big_file_id: str, big_file_unique_id: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.small_file_id: str = small_file_id @@ -109,7 +110,7 @@ async def get_small_file( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "File": """Convenience wrapper over :meth:`telegram.Bot.get_file` for getting the small (:tg-const:`telegram.ChatPhoto.SIZE_SMALL` x :tg-const:`telegram.ChatPhoto.SIZE_SMALL`) @@ -140,7 +141,7 @@ async def get_big_file( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "File": """Convenience wrapper over :meth:`telegram.Bot.get_file` for getting the big (:tg-const:`telegram.ChatPhoto.SIZE_BIG` x :tg-const:`telegram.ChatPhoto.SIZE_BIG`) @@ -164,12 +165,12 @@ async def get_big_file( api_kwargs=api_kwargs, ) - SIZE_SMALL: ClassVar[int] = constants.ChatPhotoSize.SMALL + SIZE_SMALL: Final[int] = constants.ChatPhotoSize.SMALL """:const:`telegram.constants.ChatPhotoSize.SMALL` .. versionadded:: 20.0 """ - SIZE_BIG: ClassVar[int] = constants.ChatPhotoSize.BIG + SIZE_BIG: Final[int] = constants.ChatPhotoSize.BIG """:const:`telegram.constants.ChatPhotoSize.BIG` .. versionadded:: 20.0 diff --git a/telegram/_files/contact.py b/src/telegram/_files/contact.py similarity index 83% rename from telegram/_files/contact.py rename to src/telegram/_files/contact.py index 9133841174f..c3951c31b8b 100644 --- a/telegram/_files/contact.py +++ b/src/telegram/_files/contact.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # 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 Contact.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -45,26 +44,26 @@ class Contact(TelegramObject): """ - __slots__ = ("vcard", "user_id", "first_name", "last_name", "phone_number") + __slots__ = ("first_name", "last_name", "phone_number", "user_id", "vcard") def __init__( self, phone_number: str, first_name: str, - last_name: Optional[str] = None, - user_id: Optional[int] = None, - vcard: Optional[str] = None, + last_name: str | None = None, + user_id: int | None = None, + vcard: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.phone_number: str = str(phone_number) self.first_name: str = first_name # Optionals - self.last_name: Optional[str] = last_name - self.user_id: Optional[int] = user_id - self.vcard: Optional[str] = vcard + self.last_name: str | None = last_name + self.user_id: int | None = user_id + self.vcard: str | None = vcard self._id_attrs = (self.phone_number,) diff --git a/telegram/_files/document.py b/src/telegram/_files/document.py similarity index 78% rename from telegram/_files/document.py rename to src/telegram/_files/document.py index a16c6900e29..f58fbee95da 100644 --- a/telegram/_files/document.py +++ b/src/telegram/_files/document.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # 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 Document.""" -from typing import Optional from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize @@ -31,19 +30,19 @@ class Document(_BaseThumbedMedium): Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`file_unique_id` is equal. + .. versionchanged:: 20.5 + |removed_thumb_note| + Args: file_id (:obj:`str`): Identifier for this file, which can be used to download 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. - thumb (:class:`telegram.PhotoSize`, optional): Document thumbnail as defined by sender. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. - 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 @@ -52,10 +51,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 @@ -67,23 +67,21 @@ def __init__( self, file_id: str, file_unique_id: str, - thumb: Optional[PhotoSize] = None, - file_name: Optional[str] = None, - mime_type: Optional[str] = None, - file_size: Optional[int] = None, - thumbnail: Optional[PhotoSize] = None, + file_name: str | None = None, + mime_type: str | None = None, + file_size: int | None = None, + thumbnail: PhotoSize | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, file_unique_id=file_unique_id, file_size=file_size, - thumb=thumb, thumbnail=thumbnail, api_kwargs=api_kwargs, ) with self._unfrozen(): # Optional - self.mime_type: Optional[str] = mime_type - self.file_name: Optional[str] = file_name + self.mime_type: str | None = mime_type + self.file_name: str | None = file_name diff --git a/telegram/_files/file.py b/src/telegram/_files/file.py similarity index 86% rename from telegram/_files/file.py rename to src/telegram/_files/file.py index e96860f5f17..3e9d162fd88 100644 --- a/telegram/_files/file.py +++ b/src/telegram/_files/file.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,11 +17,12 @@ # 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 File.""" + import shutil import urllib.parse as urllib_parse from base64 import b64decode from pathlib import Path -from typing import TYPE_CHECKING, BinaryIO, Optional +from typing import TYPE_CHECKING, BinaryIO from telegram._passport.credentials import decrypt from telegram._telegramobject import TelegramObject @@ -74,21 +75,21 @@ class File(TelegramObject): """ __slots__ = ( + "_credentials", "file_id", + "file_path", "file_size", "file_unique_id", - "file_path", - "_credentials", ) def __init__( self, file_id: str, file_unique_id: str, - file_size: Optional[int] = None, - file_path: Optional[str] = None, + file_size: int | None = None, + file_path: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -96,10 +97,10 @@ def __init__( self.file_id: str = str(file_id) self.file_unique_id: str = str(file_unique_id) # Optionals - self.file_size: Optional[int] = file_size - self.file_path: Optional[str] = file_path + self.file_size: int | None = file_size + self.file_path: str | None = file_path - self._credentials: Optional["FileCredentials"] = None + self._credentials: FileCredentials | None = None self._id_attrs = (self.file_unique_id,) @@ -119,7 +120,7 @@ def _prepare_decrypt(self, buf: bytes) -> bytes: async def download_to_drive( self, - custom_path: Optional[FilePathInput] = None, + custom_path: FilePathInput | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -128,9 +129,8 @@ async def download_to_drive( ) -> Path: """ Download this file. By default, the file is saved in the current working directory with - :attr:`file_path` as file name. If the file has no filename, the file ID will be used as - filename. If :paramref:`custom_path` is supplied as a :obj:`str` or :obj:`pathlib.Path`, - it will be saved to that path. + :attr:`file_path` as file name. If :paramref:`custom_path` is supplied as a :obj:`str` or + :obj:`pathlib.Path`, it will be saved to that path. Note: If :paramref:`custom_path` isn't provided and :attr:`file_path` is the path of a @@ -152,6 +152,11 @@ async def download_to_drive( * This method was previously called ``download``. It was split into :meth:`download_to_drive` and :meth:`download_to_memory`. + .. versionchanged:: 21.7 + Raises :exc:`RuntimeError` if :attr:`file_path` is not set. Note that files without + a :attr:`file_path` could never be downloaded, as this attribute is mandatory for that + operation. + Args: custom_path (:class:`pathlib.Path` | :obj:`str` , optional): The path where the file will be saved to. If not specified, will be saved in the current working directory @@ -175,7 +180,13 @@ async def download_to_drive( Returns: :class:`pathlib.Path`: Returns the Path object the file was downloaded to. + Raises: + RuntimeError: If :attr:`file_path` is not set. + """ + if not self.file_path: + raise RuntimeError("No `file_path` available for this file. Can not download.") + local_file = is_local_file(self.file_path) url = None if local_file else self._get_encoded_url() @@ -198,10 +209,8 @@ async def download_to_drive( filename = Path(custom_path) elif local_file: return Path(self.file_path) - elif self.file_path: - filename = Path(Path(self.file_path).name) else: - filename = Path.cwd() / self.file_id + filename = Path(Path(self.file_path).name) buf = await self.get_bot().request.retrieve( url, @@ -231,8 +240,17 @@ async def download_to_memory( .. seealso:: :wiki:`Working with Files and Media ` + Hint: + If you want to immediately read the data from ``out`` after calling this method, you + should call ``out.seek(0)`` first. See also :meth:`io.IOBase.seek`. + .. versionadded:: 20.0 + .. versionchanged:: 21.7 + Raises :exc:`RuntimeError` if :attr:`file_path` is not set. Note that files without + a :attr:`file_path` could never be downloaded, as this attribute is mandatory for that + operation. + Args: out (:obj:`io.BufferedIOBase`): A file-like object. Must be opened for writing in binary mode. @@ -250,7 +268,13 @@ async def download_to_memory( pool_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to :paramref:`telegram.request.BaseRequest.post.pool_timeout`. Defaults to :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. + + Raises: + RuntimeError: If :attr:`file_path` is not set. """ + if not self.file_path: + raise RuntimeError("No `file_path` available for this file. Can not download.") + local_file = is_local_file(self.file_path) url = None if local_file else self._get_encoded_url() path = Path(self.file_path) if local_file else None @@ -270,7 +294,7 @@ async def download_to_memory( async def download_as_bytearray( self, - buf: Optional[bytearray] = None, + buf: bytearray | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -279,6 +303,11 @@ async def download_as_bytearray( ) -> bytearray: """Download this file and return it as a bytearray. + .. versionchanged:: 21.7 + Raises :exc:`RuntimeError` if :attr:`file_path` is not set. Note that files without + a :attr:`file_path` could never be downloaded, as this attribute is mandatory for that + operation. + Args: buf (:obj:`bytearray`, optional): Extend the given bytearray with the downloaded data. @@ -308,7 +337,13 @@ async def download_as_bytearray( :obj:`bytearray`: The same object as :paramref:`buf` if it was specified. Otherwise a newly allocated :obj:`bytearray`. + Raises: + RuntimeError: If :attr:`file_path` is not set. + """ + if not self.file_path: + raise RuntimeError("No `file_path` available for this file. Can not download.") + if buf is None: buf = bytearray() diff --git a/telegram/_files/inputfile.py b/src/telegram/_files/inputfile.py similarity index 60% rename from telegram/_files/inputfile.py rename to src/telegram/_files/inputfile.py index 730301869bd..74423914391 100644 --- a/telegram/_files/inputfile.py +++ b/src/telegram/_files/inputfile.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,10 +19,11 @@ """This module contains an object that represents a Telegram InputFile.""" import mimetypes -from typing import IO, Optional, Union +from typing import IO from uuid import uuid4 -from telegram._utils.files import load_file +from telegram._utils.files import guess_file_name, load_file +from telegram._utils.strings import TextEncoding from telegram._utils.types import FieldTuple _DEFAULT_MIME_TYPE = "application/octet-stream" @@ -52,9 +53,36 @@ class InputFile: attach (:obj:`bool`, optional): Pass :obj:`True` if the parameter this file belongs to in the request to Telegram should point to the multipart data via an ``attach://`` URI. Defaults to `False`. + read_file_handle (:obj:`bool`, optional): If :obj:`True` and :paramref:`obj` is a file + handle, the data will be read from the file handle on initialization of this object. + If :obj:`False`, the file handle will be passed on to the + :attr:`networking backend ` which will have + to handle the reading. Defaults to :obj:`True`. + + Tip: + If you upload extremely large files, you may want to set this to :obj:`False` to + avoid reading the complete file into memory. Additionally, this may be supported + better by the networking backend (in particular it is handled better by + the default :class:`~telegram.request.HTTPXRequest`). + + Important: + If you set this to :obj:`False`, you have to ensure that the file handle is still + open when the request is made. In particular, the following snippet can *not* work + as expected. + + .. code-block:: python + + with open('file.txt', 'rb') as file: + input_file = InputFile(file, read_file_handle=False) + + # here the file handle is already closed and the upload will fail + await bot.send_document(chat_id, input_file) + + .. versionadded:: 21.5 + Attributes: - input_file_content (:obj:`bytes`): The binary content of the file to send. + input_file_content (:obj:`bytes` | :class:`IO`): The binary content of the file to send. attach_name (:obj:`str`): Optional. If present, the parameter this file belongs to in the request to Telegram should point to the multipart data via a an URI of the form ``attach://`` URI. @@ -63,23 +91,27 @@ class InputFile: """ - __slots__ = ("filename", "attach_name", "input_file_content", "mimetype") + __slots__ = ("attach_name", "filename", "input_file_content", "mimetype") def __init__( self, - obj: Union[IO[bytes], bytes, str], - filename: Optional[str] = None, + obj: IO[bytes] | bytes | str, + filename: str | None = None, attach: bool = False, + read_file_handle: bool = True, ): if isinstance(obj, bytes): - self.input_file_content: bytes = obj + self.input_file_content: bytes | IO[bytes] = obj elif isinstance(obj, str): - self.input_file_content = obj.encode("utf-8") - else: + self.input_file_content = obj.encode(TextEncoding.UTF_8) + elif read_file_handle: reported_filename, self.input_file_content = load_file(obj) filename = filename or reported_filename + else: + self.input_file_content = obj + filename = filename or guess_file_name(obj) - self.attach_name: Optional[str] = "attached" + uuid4().hex if attach else None + self.attach_name: str | None = "attached" + uuid4().hex if attach else None if filename: self.mimetype: str = ( @@ -94,13 +126,16 @@ def __init__( def field_tuple(self) -> FieldTuple: """Field tuple representing the contents of the file for upload to the Telegram servers. + .. versionchanged:: 21.5 + Content may now be a file handle. + Returns: - Tuple[:obj:`str`, :obj:`bytes`, :obj:`str`]: + tuple[:obj:`str`, :obj:`bytes` | :class:`IO`, :obj:`str`]: """ return self.filename, self.input_file_content, self.mimetype @property - def attach_uri(self) -> Optional[str]: + def attach_uri(self) -> str | None: """URI to insert into the JSON data for uploading the file. Returns :obj:`None`, if :attr:`attach_name` is :obj:`None`. """ diff --git a/telegram/_files/inputmedia.py b/src/telegram/_files/inputmedia.py similarity index 52% rename from telegram/_files/inputmedia.py rename to src/telegram/_files/inputmedia.py index 1c978ebf315..23b6620985a 100644 --- a/telegram/_files/inputmedia.py +++ b/src/telegram/_files/inputmedia.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,12 @@ # 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 +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING, Final, TypeAlias + +from telegram import constants from telegram._files.animation import Animation from telegram._files.audio import Audio from telegram._files.document import Document @@ -27,17 +31,18 @@ from telegram._files.video import Video from telegram._messageentity import MessageEntity from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils import enum +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.files import parse_file_input -from telegram._utils.types import FileInput, JSONDict, ODVInput -from telegram._utils.warnings_transition import ( - warn_about_deprecated_attr_in_property, - warn_about_thumb_return_thumbnail, -) +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InputMediaType -MediaType = Union[Animation, Audio, Document, PhotoSize, Video] +if TYPE_CHECKING: + from telegram._utils.types import FileInput + +MediaType: TypeAlias = Animation | Audio | Document | PhotoSize | Video class InputMedia(TelegramObject): @@ -52,13 +57,8 @@ class InputMedia(TelegramObject): Args: media_type (:obj:`str`): Type of media that the instance represents. - media (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ - :class:`telegram.Animation` | :class:`telegram.Audio` | \ - :class:`telegram.Document` | :class:`telegram.PhotoSize` | \ - :class:`telegram.Video`): File to send. + media (:obj:`str` | :class:`~telegram.InputFile`): File to send. |fileinputnopath| - Lastly you can pass an existing telegram media object of the corresponding type - to send. caption (:obj:`str`, optional): Caption of the media to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -76,7 +76,7 @@ class InputMedia(TelegramObject): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 @@ -90,44 +90,232 @@ class InputMedia(TelegramObject): def __init__( self, media_type: str, - media: Union[str, InputFile, MediaType], - caption: Optional[str] = None, - caption_entities: Optional[Sequence[MessageEntity]] = None, + media: str | InputFile, + caption: str | None = None, + caption_entities: Sequence[MessageEntity] | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) - self.type: str = media_type - self.media: Union[str, InputFile, Animation, Audio, Document, PhotoSize, Video] = media - self.caption: Optional[str] = caption - self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.type: str = enum.get_member(constants.InputMediaType, media_type, media_type) + self.media: str | InputFile = media + self.caption: str | None = caption + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) self.parse_mode: ODVInput[str] = parse_mode self._freeze() @staticmethod - def _parse_thumb_input(thumb: Optional[FileInput]) -> Optional[Union[str, InputFile]]: + def _parse_thumbnail_input(thumbnail: "FileInput | None") -> str | InputFile | None: # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. return ( - parse_file_input(thumb, attach=True, local_mode=True) if thumb is not None else thumb + parse_file_input(thumbnail, attach=True, local_mode=True) + if thumbnail is not None + else thumbnail ) +class InputPaidMedia(TelegramObject): + """ + Base class for Telegram InputPaidMedia Objects. Currently, it can be one of: + + * :class:`telegram.InputPaidMediaPhoto` + * :class:`telegram.InputPaidMediaVideo` + + .. seealso:: :wiki:`Working with Files and Media ` + + .. versionadded:: 21.4 + + Args: + type (:obj:`str`): Type of media that the instance represents. + media (:obj:`str` | :class:`~telegram.InputFile`): File + to send. |fileinputnopath| + + 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: str | InputFile, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.InputPaidMediaType, type, type) + self.media: str | InputFile = 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` | :class:`~telegram.InputFile` | :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: "FileInput | PhotoSize", + *, + api_kwargs: JSONDict | None = 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` | :class:`~telegram.InputFile` | :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| + cover (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ + optional): Cover for the video in the message. |fileinputnopath| + + .. versionchanged:: 21.11 + start_timestamp (:obj:`int`, optional): Start timestamp for the video in the message + + .. versionchanged:: 21.11 + width (:obj:`int`, optional): Video width. + height (:obj:`int`, optional): Video height. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Video duration in seconds. + + .. versionchanged:: v22.2 + |time-period-input| + 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| + cover (:class:`telegram.InputFile`): Optional. Cover for the video in the message. + |fileinputnopath| + + .. versionchanged:: 21.11 + start_timestamp (:obj:`int`): Optional. Start timestamp for the video in the message + + .. versionchanged:: 21.11 + width (:obj:`int`): Optional. Video width. + height (:obj:`int`): Optional. Video height. + duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Video duration in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| + supports_streaming (:obj:`bool`): Optional. :obj:`True`, if the uploaded video is + suitable for streaming. + """ + + __slots__ = ( + "_duration", + "cover", + "height", + "start_timestamp", + "supports_streaming", + "thumbnail", + "width", + ) + + def __init__( + self, + media: "FileInput | Video", + thumbnail: "FileInput | None" = None, + width: int | None = None, + height: int | None = None, + duration: TimePeriod | None = None, + supports_streaming: bool | None = None, + cover: "FileInput | None" = None, + start_timestamp: int | None = None, + *, + api_kwargs: JSONDict | None = 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: str | InputFile | None = InputMedia._parse_thumbnail_input(thumbnail) + self.width: int | None = width + self.height: int | None = height + self._duration: dtm.timedelta | None = to_timedelta(duration) + self.supports_streaming: bool | None = supports_streaming + self.cover: InputFile | str | None = ( + parse_file_input(cover, attach=True, local_mode=True) if cover else None + ) + self.start_timestamp: int | None = start_timestamp + + @property + def duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._duration, attribute="duration") + + 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 ` + .. versionchanged:: 20.5 + |removed_thumb_note| + Args: - media (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ - :class:`telegram.Animation`): File to send. |fileinputnopath| + media (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ + :class:`pathlib.Path` | :class:`telegram.Animation`): File to send. |fileinputnopath| Lastly you can pass an existing :class:`telegram.Animation` object to send. .. versionchanged:: 13.2 @@ -137,14 +325,6 @@ class InputMediaAnimation(InputMedia): :obj:`tempfile` module. .. versionadded:: 13.1 - thumb (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ - optional): |thumbdocstringnopath| - - .. versionchanged:: 13.2 - Accept :obj:`bytes` as input. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. caption (:obj:`str`, optional): Caption of the animation to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -156,7 +336,11 @@ class InputMediaAnimation(InputMedia): width (:obj:`int`, optional): Animation width. height (:obj:`int`, optional): Animation height. - duration (:obj:`int`, optional): Animation duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Animation duration + in seconds. + + .. versionchanged:: v22.2 + |time-period-input| has_spoiler (:obj:`bool`, optional): Pass :obj:`True`, if the animation needs to be covered with a spoiler animation. @@ -165,6 +349,9 @@ class InputMediaAnimation(InputMedia): optional): |thumbdocstringnopath| .. versionadded:: 20.2 + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + + .. versionadded:: 21.3 Attributes: type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.ANIMATION`. @@ -173,7 +360,7 @@ class InputMediaAnimation(InputMedia): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. The parse mode to use for text formatting. - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 @@ -181,7 +368,11 @@ class InputMediaAnimation(InputMedia): * |alwaystuple| width (:obj:`int`): Optional. Animation width. height (:obj:`int`): Optional. Animation height. - duration (:obj:`int`): Optional. Animation duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Animation duration + in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| has_spoiler (:obj:`bool`): Optional. :obj:`True`, if the animation is covered with a spoiler animation. @@ -189,37 +380,46 @@ class InputMediaAnimation(InputMedia): thumbnail (:class:`telegram.InputFile`): Optional. |thumbdocstringbase| .. versionadded:: 20.2 + show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med| + + .. versionadded:: 21.3 """ - __slots__ = ("duration", "height", "width", "has_spoiler", "thumbnail") + __slots__ = ( + "_duration", + "has_spoiler", + "height", + "show_caption_above_media", + "thumbnail", + "width", + ) def __init__( self, - media: Union[FileInput, Animation], - thumb: Optional[FileInput] = None, - caption: Optional[str] = None, + media: "FileInput | Animation", + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - width: Optional[int] = None, - height: Optional[int] = None, - duration: Optional[int] = None, - caption_entities: Optional[Sequence[MessageEntity]] = None, - filename: Optional[str] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, + width: int | None = None, + height: int | None = None, + duration: TimePeriod | None = None, + caption_entities: Sequence[MessageEntity] | None = None, + filename: str | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): if isinstance(media, Animation): width = media.width if width is None else width height = media.height if height is None else height - duration = media.duration if duration is None else duration + 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, filename=filename, attach=True, local_mode=True) - thumbnail = warn_about_thumb_return_thumbnail(deprecated_arg=thumb, new_arg=thumbnail) super().__init__( InputMediaType.ANIMATION, media, @@ -229,25 +429,16 @@ def __init__( api_kwargs=api_kwargs, ) with self._unfrozen(): - self.thumbnail: Optional[Union[str, InputFile]] = self._parse_thumb_input(thumbnail) - self.width: Optional[int] = width - self.height: Optional[int] = height - self.duration: Optional[int] = duration - self.has_spoiler: Optional[bool] = has_spoiler + self.thumbnail: str | InputFile | None = self._parse_thumbnail_input(thumbnail) + self.width: int | None = width + self.height: int | None = height + self._duration: dtm.timedelta | None = to_timedelta(duration) + self.has_spoiler: bool | None = has_spoiler + self.show_caption_above_media: bool | None = show_caption_above_media @property - def thumb(self) -> Optional[Union[str, InputFile]]: - """:class:`telegram.InputFile`: Optional. |thumbdocstringbase| - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb", - new_attr_name="thumbnail", - bot_api_version="6.6", - ) - return self.thumbnail + def duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._duration, attribute="duration") class InputMediaPhoto(InputMedia): @@ -256,8 +447,8 @@ class InputMediaPhoto(InputMedia): .. seealso:: :wiki:`Working with Files and Media ` Args: - media (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ - :class:`telegram.PhotoSize`): File to send. |fileinputnopath| + media (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ + :class:`pathlib.Path` | :class:`telegram.PhotoSize`): File to send. |fileinputnopath| Lastly you can pass an existing :class:`telegram.PhotoSize` object to send. .. versionchanged:: 13.2 @@ -279,6 +470,9 @@ class InputMediaPhoto(InputMedia): with a spoiler animation. .. versionadded:: 20.0 + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + + .. versionadded:: 21.3 Attributes: type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.PHOTO`. @@ -287,7 +481,7 @@ class InputMediaPhoto(InputMedia): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 @@ -297,20 +491,27 @@ class InputMediaPhoto(InputMedia): spoiler animation. .. versionadded:: 20.0 + show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med| + + .. versionadded:: 21.3 """ - __slots__ = ("has_spoiler",) + __slots__ = ( + "has_spoiler", + "show_caption_above_media", + ) def __init__( self, - media: Union[FileInput, PhotoSize], - caption: Optional[str] = None, + media: "FileInput | PhotoSize", + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, - filename: Optional[str] = None, - has_spoiler: Optional[bool] = None, + caption_entities: Sequence[MessageEntity] | None = None, + filename: str | None = None, + has_spoiler: bool | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. @@ -325,7 +526,8 @@ def __init__( ) with self._unfrozen(): - self.has_spoiler: Optional[bool] = has_spoiler + self.has_spoiler: bool | None = has_spoiler + self.show_caption_above_media: bool | None = show_caption_above_media class InputMediaVideo(InputMedia): @@ -337,13 +539,16 @@ class InputMediaVideo(InputMedia): * 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:`thumb` 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. + * :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. + + .. versionchanged:: 20.5 + |removed_thumb_note| Args: - media (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ - :class:`telegram.Video`): File to send. |fileinputnopath| + media (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ + :class:`pathlib.Path` | :class:`telegram.Video`): File to send. |fileinputnopath| Lastly you can pass an existing :class:`telegram.Video` object to send. .. versionchanged:: 13.2 @@ -364,17 +569,12 @@ class InputMediaVideo(InputMedia): width (:obj:`int`, optional): Video width. height (:obj:`int`, optional): Video height. - duration (:obj:`int`, optional): Video duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Video duration in seconds. + + .. versionchanged:: v22.2 + |time-period-input| supports_streaming (:obj:`bool`, optional): Pass :obj:`True`, if the uploaded video is suitable for streaming. - thumb (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ - optional): |thumbdocstringnopath| - - .. versionchanged:: 13.2 - Accept :obj:`bytes` as input. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. has_spoiler (:obj:`bool`, optional): Pass :obj:`True`, if the video needs to be covered with a spoiler animation. @@ -383,6 +583,16 @@ class InputMediaVideo(InputMedia): optional): |thumbdocstringnopath| .. versionadded:: 20.2 + cover (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ + optional): Cover for the video in the message. |fileinputnopath| + + .. versionchanged:: 21.11 + start_timestamp (:obj:`int`, optional): Start timestamp for the video in the message + + .. versionchanged:: 21.11 + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + + .. versionadded:: 21.3 Attributes: type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.VIDEO`. @@ -391,7 +601,7 @@ class InputMediaVideo(InputMedia): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 @@ -399,7 +609,10 @@ class InputMediaVideo(InputMedia): * |alwaystuple| width (:obj:`int`): Optional. Video width. height (:obj:`int`): Optional. Video height. - duration (:obj:`int`): Optional. Video duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Video duration in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| supports_streaming (:obj:`bool`): Optional. :obj:`True`, if the uploaded video is suitable for streaming. has_spoiler (:obj:`bool`): Optional. :obj:`True`, if the video is covered with a @@ -409,45 +622,59 @@ class InputMediaVideo(InputMedia): thumbnail (:class:`telegram.InputFile`): Optional. |thumbdocstringbase| .. versionadded:: 20.2 + show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med| + + .. versionadded:: 21.3 + cover (:class:`telegram.InputFile`): Optional. Cover for the video in the message. + |fileinputnopath| + + .. versionchanged:: 21.11 + start_timestamp (:obj:`int`): Optional. Start timestamp for the video in the message + + .. versionchanged:: 21.11 """ __slots__ = ( - "duration", + "_duration", + "cover", + "has_spoiler", "height", + "show_caption_above_media", + "start_timestamp", "supports_streaming", - "width", - "has_spoiler", "thumbnail", + "width", ) def __init__( self, - media: Union[FileInput, Video], - caption: Optional[str] = None, - width: Optional[int] = None, - height: Optional[int] = None, - duration: Optional[int] = None, - supports_streaming: Optional[bool] = None, + media: "FileInput | Video", + caption: str | None = None, + width: int | None = None, + height: int | None = None, + duration: TimePeriod | None = None, + supports_streaming: bool | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - thumb: Optional[FileInput] = None, - caption_entities: Optional[Sequence[MessageEntity]] = None, - filename: Optional[str] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, + caption_entities: Sequence[MessageEntity] | None = None, + filename: str | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + show_caption_above_media: bool | None = None, + cover: "FileInput | None" = None, + start_timestamp: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = 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 + 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, filename=filename, attach=True, local_mode=True) - thumbnail = warn_about_thumb_return_thumbnail(deprecated_arg=thumb, new_arg=thumbnail) super().__init__( InputMediaType.VIDEO, media, @@ -457,26 +684,21 @@ def __init__( api_kwargs=api_kwargs, ) with self._unfrozen(): - self.width: Optional[int] = width - self.height: Optional[int] = height - self.duration: Optional[int] = duration - self.thumbnail: Optional[Union[str, InputFile]] = self._parse_thumb_input(thumbnail) - self.supports_streaming: Optional[bool] = supports_streaming - self.has_spoiler: Optional[bool] = has_spoiler + self.width: int | None = width + self.height: int | None = height + self._duration: dtm.timedelta | None = to_timedelta(duration) + self.thumbnail: str | InputFile | None = self._parse_thumbnail_input(thumbnail) + self.supports_streaming: bool | None = supports_streaming + self.has_spoiler: bool | None = has_spoiler + self.show_caption_above_media: bool | None = show_caption_above_media + self.cover: InputFile | str | None = ( + parse_file_input(cover, attach=True, local_mode=True) if cover else None + ) + self.start_timestamp: int | None = start_timestamp @property - def thumb(self) -> Optional[Union[str, InputFile]]: - """:class:`telegram.InputFile`: Optional. |thumbdocstringbase| - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb", - new_attr_name="thumbnail", - bot_api_version="6.6", - ) - return self.thumbnail + def duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._duration, attribute="duration") class InputMediaAudio(InputMedia): @@ -489,9 +711,12 @@ class InputMediaAudio(InputMedia): duration, performer and title from that video, unless otherwise specified with the optional arguments. + .. versionchanged:: 20.5 + |removed_thumb_note| + Args: - media (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ - :class:`telegram.Audio`): File to send. |fileinputnopath| + media (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ + :class:`pathlib.Path` | :class:`telegram.Audio`): File to send. |fileinputnopath| Lastly you can pass an existing :class:`telegram.Audio` object to send. .. versionchanged:: 13.2 @@ -510,18 +735,14 @@ 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. - thumb (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ - optional): |thumbdocstringnopath| - - .. versionchanged:: 13.2 - Accept :obj:`bytes` as input. + duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of the audio + in seconds as defined by the sender. - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. + .. versionchanged:: v22.2 + |time-period-input| + 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| @@ -534,41 +755,44 @@ class InputMediaAudio(InputMedia): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 * |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. + duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Duration of the audio + in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| + 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 """ - __slots__ = ("duration", "performer", "title", "thumbnail") + __slots__ = ("_duration", "performer", "thumbnail", "title") def __init__( self, - media: Union[FileInput, Audio], - thumb: Optional[FileInput] = None, - caption: Optional[str] = None, + media: "FileInput | Audio", + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - duration: Optional[int] = None, - performer: Optional[str] = None, - title: Optional[str] = None, - caption_entities: Optional[Sequence[MessageEntity]] = None, - filename: Optional[str] = None, - thumbnail: Optional[FileInput] = None, + duration: TimePeriod | None = None, + performer: str | None = None, + title: str | None = None, + caption_entities: Sequence[MessageEntity] | None = None, + filename: str | None = None, + thumbnail: "FileInput | None" = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): if isinstance(media, Audio): - duration = media.duration if duration is None else duration + duration = duration if duration is not None else media._duration performer = media.performer if performer is None else performer title = media.title if title is None else title media = media.file_id @@ -577,7 +801,6 @@ def __init__( # things to work in local mode. media = parse_file_input(media, filename=filename, attach=True, local_mode=True) - thumbnail = warn_about_thumb_return_thumbnail(deprecated_arg=thumb, new_arg=thumbnail) super().__init__( InputMediaType.AUDIO, media, @@ -587,24 +810,14 @@ def __init__( api_kwargs=api_kwargs, ) with self._unfrozen(): - self.thumbnail: Optional[Union[str, InputFile]] = self._parse_thumb_input(thumbnail) - self.duration: Optional[int] = duration - self.title: Optional[str] = title - self.performer: Optional[str] = performer + self.thumbnail: str | InputFile | None = self._parse_thumbnail_input(thumbnail) + self._duration: dtm.timedelta | None = to_timedelta(duration) + self.title: str | None = title + self.performer: str | None = performer @property - def thumb(self) -> Optional[Union[str, InputFile]]: - """:class:`telegram.InputFile`: Optional. |thumbdocstringbase| - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb", - new_attr_name="thumbnail", - bot_api_version="6.6", - ) - return self.thumbnail + def duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._duration, attribute="duration") class InputMediaDocument(InputMedia): @@ -612,9 +825,12 @@ class InputMediaDocument(InputMedia): .. seealso:: :wiki:`Working with Files and Media ` + .. versionchanged:: 20.5 + |removed_thumb_note| + Args: - media (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | \ - :class:`telegram.Document`): File to send. |fileinputnopath| + media (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` \ + | :class:`pathlib.Path` | :class:`telegram.Document`): File to send. |fileinputnopath| Lastly you can pass an existing :class:`telegram.Document` object to send. .. versionchanged:: 13.2 @@ -633,14 +849,6 @@ class InputMediaDocument(InputMedia): .. versionchanged:: 20.0 |sequenceclassargs| - thumb (:term:`file object` | :obj:`bytes` | :class:`pathlib.Path` | :obj:`str`, \ - optional): |thumbdocstringnopath| - - .. versionchanged:: 13.2 - Accept :obj:`bytes` as input. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. disable_content_type_detection (:obj:`bool`, optional): Disables automatic server-side content type detection for files uploaded using multipart/form-data. Always :obj:`True`, if the document is sent as part of an album. @@ -656,7 +864,7 @@ class InputMediaDocument(InputMedia): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 @@ -674,22 +882,20 @@ class InputMediaDocument(InputMedia): def __init__( self, - media: Union[FileInput, Document], - thumb: Optional[FileInput] = None, - caption: Optional[str] = None, + media: "FileInput | Document", + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_content_type_detection: Optional[bool] = None, - caption_entities: Optional[Sequence[MessageEntity]] = None, - filename: Optional[str] = None, - thumbnail: Optional[FileInput] = None, + disable_content_type_detection: bool | None = None, + caption_entities: Sequence[MessageEntity] | None = None, + filename: str | None = None, + thumbnail: "FileInput | None" = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # 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, Document, filename=filename, attach=True, local_mode=True) - thumbnail = warn_about_thumb_return_thumbnail(deprecated_arg=thumb, new_arg=thumbnail) super().__init__( InputMediaType.DOCUMENT, media, @@ -699,19 +905,5 @@ def __init__( api_kwargs=api_kwargs, ) with self._unfrozen(): - self.thumbnail: Optional[Union[str, InputFile]] = self._parse_thumb_input(thumbnail) - self.disable_content_type_detection: Optional[bool] = disable_content_type_detection - - @property - def thumb(self) -> Optional[Union[str, InputFile]]: - """:class:`telegram.InputFile`: Optional. |thumbdocstringbase| - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb", - new_attr_name="thumbnail", - bot_api_version="6.6", - ) - return self.thumbnail + self.thumbnail: str | InputFile | None = self._parse_thumbnail_input(thumbnail) + self.disable_content_type_detection: bool | None = disable_content_type_detection diff --git a/src/telegram/_files/inputprofilephoto.py b/src/telegram/_files/inputprofilephoto.py new file mode 100644 index 00000000000..130d061020f --- /dev/null +++ b/src/telegram/_files/inputprofilephoto.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 objects that represents a InputProfilePhoto and subclasses.""" + +import datetime as dtm +from typing import TYPE_CHECKING + +from telegram import constants +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.files import parse_file_input +from telegram._utils.types import FileInput, JSONDict + +if TYPE_CHECKING: + from telegram import InputFile + + +class InputProfilePhoto(TelegramObject): + """This object describes a profile photo to set. Currently, it can be one of + + * :class:`InputProfilePhotoStatic` + * :class:`InputProfilePhotoAnimated` + + .. versionadded:: 22.1 + + Args: + type (:obj:`str`): Type of the profile photo. + + Attributes: + type (:obj:`str`): Type of the profile photo. + + """ + + STATIC = constants.InputProfilePhotoType.STATIC + """:obj:`str`: :tg-const:`telegram.constants.InputProfilePhotoType.STATIC`.""" + ANIMATED = constants.InputProfilePhotoType.ANIMATED + """:obj:`str`: :tg-const:`telegram.constants.InputProfilePhotoType.ANIMATED`.""" + + __slots__ = ("type",) + + def __init__( + self, + type: str, # pylint: disable=redefined-builtin + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.InputProfilePhotoType, type, type) + + self._freeze() + + +class InputProfilePhotoStatic(InputProfilePhoto): + """A static profile photo in the .JPG format. + + .. versionadded:: 22.1 + + Args: + photo (:term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ + :class:`pathlib.Path`): The static profile photo. |uploadinputnopath| + + Attributes: + type (:obj:`str`): :tg-const:`telegram.constants.InputProfilePhotoType.STATIC`. + photo (:class:`telegram.InputFile` | :obj:`str`): The static profile photo. + + """ + + __slots__ = ("photo",) + + def __init__( + self, + photo: FileInput, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=constants.InputProfilePhotoType.STATIC, api_kwargs=api_kwargs) + with self._unfrozen(): + # We use local_mode=True because we don't have access to the actual setting and want + # things to work in local mode. + self.photo: str | InputFile = parse_file_input(photo, attach=True, local_mode=True) + + +class InputProfilePhotoAnimated(InputProfilePhoto): + """An animated profile photo in the MPEG4 format. + + .. versionadded:: 22.1 + + Args: + animation (:term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ + :class:`pathlib.Path`): The animated profile photo. |uploadinputnopath| + main_frame_timestamp (:class:`datetime.timedelta` | :obj:`int` | :obj:`float`, optional): + Timestamp in seconds of the frame that will be used as the static profile photo. + Defaults to ``0.0``. + + Attributes: + type (:obj:`str`): :tg-const:`telegram.constants.InputProfilePhotoType.ANIMATED`. + animation (:class:`telegram.InputFile` | :obj:`str`): The animated profile photo. + main_frame_timestamp (:class:`datetime.timedelta`): Optional. Timestamp in seconds of the + frame that will be used as the static profile photo. Defaults to ``0.0``. + """ + + __slots__ = ("animation", "main_frame_timestamp") + + def __init__( + self, + animation: FileInput, + main_frame_timestamp: float | dtm.timedelta | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=constants.InputProfilePhotoType.ANIMATED, api_kwargs=api_kwargs) + with self._unfrozen(): + # We use local_mode=True because we don't have access to the actual setting and want + # things to work in local mode. + self.animation: str | InputFile = parse_file_input( + animation, attach=True, local_mode=True + ) + + self.main_frame_timestamp: dtm.timedelta | None = to_timedelta(main_frame_timestamp) diff --git a/telegram/_files/inputsticker.py b/src/telegram/_files/inputsticker.py similarity index 60% rename from telegram/_files/inputsticker.py rename to src/telegram/_files/inputsticker.py index da78bb00f3d..38fafc4de3d 100644 --- a/telegram/_files/inputsticker.py +++ b/src/telegram/_files/inputsticker.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,15 +18,18 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram InputSticker.""" -from typing import Optional, Sequence, Tuple, Union +from collections.abc import Sequence +from typing import TYPE_CHECKING -from telegram._files.inputfile import InputFile from telegram._files.sticker import MaskPosition from telegram._telegramobject import TelegramObject from telegram._utils.argumentparsing import parse_sequence_arg from telegram._utils.files import parse_file_input from telegram._utils.types import FileInput, JSONDict +if TYPE_CHECKING: + from telegram._files.inputfile import InputFile + class InputSticker(TelegramObject): """ @@ -34,15 +37,20 @@ class InputSticker(TelegramObject): .. versionadded:: 20.2 + .. versionchanged:: 21.1 + As of Bot API 7.2, the new argument :paramref:`format` is a required argument, and thus the + order of the arguments has changed. + Args: - sticker (:obj:`str` | :term:`file object` | :obj:`bytes` | :class:`pathlib.Path`): The + sticker (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` \ + | :class:`pathlib.Path`): The added sticker. |uploadinputnopath| Animated and video stickers can't be uploaded via HTTP URL. emoji_list (Sequence[:obj:`str`]): Sequence of :tg-const:`telegram.constants.StickerLimit.MIN_STICKER_EMOJI` - :tg-const:`telegram.constants.StickerLimit.MAX_STICKER_EMOJI` emoji associated with the sticker. - mask_position (:obj:`telegram.MaskPosition`, optional): Position where the mask should be + mask_position (:class:`telegram.MaskPosition`, optional): Position where the mask should be placed on faces. For ":tg-const:`telegram.constants.StickerType.MASK`" stickers only. keywords (Sequence[:obj:`str`], optional): Sequence of 0-:tg-const:`telegram.constants.StickerLimit.MAX_SEARCH_KEYWORDS` search keywords @@ -50,46 +58,62 @@ class InputSticker(TelegramObject): :tg-const:`telegram.constants.StickerLimit.MAX_KEYWORD_LENGTH` characters. For ":tg-const:`telegram.constants.StickerType.REGULAR`" and ":tg-const:`telegram.constants.StickerType.CUSTOM_EMOJI`" stickers only. + format (:obj:`str`): Format of the added sticker, must be one of + :tg-const:`telegram.constants.StickerFormat.STATIC` for a + ``.WEBP`` or ``.PNG`` image, :tg-const:`telegram.constants.StickerFormat.ANIMATED` + for a ``.TGS`` animation, :tg-const:`telegram.constants.StickerFormat.VIDEO` for a + ``.WEBM`` video. + + .. versionadded:: 21.1 Attributes: sticker (:obj:`str` | :class:`telegram.InputFile`): The added sticker. - emoji_list (Tuple[:obj:`str`]): Tuple of + emoji_list (tuple[:obj:`str`]): Tuple of :tg-const:`telegram.constants.StickerLimit.MIN_STICKER_EMOJI` - :tg-const:`telegram.constants.StickerLimit.MAX_STICKER_EMOJI` emoji associated with the sticker. - mask_position (:obj:`telegram.MaskPosition`): Optional. Position where the mask should be + mask_position (:class:`telegram.MaskPosition`): Optional. Position where the mask should be placed on faces. For ":tg-const:`telegram.constants.StickerType.MASK`" stickers only. - keywords (Tuple[:obj:`str`]): Optional. Tuple of + keywords (tuple[:obj:`str`]): Optional. Tuple of 0-:tg-const:`telegram.constants.StickerLimit.MAX_SEARCH_KEYWORDS` search keywords for the sticker with the total length of up to :tg-const:`telegram.constants.StickerLimit.MAX_KEYWORD_LENGTH` characters. For ":tg-const:`telegram.constants.StickerType.REGULAR`" and ":tg-const:`telegram.constants.StickerType.CUSTOM_EMOJI`" stickers only. + ":tg-const:`telegram.constants.StickerType.CUSTOM_EMOJI`" stickers only. + format (:obj:`str`): Format of the added sticker, must be one of + :tg-const:`telegram.constants.StickerFormat.STATIC` for a + ``.WEBP`` or ``.PNG`` image, :tg-const:`telegram.constants.StickerFormat.ANIMATED` + for a ``.TGS`` animation, :tg-const:`telegram.constants.StickerFormat.VIDEO` for a + ``.WEBM`` video. + .. versionadded:: 21.1 """ - __slots__ = ("sticker", "emoji_list", "mask_position", "keywords") + __slots__ = ("emoji_list", "format", "keywords", "mask_position", "sticker") def __init__( self, sticker: FileInput, emoji_list: Sequence[str], - mask_position: Optional[MaskPosition] = None, - keywords: Optional[Sequence[str]] = None, + format: str, # pylint: disable=redefined-builtin + mask_position: MaskPosition | None = None, + keywords: Sequence[str] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. - self.sticker: Union[str, InputFile] = parse_file_input( + self.sticker: str | InputFile = parse_file_input( sticker, local_mode=True, attach=True, ) - self.emoji_list: Tuple[str, ...] = parse_sequence_arg(emoji_list) - self.mask_position: Optional[MaskPosition] = mask_position - self.keywords: Tuple[str, ...] = parse_sequence_arg(keywords) + self.emoji_list: tuple[str, ...] = parse_sequence_arg(emoji_list) + self.format: str = format + self.mask_position: MaskPosition | None = mask_position + self.keywords: tuple[str, ...] = parse_sequence_arg(keywords) self._freeze() diff --git a/telegram/_files/location.py b/src/telegram/_files/location.py similarity index 62% rename from telegram/_files/location.py rename to src/telegram/_files/location.py index 1666a8bd154..14e1b7afc0c 100644 --- a/telegram/_files/location.py +++ b/src/telegram/_files/location.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,11 +18,14 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Location.""" -from typing import ClassVar, Optional +import datetime as dtm +from typing import Final from telegram import constants from telegram._telegramobject import TelegramObject -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class Location(TelegramObject): @@ -32,12 +35,16 @@ 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 - the location can be updated, in seconds. For active live locations only. + live_period (:obj:`int` | :class:`datetime.timedelta`, optional): Time relative to the + message sending date, during which the location can be updated, in seconds. For active + live locations only. + + .. versionchanged:: v22.2 + |time-period-input| heading (:obj:`int`, optional): The direction in which user is moving, in degrees; :tg-const:`telegram.Location.MIN_HEADING`-:tg-const:`telegram.Location.MAX_HEADING`. For active live locations only. @@ -45,12 +52,16 @@ 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 - the location can be updated, in seconds. For active live locations only. + live_period (:obj:`int` | :class:`datetime.timedelta`): Optional. Time relative to the + message sending date, during which the location can be updated, in seconds. For active + live locations only. + + .. deprecated:: v22.2 + |time-period-int-deprecated| heading (:obj:`int`): Optional. The direction in which user is moving, in degrees; :tg-const:`telegram.Location.MIN_HEADING`-:tg-const:`telegram.Location.MAX_HEADING`. For active live locations only. @@ -60,24 +71,24 @@ class Location(TelegramObject): """ __slots__ = ( - "longitude", + "_live_period", + "heading", "horizontal_accuracy", - "proximity_alert_radius", - "live_period", "latitude", - "heading", + "longitude", + "proximity_alert_radius", ) def __init__( self, longitude: float, latitude: float, - horizontal_accuracy: Optional[float] = None, - live_period: Optional[int] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, + horizontal_accuracy: float | None = None, + live_period: TimePeriod | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -85,10 +96,10 @@ def __init__( self.latitude: float = latitude # Optionals - self.horizontal_accuracy: Optional[float] = horizontal_accuracy - self.live_period: Optional[int] = live_period - self.heading: Optional[int] = heading - self.proximity_alert_radius: Optional[int] = ( + self.horizontal_accuracy: float | None = horizontal_accuracy + self._live_period: dtm.timedelta | None = to_timedelta(live_period) + self.heading: int | None = heading + self.proximity_alert_radius: int | None = ( int(proximity_alert_radius) if proximity_alert_radius else None ) @@ -96,17 +107,21 @@ def __init__( self._freeze() - HORIZONTAL_ACCURACY: ClassVar[int] = constants.LocationLimit.HORIZONTAL_ACCURACY + @property + def live_period(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._live_period, attribute="live_period") + + HORIZONTAL_ACCURACY: Final[int] = constants.LocationLimit.HORIZONTAL_ACCURACY """:const:`telegram.constants.LocationLimit.HORIZONTAL_ACCURACY` .. versionadded:: 20.0 """ - MIN_HEADING: ClassVar[int] = constants.LocationLimit.MIN_HEADING + MIN_HEADING: Final[int] = constants.LocationLimit.MIN_HEADING """:const:`telegram.constants.LocationLimit.MIN_HEADING` .. versionadded:: 20.0 """ - MAX_HEADING: ClassVar[int] = constants.LocationLimit.MAX_HEADING + MAX_HEADING: Final[int] = constants.LocationLimit.MAX_HEADING """:const:`telegram.constants.LocationLimit.MAX_HEADING` .. versionadded:: 20.0 diff --git a/telegram/_files/photosize.py b/src/telegram/_files/photosize.py similarity index 93% rename from telegram/_files/photosize.py rename to src/telegram/_files/photosize.py index 2a267b881f7..95481031b09 100644 --- a/telegram/_files/photosize.py +++ b/src/telegram/_files/photosize.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram PhotoSize.""" -from typing import Optional - from telegram._files._basemedium import _BaseMedium from telegram._utils.types import JSONDict @@ -53,7 +51,7 @@ class PhotoSize(_BaseMedium): """ - __slots__ = ("width", "height") + __slots__ = ("height", "width") def __init__( self, @@ -61,9 +59,9 @@ def __init__( file_unique_id: str, width: int, height: int, - file_size: Optional[int] = None, + file_size: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, diff --git a/telegram/_files/sticker.py b/src/telegram/_files/sticker.py similarity index 74% rename from telegram/_files/sticker.py rename to src/telegram/_files/sticker.py index cf291225040..e7854b53ab9 100644 --- a/telegram/_files/sticker.py +++ b/src/telegram/_files/sticker.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,19 +17,18 @@ # 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 stickers.""" -from typing import TYPE_CHECKING, ClassVar, Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.file import File from telegram._files.photosize import PhotoSize from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg from telegram._utils.types import JSONDict -from telegram._utils.warnings_transition import ( - warn_about_deprecated_attr_in_property, - warn_about_thumb_return_thumbnail, -) if TYPE_CHECKING: from telegram import Bot @@ -46,6 +45,9 @@ class Sticker(_BaseThumbedMedium): arguments had to be changed. Use keyword arguments to make sure that the arguments are passed correctly. + .. versionchanged:: 20.5 + |removed_thumb_note| + Args: file_id (:obj:`str`): Identifier for this file, which can be used to download or reuse the file. @@ -63,11 +65,6 @@ class Sticker(_BaseThumbedMedium): format, which is determined by the fields :attr:`is_animated` and :attr:`is_video`. .. versionadded:: 20.0 - thumb (:class:`telegram.PhotoSize`, optional): Sticker thumbnail in the ``.WEBP`` or - ``.JPG`` format. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. emoji (:obj:`str`, optional): Emoji associated with the sticker set_name (:obj:`str`, optional): Name of the sticker set to which the sticker belongs. mask_position (:class:`telegram.MaskPosition`, optional): For mask stickers, the position @@ -135,17 +132,17 @@ class Sticker(_BaseThumbedMedium): """ __slots__ = ( + "custom_emoji_id", "emoji", "height", "is_animated", "is_video", "mask_position", - "set_name", - "width", + "needs_repainting", "premium_animation", + "set_name", "type", - "custom_emoji_id", - "needs_repainting", + "width", ) def __init__( @@ -157,23 +154,21 @@ def __init__( is_animated: bool, is_video: bool, type: str, # pylint: disable=redefined-builtin - thumb: Optional[PhotoSize] = None, - emoji: Optional[str] = None, - file_size: Optional[int] = None, - set_name: Optional[str] = None, - mask_position: Optional["MaskPosition"] = None, - premium_animation: Optional["File"] = None, - custom_emoji_id: Optional[str] = None, - thumbnail: Optional[PhotoSize] = None, - needs_repainting: Optional[bool] = None, + emoji: str | None = None, + file_size: int | None = None, + set_name: str | None = None, + mask_position: "MaskPosition | None" = None, + premium_animation: "File | None" = None, + custom_emoji_id: str | None = None, + thumbnail: PhotoSize | None = None, + needs_repainting: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, file_unique_id=file_unique_id, file_size=file_size, - thumb=thumb, thumbnail=thumbnail, api_kwargs=api_kwargs, ) @@ -183,33 +178,30 @@ def __init__( self.height: int = height self.is_animated: bool = is_animated self.is_video: bool = is_video - self.type: str = type + self.type: str = enum.get_member(constants.StickerType, type, type) # Optional - self.emoji: Optional[str] = emoji - self.set_name: Optional[str] = set_name - self.mask_position: Optional[MaskPosition] = mask_position - self.premium_animation: Optional[File] = premium_animation - self.custom_emoji_id: Optional[str] = custom_emoji_id - self.needs_repainting: Optional[bool] = needs_repainting - - REGULAR: ClassVar[str] = constants.StickerType.REGULAR + self.emoji: str | None = emoji + self.set_name: str | None = set_name + self.mask_position: MaskPosition | None = mask_position + self.premium_animation: File | None = premium_animation + self.custom_emoji_id: str | None = custom_emoji_id + self.needs_repainting: bool | None = needs_repainting + + REGULAR: Final[str] = constants.StickerType.REGULAR """:const:`telegram.constants.StickerType.REGULAR`""" - MASK: ClassVar[str] = constants.StickerType.MASK + MASK: Final[str] = constants.StickerType.MASK """:const:`telegram.constants.StickerType.MASK`""" - CUSTOM_EMOJI: ClassVar[str] = constants.StickerType.CUSTOM_EMOJI + CUSTOM_EMOJI: Final[str] = constants.StickerType.CUSTOM_EMOJI """:const:`telegram.constants.StickerType.CUSTOM_EMOJI`""" @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Sticker"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Sticker": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["thumbnail"] = PhotoSize.de_json(data.get("thumbnail"), bot) - data["mask_position"] = MaskPosition.de_json(data.get("mask_position"), bot) - data["premium_animation"] = File.de_json(data.get("premium_animation"), bot) + data["thumbnail"] = de_json_optional(data.get("thumbnail"), PhotoSize, bot) + data["mask_position"] = de_json_optional(data.get("mask_position"), MaskPosition, bot) + data["premium_animation"] = de_json_optional(data.get("premium_animation"), File, bot) api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility @@ -234,13 +226,20 @@ class StickerSet(TelegramObject): .. versionchanged:: 20.0 The parameter ``contains_masks`` has been removed. Use :paramref:`sticker_type` instead. + + .. versionchanged:: 21.1 + The parameters ``is_video`` and ``is_animated`` are deprecated and now made optional. Thus, + the order of the arguments had to be changed. + + .. versionchanged:: 20.5 + |removed_thumb_note| + + .. versionremoved:: 21.2 + Removed the deprecated arguments and attributes ``is_animated`` and ``is_video``. + Args: name (:obj:`str`): Sticker set name. title (:obj:`str`): Sticker set title. - is_animated (:obj:`bool`): :obj:`True`, if the sticker set contains animated stickers. - is_video (:obj:`bool`): :obj:`True`, if the sticker set contains video stickers. - - .. versionadded:: 13.11 stickers (Sequence[:class:`telegram.Sticker`]): List of all set stickers. .. versionchanged:: 20.0 @@ -251,11 +250,6 @@ class StickerSet(TelegramObject): :attr:`telegram.Sticker.CUSTOM_EMOJI`. .. versionadded:: 20.0 - thumb (:class:`telegram.PhotoSize`, optional): Sticker set thumbnail in the ``.WEBP``, - ``.TGS``, or ``.WEBM`` format. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. thumbnail (:class:`telegram.PhotoSize`, optional): Sticker set thumbnail in the ``.WEBP``, ``.TGS``, or ``.WEBM`` format. @@ -264,11 +258,7 @@ class StickerSet(TelegramObject): Attributes: name (:obj:`str`): Sticker set name. title (:obj:`str`): Sticker set title. - is_animated (:obj:`bool`): :obj:`True`, if the sticker set contains animated stickers. - is_video (:obj:`bool`): :obj:`True`, if the sticker set contains video stickers. - - .. versionadded:: 13.11 - stickers (Tuple[:class:`telegram.Sticker`]): List of all set stickers. + stickers (tuple[:class:`telegram.Sticker`]): List of all set stickers. .. versionchanged:: 20.0 |tupleclassattrs| @@ -285,72 +275,46 @@ class StickerSet(TelegramObject): """ __slots__ = ( - "is_animated", - "is_video", "name", + "sticker_type", "stickers", "thumbnail", "title", - "sticker_type", ) def __init__( self, name: str, title: str, - is_animated: bool, stickers: Sequence[Sticker], - is_video: bool, sticker_type: str, - thumb: Optional[PhotoSize] = None, - thumbnail: Optional[PhotoSize] = None, + thumbnail: PhotoSize | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.name: str = name self.title: str = title - self.is_animated: bool = is_animated - self.is_video: bool = is_video - self.stickers: Tuple[Sticker, ...] = parse_sequence_arg(stickers) + self.stickers: tuple[Sticker, ...] = parse_sequence_arg(stickers) self.sticker_type: str = sticker_type # Optional - - self.thumbnail: Optional[PhotoSize] = warn_about_thumb_return_thumbnail( - deprecated_arg=thumb, new_arg=thumbnail - ) + self.thumbnail: PhotoSize | None = thumbnail self._id_attrs = (self.name,) self._freeze() - @property - def thumb(self) -> Optional[PhotoSize]: - """:class:`telegram.PhotoSize`: Optional. Sticker set thumbnail in the ``.WEBP``, - ``.TGS``, or ``.WEBM`` format. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb", - new_attr_name="thumbnail", - bot_api_version="6.6", - ) - return self.thumbnail - @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["StickerSet"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "StickerSet": """See :meth:`telegram.TelegramObject.de_json`.""" - if not data: - return None + data = cls._parse_data(data) - data["thumbnail"] = PhotoSize.de_json(data.get("thumbnail"), bot) - data["stickers"] = Sticker.de_list(data.get("stickers"), bot) + data["thumbnail"] = de_json_optional(data.get("thumbnail"), PhotoSize, bot) + data["stickers"] = de_list_optional(data.get("stickers"), Sticker, bot) api_kwargs = {} # These are deprecated fields that TG still returns for backwards compatibility # Let's filter them out to speed up the de-json process - for deprecated_field in ("contains_masks", "thumb"): + for deprecated_field in ("contains_masks", "thumb", "is_animated", "is_video"): if deprecated_field in data: api_kwargs[deprecated_field] = data.pop(deprecated_field) @@ -390,13 +354,13 @@ class MaskPosition(TelegramObject): __slots__ = ("point", "scale", "x_shift", "y_shift") - FOREHEAD: ClassVar[str] = constants.MaskPosition.FOREHEAD + FOREHEAD: Final[str] = constants.MaskPosition.FOREHEAD """:const:`telegram.constants.MaskPosition.FOREHEAD`""" - EYES: ClassVar[str] = constants.MaskPosition.EYES + EYES: Final[str] = constants.MaskPosition.EYES """:const:`telegram.constants.MaskPosition.EYES`""" - MOUTH: ClassVar[str] = constants.MaskPosition.MOUTH + MOUTH: Final[str] = constants.MaskPosition.MOUTH """:const:`telegram.constants.MaskPosition.MOUTH`""" - CHIN: ClassVar[str] = constants.MaskPosition.CHIN + CHIN: Final[str] = constants.MaskPosition.CHIN """:const:`telegram.constants.MaskPosition.CHIN`""" def __init__( @@ -406,7 +370,7 @@ def __init__( y_shift: float, scale: float, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.point: str = point diff --git a/telegram/_files/venue.py b/src/telegram/_files/venue.py similarity index 81% rename from telegram/_files/venue.py rename to src/telegram/_files/venue.py index f47c5863b34..3594e91e10a 100644 --- a/telegram/_files/venue.py +++ b/src/telegram/_files/venue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,10 +18,11 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Venue.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._files.location import Location from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -48,7 +49,7 @@ class Venue(TelegramObject): google_place_id (:obj:`str`, optional): Google Places identifier of the venue. google_place_type (:obj:`str`, optional): Google Places type of the venue. (See `supported types `_.) + /place-types>`_.) Attributes: location (:class:`telegram.Location`): Venue location. @@ -60,17 +61,17 @@ class Venue(TelegramObject): google_place_id (:obj:`str`): Optional. Google Places identifier of the venue. google_place_type (:obj:`str`): Optional. Google Places type of the venue. (See `supported types `_.) + /place-types>`_.) """ __slots__ = ( "address", - "location", "foursquare_id", "foursquare_type", "google_place_id", "google_place_type", + "location", "title", ) @@ -79,12 +80,12 @@ def __init__( location: Location, title: str, address: str, - foursquare_id: Optional[str] = None, - foursquare_type: Optional[str] = None, - google_place_id: Optional[str] = None, - google_place_type: Optional[str] = None, + foursquare_id: str | None = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -93,23 +94,20 @@ def __init__( self.title: str = title self.address: str = address # Optionals - self.foursquare_id: Optional[str] = foursquare_id - self.foursquare_type: Optional[str] = foursquare_type - self.google_place_id: Optional[str] = google_place_id - self.google_place_type: Optional[str] = google_place_type + self.foursquare_id: str | None = foursquare_id + self.foursquare_type: str | None = foursquare_type + self.google_place_id: str | None = google_place_id + self.google_place_type: str | None = google_place_type self._id_attrs = (self.location, self.title) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Venue"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Venue": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["location"] = Location.de_json(data.get("location"), bot) + data["location"] = de_json_optional(data.get("location"), Location, bot) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_files/video.py b/src/telegram/_files/video.py new file mode 100644 index 00000000000..d1b543096fb --- /dev/null +++ b/src/telegram/_files/video.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 Video.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from telegram._files._basethumbedmedium import _BaseThumbedMedium +from telegram._files.photosize import PhotoSize +from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg, to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod + +if TYPE_CHECKING: + from telegram import Bot + + +class Video(_BaseThumbedMedium): + """This object represents a video file. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`file_unique_id` is equal. + + .. versionchanged:: 20.5 + |removed_thumb_note| + + Args: + file_id (:obj:`str`): Identifier for this file, which can be used to download + 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. + width (:obj:`int`): Video width as defined by the sender. + height (:obj:`int`): Video height as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the video + in seconds as defined by the sender. + + .. versionchanged:: v22.2 + |time-period-input| + 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. + + .. versionadded:: 20.2 + cover (Sequence[:class:`telegram.PhotoSize`], optional): Available sizes of the cover of + the video in the message. + + .. versionadded:: 21.11 + start_timestamp (:obj:`int` | :class:`datetime.timedelta`, optional): Timestamp in seconds + from which the video will play in the message + .. versionadded:: 21.11 + + .. versionchanged:: v22.2 + |time-period-input| + + Attributes: + file_id (:obj:`str`): Identifier for this file, which can be used to download + 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. + width (:obj:`int`): Video width as defined by the sender. + height (:obj:`int`): Video height as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the video in seconds + as defined by the sender. + + .. deprecated:: v22.2 + |time-period-int-deprecated| + 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. + + .. versionadded:: 20.2 + cover (tuple[:class:`telegram.PhotoSize`]): Optional, Available sizes of the cover of + the video in the message. + + .. versionadded:: 21.11 + start_timestamp (:obj:`int` | :class:`datetime.timedelta`): Optional. Timestamp in seconds + from which the video will play in the message + .. versionadded:: 21.11 + + .. deprecated:: v22.2 + |time-period-int-deprecated| + """ + + __slots__ = ( + "_duration", + "_start_timestamp", + "cover", + "file_name", + "height", + "mime_type", + "width", + ) + + def __init__( + self, + file_id: str, + file_unique_id: str, + width: int, + height: int, + duration: TimePeriod, + mime_type: str | None = None, + file_size: int | None = None, + file_name: str | None = None, + thumbnail: PhotoSize | None = None, + cover: Sequence[PhotoSize] | None = None, + start_timestamp: TimePeriod | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__( + file_id=file_id, + file_unique_id=file_unique_id, + file_size=file_size, + thumbnail=thumbnail, + api_kwargs=api_kwargs, + ) + with self._unfrozen(): + # Required + self.width: int = width + self.height: int = height + self._duration: dtm.timedelta = to_timedelta(duration) + # Optional + self.mime_type: str | None = mime_type + self.file_name: str | None = file_name + self.cover: Sequence[PhotoSize] | None = parse_sequence_arg(cover) + self._start_timestamp: dtm.timedelta | None = to_timedelta(start_timestamp) + + @property + def duration(self) -> int | dtm.timedelta: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) + + @property + def start_timestamp(self) -> dtm.timedelta | None | int: + return get_timedelta_value(self._start_timestamp, attribute="start_timestamp") + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Video": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["cover"] = de_list_optional(data.get("cover"), PhotoSize, bot) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_files/videonote.py b/src/telegram/_files/videonote.py similarity index 72% rename from telegram/_files/videonote.py rename to src/telegram/_files/videonote.py index 463b717fe6e..edd4edc5d2b 100644 --- a/telegram/_files/videonote.py +++ b/src/telegram/_files/videonote.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,11 +18,13 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram VideoNote.""" -from typing import Optional +import datetime as dtm from telegram._files._basethumbedmedium import _BaseThumbedMedium from telegram._files.photosize import PhotoSize -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class VideoNote(_BaseThumbedMedium): @@ -31,6 +33,9 @@ class VideoNote(_BaseThumbedMedium): Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`file_unique_id` is equal. + .. versionchanged:: 20.5 + |removed_thumb_note| + Args: file_id (:obj:`str`): Identifier for this file, which can be used to download or reuse the file. @@ -39,11 +44,11 @@ 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. - thumb (:class:`telegram.PhotoSize`, optional): Video thumbnail. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the video in + seconds as defined by the sender. - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. + .. versionchanged:: v22.2 + |time-period-input| file_size (:obj:`int`, optional): File size in bytes. thumbnail (:class:`telegram.PhotoSize`, optional): Video thumbnail. @@ -57,7 +62,11 @@ 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` | :class:`datetime.timedelta`): Duration of the video in seconds as + defined by the sender. + + .. deprecated:: v22.2 + |time-period-int-deprecated| file_size (:obj:`int`): Optional. File size in bytes. thumbnail (:class:`telegram.PhotoSize`): Optional. Video thumbnail. @@ -65,29 +74,33 @@ class VideoNote(_BaseThumbedMedium): """ - __slots__ = ("duration", "length") + __slots__ = ("_duration", "length") def __init__( self, file_id: str, file_unique_id: str, length: int, - duration: int, - thumb: Optional[PhotoSize] = None, - file_size: Optional[int] = None, - thumbnail: Optional[PhotoSize] = None, + duration: TimePeriod, + file_size: int | None = None, + thumbnail: PhotoSize | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, file_unique_id=file_unique_id, file_size=file_size, - thumb=thumb, thumbnail=thumbnail, api_kwargs=api_kwargs, ) with self._unfrozen(): # Required self.length: int = length - self.duration: int = duration + self._duration: dtm.timedelta = to_timedelta(duration) + + @property + def duration(self) -> int | dtm.timedelta: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) diff --git a/telegram/_files/voice.py b/src/telegram/_files/voice.py similarity index 67% rename from telegram/_files/voice.py rename to src/telegram/_files/voice.py index 155b4fd58b7..fb7691fe2df 100644 --- a/telegram/_files/voice.py +++ b/src/telegram/_files/voice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,10 +17,13 @@ # 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 Voice.""" -from typing import Optional + +import datetime as dtm from telegram._files._basemedium import _BaseMedium -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class Voice(_BaseMedium): @@ -35,8 +38,12 @@ 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` | :class:`datetime.timedelta`): Duration of the audio in + seconds as defined by the sender. + + .. versionchanged:: v22.2 + |time-period-input| + 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,23 +52,27 @@ 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` | :class:`datetime.timedelta`): Duration of the audio in seconds as + defined by the sender. + + .. deprecated:: v22.2 + |time-period-int-deprecated| + mime_type (:obj:`str`): Optional. MIME type of the file as defined by the sender. file_size (:obj:`int`): Optional. File size in bytes. """ - __slots__ = ("duration", "mime_type") + __slots__ = ("_duration", "mime_type") def __init__( self, file_id: str, file_unique_id: str, - duration: int, - mime_type: Optional[str] = None, - file_size: Optional[int] = None, + duration: TimePeriod, + mime_type: str | None = None, + file_size: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__( file_id=file_id, @@ -71,6 +82,12 @@ def __init__( ) with self._unfrozen(): # Required - self.duration: int = duration + self._duration: dtm.timedelta = to_timedelta(duration) # Optional - self.mime_type: Optional[str] = mime_type + self.mime_type: str | None = mime_type + + @property + def duration(self) -> int | dtm.timedelta: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) diff --git a/telegram/_forcereply.py b/src/telegram/_forcereply.py similarity index 77% rename from telegram/_forcereply.py rename to src/telegram/_forcereply.py index 569edf19dfc..bf69c93aa43 100644 --- a/telegram/_forcereply.py +++ b/src/telegram/_forcereply.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram ForceReply.""" -from typing import ClassVar, Optional +from typing import Final from telegram import constants from telegram._telegramobject import TelegramObject @@ -30,7 +30,8 @@ class ForceReply(TelegramObject): Upon receiving a message with this object, Telegram clients will display a reply interface to the user (act as if the user has selected the bot's message and tapped 'Reply'). This can be extremely useful if you want to create user-friendly step-by-step interfaces without having - to sacrifice privacy mode. + to sacrifice `privacy mode `_. Not + supported in channels and for messages sent on behalf of a Telegram Business account. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`selective` is equal. @@ -45,8 +46,8 @@ class ForceReply(TelegramObject): 1) Users that are @mentioned in the :attr:`~telegram.Message.text` of the :class:`telegram.Message` object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of the - original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. input_field_placeholder (:obj:`str`, optional): The placeholder to be shown in the input field when the reply is active; @@ -63,8 +64,8 @@ class ForceReply(TelegramObject): 1) Users that are @mentioned in the :attr:`~telegram.Message.text` of the :class:`telegram.Message` object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of the - original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. input_field_placeholder (:obj:`str`): Optional. The placeholder to be shown in the input field when the reply is active; :tg-const:`telegram.ForceReply.MIN_INPUT_FIELD_PLACEHOLDER`- @@ -75,30 +76,30 @@ class ForceReply(TelegramObject): """ - __slots__ = ("selective", "force_reply", "input_field_placeholder") + __slots__ = ("force_reply", "input_field_placeholder", "selective") def __init__( self, - selective: Optional[bool] = None, - input_field_placeholder: Optional[str] = None, + selective: bool | None = None, + input_field_placeholder: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.force_reply: bool = True - self.selective: Optional[bool] = selective - self.input_field_placeholder: Optional[str] = input_field_placeholder + self.selective: bool | None = selective + self.input_field_placeholder: str | None = input_field_placeholder self._id_attrs = (self.selective,) self._freeze() - MIN_INPUT_FIELD_PLACEHOLDER: ClassVar[int] = constants.ReplyLimit.MIN_INPUT_FIELD_PLACEHOLDER + MIN_INPUT_FIELD_PLACEHOLDER: Final[int] = constants.ReplyLimit.MIN_INPUT_FIELD_PLACEHOLDER """:const:`telegram.constants.ReplyLimit.MIN_INPUT_FIELD_PLACEHOLDER` .. versionadded:: 20.0 """ - MAX_INPUT_FIELD_PLACEHOLDER: ClassVar[int] = constants.ReplyLimit.MAX_INPUT_FIELD_PLACEHOLDER + MAX_INPUT_FIELD_PLACEHOLDER: Final[int] = constants.ReplyLimit.MAX_INPUT_FIELD_PLACEHOLDER """:const:`telegram.constants.ReplyLimit.MAX_INPUT_FIELD_PLACEHOLDER` .. versionadded:: 20.0 diff --git a/telegram/_forumtopic.py b/src/telegram/_forumtopic.py similarity index 72% rename from telegram/_forumtopic.py rename to src/telegram/_forumtopic.py index 69d53eb75d5..51c6d8df71f 100644 --- a/telegram/_forumtopic.py +++ b/src/telegram/_forumtopic.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # 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 related to Telegram forum topics.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -39,6 +38,10 @@ class ForumTopic(TelegramObject): icon_color (:obj:`int`): Color of the topic icon in RGB format icon_custom_emoji_id (:obj:`str`, optional): Unique identifier of the custom emoji shown as the topic icon. + is_name_implicit (:obj:`bool`, optional): :obj:`True`, if the name of the topic wasn't + specified explicitly by its creator and likely needs to be changed by the bot. + + .. versionadded:: 22.6 Attributes: message_thread_id (:obj:`int`): Unique identifier of the forum topic @@ -46,24 +49,36 @@ class ForumTopic(TelegramObject): icon_color (:obj:`int`): Color of the topic icon in RGB format icon_custom_emoji_id (:obj:`str`): Optional. Unique identifier of the custom emoji shown as the topic icon. + is_name_implicit (:obj:`bool`): Optional. :obj:`True`, if the name of the topic wasn't + specified explicitly by its creator and likely needs to be changed by the bot. + + .. versionadded:: 22.6 """ - __slots__ = ("message_thread_id", "name", "icon_color", "icon_custom_emoji_id") + __slots__ = ( + "icon_color", + "icon_custom_emoji_id", + "is_name_implicit", + "message_thread_id", + "name", + ) def __init__( self, message_thread_id: int, name: str, icon_color: int, - icon_custom_emoji_id: Optional[str] = None, + icon_custom_emoji_id: str | None = None, + is_name_implicit: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.message_thread_id: int = message_thread_id self.name: str = name self.icon_color: int = icon_color - self.icon_custom_emoji_id: Optional[str] = icon_custom_emoji_id + self.icon_custom_emoji_id: str | None = icon_custom_emoji_id + self.is_name_implicit: bool | None = is_name_implicit self._id_attrs = (self.message_thread_id, self.name, self.icon_color) @@ -85,28 +100,38 @@ class ForumTopicCreated(TelegramObject): icon_color (:obj:`int`): Color of the topic icon in RGB format icon_custom_emoji_id (:obj:`str`, optional): Unique identifier of the custom emoji shown as the topic icon. + is_name_implicit (:obj:`bool`, optional): :obj:`True`, if the name of the topic wasn't + specified explicitly by its creator and likely needs to be changed by the bot. + + .. versionadded:: 22.6 Attributes: name (:obj:`str`): Name of the topic icon_color (:obj:`int`): Color of the topic icon in RGB format icon_custom_emoji_id (:obj:`str`): Optional. Unique identifier of the custom emoji shown as the topic icon. + is_name_implicit (:obj:`bool`): Optional. :obj:`True`, if the name of the topic wasn't + specified explicitly by its creator and likely needs to be changed by the bot. + + .. versionadded:: 22.6 """ - __slots__ = ("name", "icon_color", "icon_custom_emoji_id") + __slots__ = ("icon_color", "icon_custom_emoji_id", "is_name_implicit", "name") def __init__( self, name: str, icon_color: int, - icon_custom_emoji_id: Optional[str] = None, + icon_custom_emoji_id: str | None = None, + is_name_implicit: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.name: str = name self.icon_color: int = icon_color - self.icon_custom_emoji_id: Optional[str] = icon_custom_emoji_id + self.icon_custom_emoji_id: str | None = icon_custom_emoji_id + self.is_name_implicit: bool | None = is_name_implicit self._id_attrs = (self.name, self.icon_color) @@ -123,7 +148,7 @@ class ForumTopicClosed(TelegramObject): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(api_kwargs=api_kwargs) self._freeze() @@ -139,7 +164,7 @@ class ForumTopicReopened(TelegramObject): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(api_kwargs=api_kwargs) self._freeze() @@ -165,18 +190,18 @@ class ForumTopicEdited(TelegramObject): the topic icon, if it was edited; an empty string if the icon was removed. """ - __slots__ = ("name", "icon_custom_emoji_id") + __slots__ = ("icon_custom_emoji_id", "name") def __init__( self, - name: Optional[str] = None, - icon_custom_emoji_id: Optional[str] = None, + name: str | None = None, + icon_custom_emoji_id: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) - self.name: Optional[str] = name - self.icon_custom_emoji_id: Optional[str] = icon_custom_emoji_id + self.name: str | None = name + self.icon_custom_emoji_id: str | None = icon_custom_emoji_id self._id_attrs = (self.name, self.icon_custom_emoji_id) @@ -193,7 +218,7 @@ class GeneralForumTopicHidden(TelegramObject): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self._freeze() @@ -209,7 +234,7 @@ class GeneralForumTopicUnhidden(TelegramObject): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self._freeze() diff --git a/telegram/_payment/__init__.py b/src/telegram/_games/__init__.py similarity index 100% rename from telegram/_payment/__init__.py rename to src/telegram/_games/__init__.py diff --git a/telegram/_games/callbackgame.py b/src/telegram/_games/callbackgame.py similarity index 90% rename from telegram/_games/callbackgame.py rename to src/telegram/_games/callbackgame.py index 20b788920e1..093a5449b81 100644 --- a/telegram/_games/callbackgame.py +++ b/src/telegram/_games/callbackgame.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram CallbackGame.""" -from typing import Optional - from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -29,7 +27,7 @@ class CallbackGame(TelegramObject): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(api_kwargs=api_kwargs) self._freeze() diff --git a/telegram/_games/game.py b/src/telegram/_games/game.py similarity index 82% rename from telegram/_games/game.py rename to src/telegram/_games/game.py index 148b75a964a..898838e8b97 100644 --- a/telegram/_games/game.py +++ b/src/telegram/_games/game.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,13 +17,16 @@ # 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 Game.""" -from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._files.animation import Animation from telegram._files.photosize import PhotoSize from telegram._messageentity import MessageEntity from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg +from telegram._utils.strings import TextEncoding from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -64,7 +67,7 @@ class Game(TelegramObject): Attributes: title (:obj:`str`): Title of the game. description (:obj:`str`): Description of the game. - photo (Tuple[:class:`telegram.PhotoSize`]): Photo that will be displayed in the game + photo (tuple[:class:`telegram.PhotoSize`]): Photo that will be displayed in the game message in chats. .. versionchanged:: 20.0 @@ -75,7 +78,7 @@ class Game(TelegramObject): when the bot calls :meth:`telegram.Bot.set_game_score`, or manually edited using :meth:`telegram.Bot.edit_message_text`. 0-:tg-const:`telegram.constants.MessageLimit.MAX_TEXT_LENGTH` characters. - text_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. Special entities that + text_entities (tuple[:class:`telegram.MessageEntity`]): Optional. Special entities that appear in text, such as usernames, URLs, bot commands, etc. This tuple is empty if the message does not contain text entities. @@ -88,12 +91,12 @@ class Game(TelegramObject): """ __slots__ = ( - "title", - "photo", + "animation", "description", - "text_entities", + "photo", "text", - "animation", + "text_entities", + "title", ) def __init__( @@ -101,37 +104,34 @@ def __init__( title: str, description: str, photo: Sequence[PhotoSize], - text: Optional[str] = None, - text_entities: Optional[Sequence[MessageEntity]] = None, - animation: Optional[Animation] = None, + text: str | None = None, + text_entities: Sequence[MessageEntity] | None = None, + animation: Animation | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.title: str = title self.description: str = description - self.photo: Tuple[PhotoSize, ...] = parse_sequence_arg(photo) + self.photo: tuple[PhotoSize, ...] = parse_sequence_arg(photo) # Optionals - self.text: Optional[str] = text - self.text_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) - self.animation: Optional[Animation] = animation + self.text: str | None = text + self.text_entities: tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) + self.animation: Animation | None = animation self._id_attrs = (self.title, self.description, self.photo) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Game"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Game": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["photo"] = PhotoSize.de_list(data.get("photo"), bot) - data["text_entities"] = MessageEntity.de_list(data.get("text_entities"), bot) - data["animation"] = Animation.de_json(data.get("animation"), bot) + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) + data["text_entities"] = de_list_optional(data.get("text_entities"), MessageEntity, bot) + data["animation"] = de_json_optional(data.get("animation"), Animation, bot) return super().de_json(data=data, bot=bot) @@ -157,12 +157,12 @@ def parse_text_entity(self, entity: MessageEntity) -> str: if not self.text: raise RuntimeError("This Game has no 'text'.") - entity_text = self.text.encode("utf-16-le") + entity_text = self.text.encode(TextEncoding.UTF_16_LE) entity_text = entity_text[entity.offset * 2 : (entity.offset + entity.length) * 2] - return entity_text.decode("utf-16-le") + return entity_text.decode(TextEncoding.UTF_16_LE) - def parse_text_entities(self, types: Optional[List[str]] = None) -> Dict[MessageEntity, str]: + def parse_text_entities(self, types: list[str] | None = None) -> dict[MessageEntity, str]: """ Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. It contains entities from this message filtered by their @@ -175,13 +175,13 @@ def parse_text_entities(self, types: Optional[List[str]] = None) -> Dict[Message See :attr:`parse_text_entity` for more info. Args: - types (List[:obj:`str`], optional): List of :class:`telegram.MessageEntity` types as + types (list[:obj:`str`], optional): List of :class:`telegram.MessageEntity` types as strings. If the :attr:`~telegram.MessageEntity.type` attribute of an entity is contained in this list, it will be returned. Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. Returns: - Dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to the text that belongs to them, calculated based on UTF-16 codepoints. """ diff --git a/telegram/_games/gamehighscore.py b/src/telegram/_games/gamehighscore.py similarity index 86% rename from telegram/_games/gamehighscore.py rename to src/telegram/_games/gamehighscore.py index c98540a6514..482a61c9659 100644 --- a/telegram/_games/gamehighscore.py +++ b/src/telegram/_games/gamehighscore.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,10 +18,11 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram GameHighScore.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -46,10 +47,10 @@ class GameHighScore(TelegramObject): """ - __slots__ = ("position", "user", "score") + __slots__ = ("position", "score", "user") def __init__( - self, position: int, user: User, score: int, *, api_kwargs: Optional[JSONDict] = None + self, position: int, user: User, score: int, *, api_kwargs: JSONDict | None = None ): super().__init__(api_kwargs=api_kwargs) self.position: int = position @@ -61,13 +62,10 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["GameHighScore"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "GameHighScore": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["user"] = User.de_json(data.get("user"), bot) + data["user"] = de_json_optional(data.get("user"), User, bot) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_gifts.py b/src/telegram/_gifts.py new file mode 100644 index 00000000000..84a9bad7100 --- /dev/null +++ b/src/telegram/_gifts.py @@ -0,0 +1,523 @@ +#!/usr/bin/env python +# pylint: disable=redefined-builtin +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 classes related to gifs sent by bots.""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from telegram._chat import Chat +from telegram._files.sticker import Sticker +from telegram._messageentity import MessageEntity +from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg +from telegram._utils.entities import parse_message_entities, parse_message_entity +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class GiftBackground(TelegramObject): + """This object describes the background of a gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`center_color`, :attr:`edge_color` and :attr:`text_color` are + equal. + + .. versionadded:: 22.6 + + Args: + center_color (:obj:`int`): Center color of the background in RGB format. + edge_color (:obj:`int`): Edge color of the background in RGB format. + text_color (:obj:`int`): Text color of the background in RGB format. + + Attributes: + center_color (:obj:`int`): Center color of the background in RGB format. + edge_color (:obj:`int`): Edge color of the background in RGB format. + text_color (:obj:`int`): Text color of the background in RGB format. + + """ + + __slots__ = ( + "center_color", + "edge_color", + "text_color", + ) + + def __init__( + self, + center_color: int, + edge_color: int, + text_color: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.center_color: int = center_color + self.edge_color: int = edge_color + self.text_color: int = text_color + + self._id_attrs = ( + self.center_color, + self.edge_color, + self.text_color, + ) + + self._freeze() + + +class Gift(TelegramObject): + """This object represents a gift that can be sent by the bot. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`id` is equal. + + .. versionadded:: 21.8 + + Args: + id (:obj:`str`): Unique identifier of the gift. + sticker (:class:`~telegram.Sticker`): The sticker that represents the gift. + star_count (:obj:`int`): The number of Telegram Stars that must be paid to send the + sticker. + total_count (:obj:`int`, optional): The total number of the gifts of this type that can be + sent by all users; for limited gifts only. + remaining_count (:obj:`int`, optional): The number of remaining gifts of this type that can + be sent by all users; for limited gifts only. + upgrade_star_count (:obj:`int`, optional): The number of Telegram Stars that must be paid + to upgrade the gift to a unique one. + + .. versionadded:: 21.10 + publisher_chat (:class:`telegram.Chat`, optional): Information about the chat that + published the gift. + + .. versionadded:: 22.4 + personal_total_count (:obj:`int`, optional): The total number of gifts of this type that + can be sent by the bot; for limited gifts only. + + .. versionadded:: 22.6 + personal_remaining_count (:obj:`int`, optional): The number of remaining gifts of this type + that can be sent by the bot; for limited gifts only. + + .. versionadded:: 22.6 + background (:class:`GiftBackground`, optional): Background of the gift. + + .. versionadded:: 22.6 + is_premium (:obj:`bool`, optional): :obj:`True`, if the gift can only be purchased by + Telegram Premium subscribers. + + .. versionadded:: 22.6 + has_colors (:obj:`bool`, optional): :obj:`True`, if the gift can be used (after being + upgraded) to customize a user's appearance. + + .. versionadded:: 22.6 + unique_gift_variant_count (:obj:`int`, optional): The total number of different unique + gifts that can be obtained by upgrading the gift. + + .. versionadded:: 22.6 + + Attributes: + id (:obj:`str`): Unique identifier of the gift. + sticker (:class:`~telegram.Sticker`): The sticker that represents the gift. + star_count (:obj:`int`): The number of Telegram Stars that must be paid to send the + sticker. + total_count (:obj:`int`): Optional. The total number of the gifts of this type that can be + sent by all users; for limited gifts only. + remaining_count (:obj:`int`): Optional. The number of remaining gifts of this type that can + be sent by all users; for limited gifts only. + upgrade_star_count (:obj:`int`): Optional. The number of Telegram Stars that must be paid + to upgrade the gift to a unique one. + + .. versionadded:: 21.10 + publisher_chat (:class:`telegram.Chat`): Optional. Information about the chat that + published the gift. + + .. versionadded:: 22.4 + personal_total_count (:obj:`int`): Optional. The total number of gifts of this type that + can be sent by the bot; for limited gifts only. + + .. versionadded:: 22.6 + personal_remaining_count (:obj:`int`): Optional. The number of remaining gifts of this type + that can be sent by the bot; for limited gifts only. + + .. versionadded:: 22.6 + background (:class:`GiftBackground`): Optional. Background of the gift. + + .. versionadded:: 22.6 + is_premium (:obj:`bool`): Optional. :obj:`True`, if the gift can only be purchased by + Telegram Premium subscribers. + + .. versionadded:: 22.6 + has_colors (:obj:`bool`): Optional. :obj:`True`, if the gift can be used (after being + upgraded) to customize a user's appearance. + + .. versionadded:: 22.6 + unique_gift_variant_count (:obj:`int`): Optional. The total number of different unique + gifts that can be obtained by upgrading the gift. + + .. versionadded:: 22.6 + + """ + + __slots__ = ( + "background", + "has_colors", + "id", + "is_premium", + "personal_remaining_count", + "personal_total_count", + "publisher_chat", + "remaining_count", + "star_count", + "sticker", + "total_count", + "unique_gift_variant_count", + "upgrade_star_count", + ) + + def __init__( + self, + id: str, + sticker: Sticker, + star_count: int, + total_count: int | None = None, + remaining_count: int | None = None, + upgrade_star_count: int | None = None, + publisher_chat: Chat | None = None, + personal_total_count: int | None = None, + personal_remaining_count: int | None = None, + background: GiftBackground | None = None, + is_premium: bool | None = None, + has_colors: bool | None = None, + unique_gift_variant_count: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.id: str = id + self.sticker: Sticker = sticker + self.star_count: int = star_count + self.total_count: int | None = total_count + self.remaining_count: int | None = remaining_count + self.upgrade_star_count: int | None = upgrade_star_count + self.publisher_chat: Chat | None = publisher_chat + self.personal_total_count: int | None = personal_total_count + self.personal_remaining_count: int | None = personal_remaining_count + self.background: GiftBackground | None = background + self.is_premium: bool | None = is_premium + self.has_colors: bool | None = has_colors + self.unique_gift_variant_count: int | None = unique_gift_variant_count + + self._id_attrs = (self.id,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Gift": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + data["publisher_chat"] = de_json_optional(data.get("publisher_chat"), Chat, bot) + data["background"] = de_json_optional(data.get("background"), GiftBackground, bot) + return super().de_json(data=data, bot=bot) + + +class Gifts(TelegramObject): + """This object represent a list of gifts. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`gifts` are equal. + + .. versionadded:: 21.8 + + Args: + gifts (Sequence[:class:`Gift`]): The sequence of gifts. + + Attributes: + gifts (tuple[:class:`Gift`]): The sequence of gifts. + + """ + + __slots__ = ("gifts",) + + def __init__( + self, + gifts: Sequence[Gift], + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.gifts: tuple[Gift, ...] = parse_sequence_arg(gifts) + + self._id_attrs = (self.gifts,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Gifts": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["gifts"] = de_list_optional(data.get("gifts"), Gift, bot) + return super().de_json(data=data, bot=bot) + + +class GiftInfo(TelegramObject): + """Describes a service message about a regular gift that was sent or received. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`gift` is equal. + + .. versionadded:: 22.1 + + Args: + gift (:class:`Gift`): Information about the gift. + owned_gift_id (:obj:`str`, optional): Unique identifier of the received gift for the bot; + only present for gifts received on behalf of business accounts. + convert_star_count (:obj:`int`, optional) Number of Telegram Stars that can be claimed by + the receiver by converting the gift; omitted if conversion to Telegram Stars + is impossible. + prepaid_upgrade_star_count (:obj:`int`, optional): Number of Telegram Stars that were + prepaid for the ability to upgrade the gift. + can_be_upgraded (:obj:`bool`, optional): :obj:`True`, if the gift can be upgraded + to a unique gift. + text (:obj:`str`, optional): Text of the message that was added to the gift. + entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities that + appear in the text. + is_private (:obj:`bool`, optional): :obj:`True`, if the sender and gift text are + shown only to the gift receiver; otherwise, everyone will be able to see them. + is_upgrade_separate (:obj:`bool`, optional): :obj:`True`, if the gift's upgrade was + purchased after the gift was sent. + + .. versionadded:: 22.6 + unique_gift_number (:obj:`int`, optional): Unique number reserved for this gift when + upgraded. See the number field in :class:`~telegram.UniqueGift`. + + .. versionadded:: 22.6 + + Attributes: + gift (:class:`Gift`): Information about the gift. + owned_gift_id (:obj:`str`): Optional. Unique identifier of the received gift for the bot; + only present for gifts received on behalf of business accounts. + convert_star_count (:obj:`int`): Optional. Number of Telegram Stars that can be claimed by + the receiver by converting the gift; omitted if conversion to Telegram Stars + is impossible. + prepaid_upgrade_star_count (:obj:`int`): Optional. Number of Telegram Stars that were + prepaid for the ability to upgrade the gift. + can_be_upgraded (:obj:`bool`): Optional. :obj:`True`, if the gift can be upgraded + to a unique gift. + text (:obj:`str`): Optional. Text of the message that was added to the gift. + entities (Sequence[:class:`telegram.MessageEntity`]): Optional. Special entities that + appear in the text. + is_private (:obj:`bool`): Optional. :obj:`True`, if the sender and gift text are + shown only to the gift receiver; otherwise, everyone will be able to see them. + is_upgrade_separate (:obj:`bool`): Optional. :obj:`True`, if the gift's upgrade was + purchased after the gift was sent. + + .. versionadded:: 22.6 + unique_gift_number (:obj:`int`): Optional. Unique number reserved for this gift when + upgraded. See the number field in :class:`~telegram.UniqueGift`. + + .. versionadded:: 22.6 + + """ + + __slots__ = ( + "can_be_upgraded", + "convert_star_count", + "entities", + "gift", + "is_private", + "is_upgrade_separate", + "owned_gift_id", + "prepaid_upgrade_star_count", + "text", + "unique_gift_number", + ) + + def __init__( + self, + gift: Gift, + owned_gift_id: str | None = None, + convert_star_count: int | None = None, + prepaid_upgrade_star_count: int | None = None, + can_be_upgraded: bool | None = None, + text: str | None = None, + entities: Sequence[MessageEntity] | None = None, + is_private: bool | None = None, + unique_gift_number: int | None = None, + is_upgrade_separate: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.gift: Gift = gift + # Optional + self.owned_gift_id: str | None = owned_gift_id + self.convert_star_count: int | None = convert_star_count + self.prepaid_upgrade_star_count: int | None = prepaid_upgrade_star_count + self.can_be_upgraded: bool | None = can_be_upgraded + self.text: str | None = text + self.entities: tuple[MessageEntity, ...] = parse_sequence_arg(entities) + self.is_private: bool | None = is_private + self.unique_gift_number: int | None = unique_gift_number + self.is_upgrade_separate: bool | None = is_upgrade_separate + + self._id_attrs = (self.gift,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "GiftInfo": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["gift"] = de_json_optional(data.get("gift"), Gift, bot) + data["entities"] = de_list_optional(data.get("entities"), MessageEntity, bot) + + return super().de_json(data=data, bot=bot) + + def parse_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`text` + from a given :class:`telegram.MessageEntity` of :attr:`entities`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``Message.text`` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`entities`. + + Returns: + :obj:`str`: The text of the given entity. + + Raises: + RuntimeError: If the gift info has no text. + + """ + if not self.text: + raise RuntimeError("This GiftInfo has no 'text'.") + + return parse_message_entity(self.text, entity) + + def parse_entities(self, types: list[str] | None = None) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this gift info's text filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + Note: + This method should always be used instead of the :attr:`entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + + Raises: + RuntimeError: If the gift info has no text. + + """ + if not self.text: + raise RuntimeError("This GiftInfo has no 'text'.") + + return parse_message_entities(self.text, self.entities, types) + + +class AcceptedGiftTypes(TelegramObject): + """This object describes the types of gifts that can be gifted to a user or a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`unlimited_gifts`, :attr:`limited_gifts`, + :attr:`unique_gifts`, :attr:`premium_subscription` and :attr:`gifts_from_channels` are equal. + + .. versionadded:: 22.1 + .. versionchanged:: 22.6 + :attr:`gifts_from_channels` is now considered for equality checks. + + Args: + unlimited_gifts (:class:`bool`): :obj:`True`, if unlimited regular gifts are accepted. + limited_gifts (:class:`bool`): :obj:`True`, if limited regular gifts are accepted. + unique_gifts (:class:`bool`): :obj:`True`, if unique gifts or gifts that can be upgraded + to unique for free are accepted. + premium_subscription (:class:`bool`): :obj:`True`, if a Telegram Premium subscription + is accepted. + gifts_from_channels (:obj:`bool`): :obj:`True`, if transfers of unique gifts from channels + are accepted + + .. versionadded:: 22.6 + + Attributes: + unlimited_gifts (:class:`bool`): :obj:`True`, if unlimited regular gifts are accepted. + limited_gifts (:class:`bool`): :obj:`True`, if limited regular gifts are accepted. + unique_gifts (:class:`bool`): :obj:`True`, if unique gifts or gifts that can be upgraded + to unique for free are accepted. + premium_subscription (:class:`bool`): :obj:`True`, if a Telegram Premium subscription + is accepted. + gifts_from_channels (:obj:`bool`): :obj:`True`, if transfers of unique gifts from channels + are accepted + + .. versionadded:: 22.6 + + """ + + __slots__ = ( + "gifts_from_channels", + "limited_gifts", + "premium_subscription", + "unique_gifts", + "unlimited_gifts", + ) + + def __init__( + self, + unlimited_gifts: bool, + limited_gifts: bool, + unique_gifts: bool, + premium_subscription: bool, + gifts_from_channels: bool, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.unlimited_gifts: bool = unlimited_gifts + self.limited_gifts: bool = limited_gifts + self.unique_gifts: bool = unique_gifts + self.premium_subscription: bool = premium_subscription + self.gifts_from_channels: bool = gifts_from_channels + + self._id_attrs = ( + self.unlimited_gifts, + self.limited_gifts, + self.unique_gifts, + self.premium_subscription, + self.gifts_from_channels, + ) + + self._freeze() diff --git a/src/telegram/_giveaway.py b/src/telegram/_giveaway.py new file mode 100644 index 00000000000..3fe25c40920 --- /dev/null +++ b/src/telegram/_giveaway.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 objects that are related to Telegram giveaways.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from telegram._chat import Chat +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, 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, Message + + +class Giveaway(TelegramObject): + """This object represents a message about a scheduled giveaway. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`chats`, :attr:`winners_selection_date` and + :attr:`winner_count` are equal. + + .. versionadded:: 20.8 + + Args: + chats (tuple[:class:`telegram.Chat`]): The list of chats which the user must join to + participate in the giveaway. + winners_selection_date (:class:`datetime.datetime`): The date when the giveaway winner will + be selected. |datetime_localization| + winner_count (:obj:`int`): The number of users which are supposed to be selected as winners + of the giveaway. + only_new_members (:obj:`True`, optional): If :obj:`True`, only users who join the chats + after the giveaway started should be eligible to win. + has_public_winners (:obj:`True`, optional): :obj:`True`, if the list of giveaway winners + will be visible to everyone + prize_description (:obj:`str`, optional): Description of additional giveaway prize + country_codes (Sequence[:obj:`str`]): A list of two-letter ISO 3166-1 alpha-2 + country codes indicating the countries from which eligible users for the giveaway must + come. If empty, then all users can participate in the giveaway. Users with a phone + number that was bought on Fragment can always participate in giveaways. + prize_star_count (:obj:`int`, optional): The number of Telegram Stars to be split between + giveaway winners; for Telegram Star giveaways only. + + .. versionadded:: 21.6 + premium_subscription_month_count (:obj:`int`, optional): The number of months the Telegram + Premium subscription won from the giveaway will be active for; for Telegram Premium + giveaways only. + + Attributes: + chats (Sequence[:class:`telegram.Chat`]): The list of chats which the user must join to + participate in the giveaway. + winners_selection_date (:class:`datetime.datetime`): The date when the giveaway winner will + be selected. |datetime_localization| + winner_count (:obj:`int`): The number of users which are supposed to be selected as winners + of the giveaway. + only_new_members (:obj:`True`): Optional. If :obj:`True`, only users who join the chats + after the giveaway started should be eligible to win. + has_public_winners (:obj:`True`): Optional. :obj:`True`, if the list of giveaway winners + will be visible to everyone + prize_description (:obj:`str`): Optional. Description of additional giveaway prize + country_codes (tuple[:obj:`str`]): Optional. A tuple of two-letter ISO 3166-1 alpha-2 + country codes indicating the countries from which eligible users for the giveaway must + come. If empty, then all users can participate in the giveaway. Users with a phone + number that was bought on Fragment can always participate in giveaways. + prize_star_count (:obj:`int`): Optional. The number of Telegram Stars to be split between + giveaway winners; for Telegram Star giveaways only. + + .. versionadded:: 21.6 + premium_subscription_month_count (:obj:`int`): Optional. The number of months the Telegram + Premium subscription won from the giveaway will be active for; for Telegram Premium + giveaways only. + """ + + __slots__ = ( + "chats", + "country_codes", + "has_public_winners", + "only_new_members", + "premium_subscription_month_count", + "prize_description", + "prize_star_count", + "winner_count", + "winners_selection_date", + ) + + def __init__( + self, + chats: Sequence[Chat], + winners_selection_date: dtm.datetime, + winner_count: int, + only_new_members: bool | None = None, + has_public_winners: bool | None = None, + prize_description: str | None = None, + country_codes: Sequence[str] | None = None, + premium_subscription_month_count: int | None = None, + prize_star_count: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.chats: tuple[Chat, ...] = tuple(chats) + self.winners_selection_date: dtm.datetime = winners_selection_date + self.winner_count: int = winner_count + self.only_new_members: bool | None = only_new_members + self.has_public_winners: bool | None = has_public_winners + self.prize_description: str | None = prize_description + self.country_codes: tuple[str, ...] = parse_sequence_arg(country_codes) + self.premium_subscription_month_count: int | None = premium_subscription_month_count + self.prize_star_count: int | None = prize_star_count + + self._id_attrs = ( + self.chats, + self.winners_selection_date, + self.winner_count, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Giveaway": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["chats"] = de_list_optional(data.get("chats"), Chat, bot) + data["winners_selection_date"] = from_timestamp( + data.get("winners_selection_date"), tzinfo=loc_tzinfo + ) + + return super().de_json(data=data, bot=bot) + + +class GiveawayCreated(TelegramObject): + """This object represents a service message about the creation of a scheduled giveaway. + + Args: + prize_star_count (:obj:`int`, optional): The number of Telegram Stars to be + split between giveaway winners; for Telegram Star giveaways only. + + .. versionadded:: 21.6 + + Attributes: + prize_star_count (:obj:`int`): Optional. The number of Telegram Stars to be + split between giveaway winners; for Telegram Star giveaways only. + + .. versionadded:: 21.6 + + """ + + __slots__ = ("prize_star_count",) + + def __init__(self, prize_star_count: int | None = None, *, api_kwargs: JSONDict | None = None): + super().__init__(api_kwargs=api_kwargs) + self.prize_star_count: int | None = prize_star_count + + self._freeze() + + +class GiveawayWinners(TelegramObject): + """This object represents a message about the completion of a giveaway with public winners. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`chat`, :attr:`giveaway_message_id`, + :attr:`winners_selection_date`, :attr:`winner_count` and :attr:`winners` are equal. + + .. versionadded:: 20.8 + + Args: + chat (:class:`telegram.Chat`): The chat that created the giveaway + giveaway_message_id (:obj:`int`): Identifier of the message with the giveaway in the chat + winners_selection_date (:class:`datetime.datetime`): Point in time when winners of the + giveaway were selected. |datetime_localization| + winner_count (:obj:`int`): Total number of winners in the giveaway + winners (Sequence[:class:`telegram.User`]): List of up to + :tg-const:`telegram.constants.GiveawayLimit.MAX_WINNERS` winners of the giveaway + prize_star_count (:obj:`int`, optional): The number of Telegram Stars to be split between + giveaway winners; for Telegram Star giveaways only. + + .. versionadded:: 21.6 + additional_chat_count (:obj:`int`, optional): The number of other chats the user had to + join in order to be eligible for the giveaway + premium_subscription_month_count (:obj:`int`, optional): The number of months the Telegram + Premium subscription won from the giveaway will be active for + unclaimed_prize_count (:obj:`int`, optional): Number of undistributed prizes + only_new_members (:obj:`True`, optional): :obj:`True`, if only users who had joined the + chats after the giveaway started were eligible to win + was_refunded (:obj:`True`, optional): :obj:`True`, if the giveaway was canceled because the + payment for it was refunded + prize_description (:obj:`str`, optional): Description of additional giveaway prize + + Attributes: + chat (:class:`telegram.Chat`): The chat that created the giveaway + giveaway_message_id (:obj:`int`): Identifier of the message with the giveaway in the chat + winners_selection_date (:class:`datetime.datetime`): Point in time when winners of the + giveaway were selected. |datetime_localization| + winner_count (:obj:`int`): Total number of winners in the giveaway + winners (tuple[:class:`telegram.User`]): tuple of up to + :tg-const:`telegram.constants.GiveawayLimit.MAX_WINNERS` winners of the giveaway + additional_chat_count (:obj:`int`): Optional. The number of other chats the user had to + join in order to be eligible for the giveaway + prize_star_count (:obj:`int`): Optional. The number of Telegram Stars to be split between + giveaway winners; for Telegram Star giveaways only. + + .. versionadded:: 21.6 + premium_subscription_month_count (:obj:`int`): Optional. The number of months the Telegram + Premium subscription won from the giveaway will be active for + unclaimed_prize_count (:obj:`int`): Optional. Number of undistributed prizes + only_new_members (:obj:`True`): Optional. :obj:`True`, if only users who had joined the + chats after the giveaway started were eligible to win + was_refunded (:obj:`True`): Optional. :obj:`True`, if the giveaway was canceled because the + payment for it was refunded + prize_description (:obj:`str`): Optional. Description of additional giveaway prize + """ + + __slots__ = ( + "additional_chat_count", + "chat", + "giveaway_message_id", + "only_new_members", + "premium_subscription_month_count", + "prize_description", + "prize_star_count", + "unclaimed_prize_count", + "was_refunded", + "winner_count", + "winners", + "winners_selection_date", + ) + + def __init__( + self, + chat: Chat, + giveaway_message_id: int, + winners_selection_date: dtm.datetime, + winner_count: int, + winners: Sequence[User], + additional_chat_count: int | None = None, + premium_subscription_month_count: int | None = None, + unclaimed_prize_count: int | None = None, + only_new_members: bool | None = None, + was_refunded: bool | None = None, + prize_description: str | None = None, + prize_star_count: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.chat: Chat = chat + self.giveaway_message_id: int = giveaway_message_id + self.winners_selection_date: dtm.datetime = winners_selection_date + self.winner_count: int = winner_count + self.winners: tuple[User, ...] = tuple(winners) + self.additional_chat_count: int | None = additional_chat_count + self.premium_subscription_month_count: int | None = premium_subscription_month_count + self.unclaimed_prize_count: int | None = unclaimed_prize_count + self.only_new_members: bool | None = only_new_members + self.was_refunded: bool | None = was_refunded + self.prize_description: str | None = prize_description + self.prize_star_count: int | None = prize_star_count + + self._id_attrs = ( + self.chat, + self.giveaway_message_id, + self.winners_selection_date, + self.winner_count, + self.winners, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "GiveawayWinners": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["winners"] = de_list_optional(data.get("winners"), User, bot) + data["winners_selection_date"] = from_timestamp( + data.get("winners_selection_date"), tzinfo=loc_tzinfo + ) + + return super().de_json(data=data, bot=bot) + + +class GiveawayCompleted(TelegramObject): + """This object represents a service message about the completion of a giveaway without public + winners. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`winner_count` and :attr:`unclaimed_prize_count` are equal. + + .. versionadded:: 20.8 + + + Args: + winner_count (:obj:`int`): Number of winners in the giveaway + unclaimed_prize_count (:obj:`int`, optional): Number of undistributed prizes + giveaway_message (:class:`telegram.Message`, optional): Message with the giveaway that was + completed, if it wasn't deleted + is_star_giveaway (:obj:`bool`, optional): :obj:`True`, if the giveaway is a Telegram Star + giveaway. Otherwise, currently, the giveaway is a Telegram Premium giveaway. + + .. versionadded:: 21.6 + Attributes: + winner_count (:obj:`int`): Number of winners in the giveaway + unclaimed_prize_count (:obj:`int`): Optional. Number of undistributed prizes + giveaway_message (:class:`telegram.Message`): Optional. Message with the giveaway that was + completed, if it wasn't deleted + is_star_giveaway (:obj:`bool`): Optional. :obj:`True`, if the giveaway is a Telegram Star + giveaway. Otherwise, currently, the giveaway is a Telegram Premium giveaway. + + .. versionadded:: 21.6 + """ + + __slots__ = ("giveaway_message", "is_star_giveaway", "unclaimed_prize_count", "winner_count") + + def __init__( + self, + winner_count: int, + unclaimed_prize_count: int | None = None, + giveaway_message: "Message | None" = None, + is_star_giveaway: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.winner_count: int = winner_count + self.unclaimed_prize_count: int | None = unclaimed_prize_count + self.giveaway_message: Message | None = giveaway_message + self.is_star_giveaway: bool | None = is_star_giveaway + + self._id_attrs = ( + self.winner_count, + self.unclaimed_prize_count, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "GiveawayCompleted": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # Unfortunately, this needs to be here due to cyclic imports + from telegram._message import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + Message, + ) + + data["giveaway_message"] = de_json_optional(data.get("giveaway_message"), Message, bot) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_utils/__init__.py b/src/telegram/_inline/__init__.py similarity index 100% rename from telegram/_utils/__init__.py rename to src/telegram/_inline/__init__.py diff --git a/telegram/_inline/inlinekeyboardbutton.py b/src/telegram/_inline/inlinekeyboardbutton.py similarity index 62% rename from telegram/_inline/inlinekeyboardbutton.py rename to src/telegram/_inline/inlinekeyboardbutton.py index 83163948ca4..7114fac60e5 100644 --- a/telegram/_inline/inlinekeyboardbutton.py +++ b/src/telegram/_inline/inlinekeyboardbutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,13 +18,15 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram InlineKeyboardButton.""" -from typing import TYPE_CHECKING, ClassVar, Optional, Union +from typing import TYPE_CHECKING, Final from telegram import constants +from telegram._copytextbutton import CopyTextButton from telegram._games.callbackgame import CallbackGame from telegram._loginurl import LoginUrl from telegram._switchinlinequerychosenchat import SwitchInlineQueryChosenChat from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict from telegram._webappinfo import WebAppInfo @@ -41,11 +43,12 @@ class InlineKeyboardButton(TelegramObject): :attr:`web_app` and :attr:`pay` are equal. Note: - * You must use exactly one of the optional fields. Mind that :attr:`callback_game` is not + * Exactly one of the optional fields must be used to specify type of the button. + * Mind that :attr:`callback_game` is not 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. @@ -88,7 +91,7 @@ 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. If the bot instance allows arbitrary callback data, anything can be passed. @@ -98,39 +101,51 @@ class InlineKeyboardButton(TelegramObject): .. seealso:: :wiki:`Arbitrary callback_data ` - web_app (:obj:`telegram.WebAppInfo`, optional): Description of the `Web App + web_app (:class:`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 using the method :meth:`~telegram.Bot.answer_web_app_query`. Available only in - private chats between a user and the bot. + private chats between a user and the bot. Not supported for messages sent on behalf of + a Telegram Business account. .. versionadded:: 20.0 switch_inline_query (:obj:`str`, optional): If set, pressing the button will prompt the user to select one of their chats, open that chat and insert the bot's username and the - specified inline query in the input field. Can be empty, in which case just the bot's - username will be inserted. This offers an easy way for users to start using your bot - in inline mode when they are currently in a private chat with it. Especially useful - when combined with ``switch_pm*`` actions - in this case the user will be automatically - returned to the chat they switched from, skipping the chat selection screen. + specified inline query in the input field. May be empty, in which case just the bot's + username will be inserted. Not supported for messages sent on behalf of a Telegram + Business account. Tip: - This is similar to the new parameter :paramref:`switch_inline_query_chosen_chat`, + This is similar to the 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 insert the bot's username and the specified inline query in the current chat's input - field. Can 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. + 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. + copy_text (:class:`telegram.CopyTextButton`, optional): Description of the button that + copies the specified text to the clipboard. + + .. versionadded:: 21.7 callback_game (:class:`telegram.CallbackGame`, optional): Description of the game that will - be launched when the user presses the button. This type of button **must** always be - the **first** button in the first row. - pay (:obj:`bool`, optional): Specify :obj:`True`, to send a Pay button. This type of button - **must** always be the **first** button in the first row and can only be used in - invoice messages. - switch_inline_query_chosen_chat (:obj:`telegram.SwitchInlineQueryChosenChat`, optional): + be launched when the user presses the button + + Note: + This type of button **must** always be the first button in the first row. + pay (:obj:`bool`, optional): Specify :obj:`True`, to send a Pay button. + Substrings ``“⭐️”`` and ``“XTR”`` in the buttons's text will be replaced with a + Telegram Star icon. + + Note: + This type of button **must** always be the first button in the first row and can + only be used in invoice messages. + switch_inline_query_chosen_chat (:class:`telegram.SwitchInlineQueryChosenChat`, 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. + query in the input field. Not supported for messages sent on behalf of a Telegram + Business account. .. versionadded:: 20.3 @@ -156,42 +171,54 @@ 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. - web_app (:obj:`telegram.WebAppInfo`): Optional. Description of the `Web App + web_app (:class:`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 using the method :meth:`~telegram.Bot.answer_web_app_query`. Available only in - private chats between a user and the bot. + private chats between a user and the bot. Not supported for messages sent on behalf of + a Telegram Business account. .. versionadded:: 20.0 switch_inline_query (:obj:`str`): Optional. If set, pressing the button will prompt the user to select one of their chats, open that chat and insert the bot's username and the - specified inline query in the input field. Can be empty, in which case just the bot's - username will be inserted. This offers an easy way for users to start using your bot - in inline mode when they are currently in a private chat with it. Especially useful - when combined with ``switch_pm*`` actions - in this case the user will be automatically - returned to the chat they switched from, skipping the chat selection screen. + specified inline query in the input field. May be empty, in which case just the bot's + username will be inserted. Not supported for messages sent on behalf of a Telegram + Business account. Tip: - This is similar to the new parameter :paramref:`switch_inline_query_chosen_chat`, + This is similar to the 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 insert the bot's username and the specified inline query in the current chat's input - field. Can 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. + 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. + copy_text (:class:`telegram.CopyTextButton`): Optional. Description of the button that + copies the specified text to the clipboard. + + .. versionadded:: 21.7 callback_game (:class:`telegram.CallbackGame`): Optional. Description of the game that will - be launched when the user presses the button. This type of button **must** always be - the **first** button in the first row. - pay (:obj:`bool`): Optional. Specify :obj:`True`, to send a Pay button. This type of button - **must** always be the **first** button in the first row and can only be used in - invoice messages. - switch_inline_query_chosen_chat (:obj:`telegram.SwitchInlineQueryChosenChat`): Optional. + be launched when the user presses the button. + + Note: + This type of button **must** always be the first button in the first row. + pay (:obj:`bool`): Optional. Specify :obj:`True`, to send a Pay button. + Substrings ``“⭐️”`` and ``“XTR”`` in the buttons's text will be replaced with a + Telegram Star icon. + + Note: + This type of button **must** always be the first button in the first row and can + only be used in invoice messages. + switch_inline_query_chosen_chat (:class:`telegram.SwitchInlineQueryChosenChat`): 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. + query in the input field. Not supported for messages sent on behalf of a Telegram + Business account. .. versionadded:: 20.3 @@ -205,49 +232,52 @@ class InlineKeyboardButton(TelegramObject): """ __slots__ = ( - "callback_game", - "url", - "switch_inline_query_current_chat", "callback_data", + "callback_game", + "copy_text", + "login_url", "pay", "switch_inline_query", + "switch_inline_query_chosen_chat", + "switch_inline_query_current_chat", "text", - "login_url", + "url", "web_app", - "switch_inline_query_chosen_chat", ) def __init__( self, text: str, - url: Optional[str] = None, - callback_data: Optional[Union[str, object]] = None, - switch_inline_query: Optional[str] = None, - switch_inline_query_current_chat: Optional[str] = None, - callback_game: Optional[CallbackGame] = None, - pay: Optional[bool] = None, - login_url: Optional[LoginUrl] = None, - web_app: Optional[WebAppInfo] = None, - switch_inline_query_chosen_chat: Optional[SwitchInlineQueryChosenChat] = None, + url: str | None = None, + callback_data: str | object | None = None, + switch_inline_query: str | None = None, + switch_inline_query_current_chat: str | None = None, + callback_game: CallbackGame | None = None, + pay: bool | None = None, + login_url: LoginUrl | None = None, + web_app: WebAppInfo | None = None, + switch_inline_query_chosen_chat: SwitchInlineQueryChosenChat | None = None, + copy_text: CopyTextButton | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.text: str = text # Optionals - self.url: Optional[str] = url - self.login_url: Optional[LoginUrl] = login_url - self.callback_data: Optional[Union[str, object]] = callback_data - self.switch_inline_query: Optional[str] = switch_inline_query - self.switch_inline_query_current_chat: Optional[str] = switch_inline_query_current_chat - self.callback_game: Optional[CallbackGame] = callback_game - self.pay: Optional[bool] = pay - self.web_app: Optional[WebAppInfo] = web_app - self.switch_inline_query_chosen_chat: Optional[ - SwitchInlineQueryChosenChat - ] = switch_inline_query_chosen_chat + self.url: str | None = url + self.login_url: LoginUrl | None = login_url + self.callback_data: str | object | None = callback_data + self.switch_inline_query: str | None = switch_inline_query + self.switch_inline_query_current_chat: str | None = switch_inline_query_current_chat + self.callback_game: CallbackGame | None = callback_game + self.pay: bool | None = pay + self.web_app: WebAppInfo | None = web_app + self.switch_inline_query_chosen_chat: SwitchInlineQueryChosenChat | None = ( + switch_inline_query_chosen_chat + ) + self.copy_text: CopyTextButton | None = copy_text self._id_attrs = () self._set_id_attrs() @@ -267,23 +297,21 @@ def _set_id_attrs(self) -> None: ) @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["InlineKeyboardButton"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "InlineKeyboardButton": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["login_url"] = LoginUrl.de_json(data.get("login_url"), bot) - data["web_app"] = WebAppInfo.de_json(data.get("web_app"), bot) - data["callback_game"] = CallbackGame.de_json(data.get("callback_game"), bot) - data["switch_inline_query_chosen_chat"] = SwitchInlineQueryChosenChat.de_json( - data.get("switch_inline_query_chosen_chat"), bot + data["login_url"] = de_json_optional(data.get("login_url"), LoginUrl, bot) + data["web_app"] = de_json_optional(data.get("web_app"), WebAppInfo, bot) + data["callback_game"] = de_json_optional(data.get("callback_game"), CallbackGame, bot) + data["switch_inline_query_chosen_chat"] = de_json_optional( + data.get("switch_inline_query_chosen_chat"), SwitchInlineQueryChosenChat, bot ) + data["copy_text"] = de_json_optional(data.get("copy_text"), CopyTextButton, bot) return super().de_json(data=data, bot=bot) - def update_callback_data(self, callback_data: Union[str, object]) -> None: + def update_callback_data(self, callback_data: str | object) -> None: """ Sets :attr:`callback_data` to the passed object. Intended to be used by :class:`telegram.ext.CallbackDataCache`. @@ -297,12 +325,12 @@ def update_callback_data(self, callback_data: Union[str, object]) -> None: self.callback_data = callback_data self._set_id_attrs() - MIN_CALLBACK_DATA: ClassVar[int] = constants.InlineKeyboardButtonLimit.MIN_CALLBACK_DATA + MIN_CALLBACK_DATA: Final[int] = constants.InlineKeyboardButtonLimit.MIN_CALLBACK_DATA """:const:`telegram.constants.InlineKeyboardButtonLimit.MIN_CALLBACK_DATA` .. versionadded:: 20.0 """ - MAX_CALLBACK_DATA: ClassVar[int] = constants.InlineKeyboardButtonLimit.MAX_CALLBACK_DATA + MAX_CALLBACK_DATA: Final[int] = constants.InlineKeyboardButtonLimit.MAX_CALLBACK_DATA """:const:`telegram.constants.InlineKeyboardButtonLimit.MAX_CALLBACK_DATA` .. versionadded:: 20.0 diff --git a/telegram/_inline/inlinekeyboardmarkup.py b/src/telegram/_inline/inlinekeyboardmarkup.py similarity index 91% rename from telegram/_inline/inlinekeyboardmarkup.py rename to src/telegram/_inline/inlinekeyboardmarkup.py index 78b04fec755..37040bc9ec5 100644 --- a/telegram/_inline/inlinekeyboardmarkup.py +++ b/src/telegram/_inline/inlinekeyboardmarkup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,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/]. """This module contains an object that represents a Telegram InlineKeyboardMarkup.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardbutton import InlineKeyboardButton from telegram._telegramobject import TelegramObject @@ -42,7 +44,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 ` @@ -57,7 +59,7 @@ class InlineKeyboardMarkup(TelegramObject): |sequenceclassargs| Attributes: - inline_keyboard (Tuple[Tuple[:class:`telegram.InlineKeyboardButton`]]): Tuple of + inline_keyboard (tuple[tuple[:class:`telegram.InlineKeyboardButton`]]): Tuple of button rows, each represented by a tuple of :class:`~telegram.InlineKeyboardButton` objects. @@ -72,7 +74,7 @@ def __init__( self, inline_keyboard: Sequence[Sequence[InlineKeyboardButton]], *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) if not check_keyboard_type(inline_keyboard): @@ -81,7 +83,7 @@ def __init__( "InlineKeyboardButtons" ) # Required - self.inline_keyboard: Tuple[Tuple[InlineKeyboardButton, ...], ...] = tuple( + self.inline_keyboard: tuple[tuple[InlineKeyboardButton, ...], ...] = tuple( tuple(row) for row in inline_keyboard ) @@ -90,10 +92,8 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["InlineKeyboardMarkup"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "InlineKeyboardMarkup": """See :meth:`telegram.TelegramObject.de_json`.""" - if not data: - return None keyboard = [] for row in data["inline_keyboard"]: diff --git a/telegram/_inline/inlinequery.py b/src/telegram/_inline/inlinequery.py similarity index 78% rename from telegram/_inline/inlinequery.py rename to src/telegram/_inline/inlinequery.py index 786430ebede..48ff6162f17 100644 --- a/telegram/_inline/inlinequery.py +++ b/src/telegram/_inline/inlinequery.py @@ -2,7 +2,7 @@ # pylint: disable=too-many-arguments # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,15 +19,17 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram InlineQuery.""" -from typing import TYPE_CHECKING, Callable, ClassVar, Optional, Sequence, Union +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._files.location import Location from telegram._inline.inlinequeryresultsbutton import InlineQueryResultsButton from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod if TYPE_CHECKING: from telegram import Bot, InlineQueryResult @@ -60,6 +62,12 @@ class InlineQuery(TelegramObject): ``auto_pagination``. Use a named argument for those, and notice that some positional arguments changed position as a result. + .. versionchanged:: 22.0 + Removed constants ``MIN_START_PARAMETER_LENGTH`` and ``MAX_START_PARAMETER_LENGTH``. + Use :attr:`telegram.constants.InlineQueryResultsButtonLimit.MIN_START_PARAMETER_LENGTH` and + :attr:`telegram.constants.InlineQueryResultsButtonLimit.MAX_START_PARAMETER_LENGTH` + instead. + Args: id (:obj:`str`): Unique identifier for this query. from_user (:class:`telegram.User`): Sender. @@ -96,7 +104,7 @@ class InlineQuery(TelegramObject): """ - __slots__ = ("location", "chat_type", "id", "offset", "from_user", "query") + __slots__ = ("chat_type", "from_user", "id", "location", "offset", "query") def __init__( self, @@ -104,58 +112,53 @@ def __init__( from_user: User, query: str, offset: str, - location: Optional[Location] = None, - chat_type: Optional[str] = None, + location: "Location | None" = None, + chat_type: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required - self.id: str = id # pylint: disable=invalid-name + self.id: str = id self.from_user: User = from_user self.query: str = query self.offset: str = offset # Optional - self.location: Optional[Location] = location - self.chat_type: Optional[str] = chat_type + self.location: Location | None = location + self.chat_type: str | None = chat_type self._id_attrs = (self.id,) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["InlineQuery"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "InlineQuery": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["from_user"] = User.de_json(data.pop("from", None), bot) - data["location"] = Location.de_json(data.get("location"), bot) + data["from_user"] = de_json_optional(data.pop("from", None), User, bot) + data["location"] = de_json_optional(data.get("location"), Location, bot) return super().de_json(data=data, bot=bot) async def answer( self, - results: Union[ - Sequence["InlineQueryResult"], Callable[[int], Optional[Sequence["InlineQueryResult"]]] - ], - cache_time: Optional[int] = None, - is_personal: Optional[bool] = None, - next_offset: Optional[str] = None, - switch_pm_text: Optional[str] = None, - switch_pm_parameter: Optional[str] = None, - button: Optional[InlineQueryResultsButton] = None, + results: ( + Sequence["InlineQueryResult"] | Callable[[int], Sequence["InlineQueryResult"] | None] + ), + cache_time: TimePeriod | None = None, + is_personal: bool | None = None, + next_offset: str | None = None, + button: InlineQueryResultsButton | None = None, *, - current_offset: Optional[str] = None, + current_offset: str | None = None, auto_pagination: bool = False, 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: @@ -192,8 +195,6 @@ async def answer( cache_time=cache_time, is_personal=is_personal, next_offset=next_offset, - switch_pm_text=switch_pm_text, - switch_pm_parameter=switch_pm_parameter, button=button, read_timeout=read_timeout, write_timeout=write_timeout, @@ -202,27 +203,17 @@ async def answer( api_kwargs=api_kwargs, ) - MAX_RESULTS: ClassVar[int] = constants.InlineQueryLimit.RESULTS + MAX_RESULTS: Final[int] = constants.InlineQueryLimit.RESULTS """:const:`telegram.constants.InlineQueryLimit.RESULTS` .. versionadded:: 13.2 """ - MIN_SWITCH_PM_TEXT_LENGTH: ClassVar[int] = constants.InlineQueryLimit.MIN_SWITCH_PM_TEXT_LENGTH - """:const:`telegram.constants.InlineQueryLimit.MIN_SWITCH_PM_TEXT_LENGTH` - - .. versionadded:: 20.0 - """ - MAX_SWITCH_PM_TEXT_LENGTH: ClassVar[int] = constants.InlineQueryLimit.MAX_SWITCH_PM_TEXT_LENGTH - """:const:`telegram.constants.InlineQueryLimit.MAX_SWITCH_PM_TEXT_LENGTH` - - .. versionadded:: 20.0 - """ - MAX_OFFSET_LENGTH: ClassVar[int] = constants.InlineQueryLimit.MAX_OFFSET_LENGTH + MAX_OFFSET_LENGTH: Final[int] = constants.InlineQueryLimit.MAX_OFFSET_LENGTH """:const:`telegram.constants.InlineQueryLimit.MAX_OFFSET_LENGTH` .. versionadded:: 20.0 """ - MAX_QUERY_LENGTH: ClassVar[int] = constants.InlineQueryLimit.MAX_QUERY_LENGTH + MAX_QUERY_LENGTH: Final[int] = constants.InlineQueryLimit.MAX_QUERY_LENGTH """:const:`telegram.constants.InlineQueryLimit.MAX_QUERY_LENGTH` .. versionadded:: 20.0 diff --git a/telegram/_inline/inlinequeryresult.py b/src/telegram/_inline/inlinequeryresult.py similarity index 83% rename from telegram/_inline/inlinequeryresult.py rename to src/telegram/_inline/inlinequeryresult.py index 69d7f5de9ca..7e708ebbfe2 100644 --- a/telegram/_inline/inlinequeryresult.py +++ b/src/telegram/_inline/inlinequeryresult.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,10 +19,11 @@ # pylint: disable=redefined-builtin """This module contains the classes that represent Telegram InlineQueryResult.""" -from typing import ClassVar, Optional +from typing import Final from telegram import constants from telegram._telegramobject import TelegramObject +from telegram._utils import enum from telegram._utils.types import JSONDict @@ -53,25 +54,25 @@ class InlineQueryResult(TelegramObject): """ - __slots__ = ("type", "id") + __slots__ = ("id", "type") - def __init__(self, type: str, id: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, type: str, id: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) # Required - self.type: str = type - self.id: str = str(id) # pylint: disable=invalid-name + self.type: str = enum.get_member(constants.InlineQueryResultType, type, type) + self.id: str = str(id) self._id_attrs = (self.id,) self._freeze() - MIN_ID_LENGTH: ClassVar[int] = constants.InlineQueryResultLimit.MIN_ID_LENGTH + MIN_ID_LENGTH: Final[int] = constants.InlineQueryResultLimit.MIN_ID_LENGTH """:const:`telegram.constants.InlineQueryResultLimit.MIN_ID_LENGTH` .. versionadded:: 20.0 """ - MAX_ID_LENGTH: ClassVar[int] = constants.InlineQueryResultLimit.MAX_ID_LENGTH + MAX_ID_LENGTH: Final[int] = constants.InlineQueryResultLimit.MAX_ID_LENGTH """:const:`telegram.constants.InlineQueryResultLimit.MAX_ID_LENGTH` .. versionadded:: 20.0 diff --git a/telegram/_inline/inlinequeryresultarticle.py b/src/telegram/_inline/inlinequeryresultarticle.py similarity index 50% rename from telegram/_inline/inlinequeryresultarticle.py rename to src/telegram/_inline/inlinequeryresultarticle.py index d82ee58c657..cca861b76f7 100644 --- a/telegram/_inline/inlinequeryresultarticle.py +++ b/src/telegram/_inline/inlinequeryresultarticle.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,15 +18,11 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultArticle.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._utils.types import JSONDict -from telegram._utils.warnings_transition import ( - warn_about_deprecated_arg_return_new_arg, - warn_about_deprecated_attr_in_property, -) from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -39,6 +35,12 @@ class InlineQueryResultArticle(InlineQueryResult): Examples: :any:`Inline Bot ` + .. versionchanged:: 20.5 + Removed the deprecated arguments and attributes ``thumb_*``. + + .. versionchanged:: 21.11 + Removed the deprecated argument and attribute ``hide_url``. + Args: id (:obj:`str`): Unique identifier for this result, :tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`- @@ -49,21 +51,10 @@ class InlineQueryResultArticle(InlineQueryResult): reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached to the message. url (:obj:`str`, optional): URL of the result. - hide_url (:obj:`bool`, optional): Pass :obj:`True`, if you don't want the URL to be shown - in the message. - description (:obj:`str`, optional): Short description of the result. - thumb_url (:obj:`str`, optional): Url of the thumbnail for the result. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_url`. - thumb_width (:obj:`int`, optional): Thumbnail width. - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_width`. - thumb_height (:obj:`int`, optional): Thumbnail height. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_height`. + Tip: + Pass an empty string as URL if you don't want the URL to be shown in the message. + description (:obj:`str`, optional): Short description of the result. thumbnail_url (:obj:`str`, optional): Url of the thumbnail for the result. .. versionadded:: 20.2 @@ -85,8 +76,6 @@ class InlineQueryResultArticle(InlineQueryResult): reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached to the message. url (:obj:`str`): Optional. URL of the result. - hide_url (:obj:`bool`): Optional. Pass :obj:`True`, if you don't want the URL to be shown - in the message. description (:obj:`str`): Optional. Short description of the result. thumbnail_url (:obj:`str`): Optional. Url of the thumbnail for the result. @@ -101,15 +90,14 @@ class InlineQueryResultArticle(InlineQueryResult): """ __slots__ = ( - "reply_markup", - "hide_url", - "url", - "title", "description", "input_message_content", - "thumbnail_width", + "reply_markup", "thumbnail_height", "thumbnail_url", + "thumbnail_width", + "title", + "url", ) def __init__( @@ -117,18 +105,14 @@ def __init__( id: str, # pylint: disable=redefined-builtin title: str, input_message_content: "InputMessageContent", - reply_markup: Optional[InlineKeyboardMarkup] = None, - url: Optional[str] = None, - hide_url: Optional[bool] = None, - description: Optional[str] = None, - thumb_url: Optional[str] = None, - thumb_width: Optional[int] = None, - thumb_height: Optional[int] = None, - thumbnail_url: Optional[str] = None, - thumbnail_width: Optional[int] = None, - thumbnail_height: Optional[int] = None, + reply_markup: InlineKeyboardMarkup | None = None, + url: str | None = None, + description: str | None = None, + thumbnail_url: str | None = None, + thumbnail_width: int | None = None, + thumbnail_height: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.ARTICLE, id, api_kwargs=api_kwargs) @@ -137,70 +121,9 @@ def __init__( self.input_message_content: InputMessageContent = input_message_content # Optional - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.url: Optional[str] = url - self.hide_url: Optional[bool] = hide_url - self.description: Optional[str] = description - self.thumbnail_url: Optional[str] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_url, - new_arg=thumbnail_url, - deprecated_arg_name="thumb_url", - new_arg_name="thumbnail_url", - bot_api_version="6.6", - ) - self.thumbnail_width: Optional[int] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_width, - new_arg=thumbnail_width, - deprecated_arg_name="thumb_width", - new_arg_name="thumbnail_width", - bot_api_version="6.6", - ) - self.thumbnail_height: Optional[int] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_height, - new_arg=thumbnail_height, - deprecated_arg_name="thumb_height", - new_arg_name="thumbnail_height", - bot_api_version="6.6", - ) - - @property - def thumb_url(self) -> Optional[str]: - """:obj:`str`: Optional. Url of the thumbnail for the result. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_url`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_url", - new_attr_name="thumbnail_url", - bot_api_version="6.6", - ) - return self.thumbnail_url - - @property - def thumb_width(self) -> Optional[int]: - """:obj:`str`: Optional. Thumbnail width. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_width`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_width", - new_attr_name="thumbnail_width", - bot_api_version="6.6", - ) - return self.thumbnail_width - - @property - def thumb_height(self) -> Optional[int]: - """:obj:`str`: Optional. Thumbnail height. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_height`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_height", - new_attr_name="thumbnail_height", - bot_api_version="6.6", - ) - return self.thumbnail_height + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.url: str | None = url + self.description: str | None = description + self.thumbnail_url: str | None = thumbnail_url + self.thumbnail_width: int | None = thumbnail_width + self.thumbnail_height: int | None = thumbnail_height diff --git a/telegram/_inline/inlinequeryresultaudio.py b/src/telegram/_inline/inlinequeryresultaudio.py similarity index 72% rename from telegram/_inline/inlinequeryresultaudio.py rename to src/telegram/_inline/inlinequeryresultaudio.py index cd821db9aa7..02ce1a1eea1 100644 --- a/telegram/_inline/inlinequeryresultaudio.py +++ b/src/telegram/_inline/inlinequeryresultaudio.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,14 +17,18 @@ # 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 the classes that represent Telegram InlineQueryResultAudio.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -46,7 +50,11 @@ class InlineQueryResultAudio(InlineQueryResult): audio_url (:obj:`str`): A valid URL for the audio file. title (:obj:`str`): Title. performer (:obj:`str`, optional): Performer. - audio_duration (:obj:`str`, optional): Audio duration in seconds. + audio_duration (:obj:`int` | :class:`datetime.timedelta`, optional): Audio duration + in seconds. + + .. versionchanged:: v22.2 + |time-period-input| caption (:obj:`str`, optional): Caption, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -68,12 +76,16 @@ class InlineQueryResultAudio(InlineQueryResult): audio_url (:obj:`str`): A valid URL for the audio file. title (:obj:`str`): Title. performer (:obj:`str`): Optional. Performer. - audio_duration (:obj:`str`): Optional. Audio duration in seconds. + audio_duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Audio duration + in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| caption (:obj:`str`): Optional. Caption, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 @@ -87,15 +99,15 @@ class InlineQueryResultAudio(InlineQueryResult): """ __slots__ = ( - "reply_markup", - "caption_entities", + "_audio_duration", + "audio_url", "caption", - "title", + "caption_entities", + "input_message_content", "parse_mode", - "audio_url", "performer", - "input_message_content", - "audio_duration", + "reply_markup", + "title", ) def __init__( @@ -103,15 +115,15 @@ def __init__( id: str, # pylint: disable=redefined-builtin audio_url: str, title: str, - performer: Optional[str] = None, - audio_duration: Optional[int] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + performer: str | None = None, + audio_duration: TimePeriod | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, + caption_entities: Sequence[MessageEntity] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.AUDIO, id, api_kwargs=api_kwargs) @@ -120,10 +132,14 @@ def __init__( self.title: str = title # Optionals - self.performer: Optional[str] = performer - self.audio_duration: Optional[int] = audio_duration - self.caption: Optional[str] = caption + self.performer: str | None = performer + self._audio_duration: dtm.timedelta | None = to_timedelta(audio_duration) + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode - self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + + @property + def audio_duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._audio_duration, attribute="audio_duration") diff --git a/telegram/_inline/inlinequeryresultcachedaudio.py b/src/telegram/_inline/inlinequeryresultcachedaudio.py similarity index 86% rename from telegram/_inline/inlinequeryresultcachedaudio.py rename to src/telegram/_inline/inlinequeryresultcachedaudio.py index a30ede99391..d925d92d33a 100644 --- a/telegram/_inline/inlinequeryresultcachedaudio.py +++ b/src/telegram/_inline/inlinequeryresultcachedaudio.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,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/]. """This module contains the classes that represent Telegram InlineQueryResultCachedAudio.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -68,7 +70,7 @@ class InlineQueryResultCachedAudio(InlineQueryResult): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 @@ -82,25 +84,25 @@ class InlineQueryResultCachedAudio(InlineQueryResult): """ __slots__ = ( - "reply_markup", - "caption_entities", - "caption", - "parse_mode", "audio_file_id", + "caption", + "caption_entities", "input_message_content", + "parse_mode", + "reply_markup", ) def __init__( self, id: str, # pylint: disable=redefined-builtin audio_file_id: str, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, + caption_entities: Sequence[MessageEntity] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.AUDIO, id, api_kwargs=api_kwargs) @@ -108,8 +110,8 @@ def __init__( self.audio_file_id: str = audio_file_id # Optionals - self.caption: Optional[str] = caption + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode - self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content diff --git a/telegram/_inline/inlinequeryresultcacheddocument.py b/src/telegram/_inline/inlinequeryresultcacheddocument.py similarity index 85% rename from telegram/_inline/inlinequeryresultcacheddocument.py rename to src/telegram/_inline/inlinequeryresultcacheddocument.py index 4e189e6f201..0f7771ccba7 100644 --- a/telegram/_inline/inlinequeryresultcacheddocument.py +++ b/src/telegram/_inline/inlinequeryresultcacheddocument.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,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/]. """This module contains the classes that represent Telegram InlineQueryResultCachedDocument.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -72,7 +74,7 @@ class InlineQueryResultCachedDocument(InlineQueryResult): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 @@ -86,14 +88,14 @@ class InlineQueryResultCachedDocument(InlineQueryResult): """ __slots__ = ( - "reply_markup", - "caption_entities", - "document_file_id", "caption", - "title", + "caption_entities", "description", - "parse_mode", + "document_file_id", "input_message_content", + "parse_mode", + "reply_markup", + "title", ) def __init__( @@ -101,14 +103,14 @@ def __init__( id: str, # pylint: disable=redefined-builtin title: str, document_file_id: str, - description: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + description: str | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, + caption_entities: Sequence[MessageEntity] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.DOCUMENT, id, api_kwargs=api_kwargs) @@ -117,9 +119,9 @@ def __init__( self.document_file_id: str = document_file_id # Optionals - self.description: Optional[str] = description - self.caption: Optional[str] = caption + self.description: str | None = description + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode - self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content diff --git a/telegram/_inline/inlinequeryresultcachedgif.py b/src/telegram/_inline/inlinequeryresultcachedgif.py similarity index 79% rename from telegram/_inline/inlinequeryresultcachedgif.py rename to src/telegram/_inline/inlinequeryresultcachedgif.py index 636fd00ffad..e9fbd38a37c 100644 --- a/telegram/_inline/inlinequeryresultcachedgif.py +++ b/src/telegram/_inline/inlinequeryresultcachedgif.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,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/]. """This module contains the classes that represent Telegram InlineQueryResultCachedGif.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -59,6 +61,9 @@ class InlineQueryResultCachedGif(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`, optional): Content of the message to be sent instead of the gif. + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + + .. versionadded:: 21.3 Attributes: type (:obj:`str`): :tg-const:`telegram.constants.InlineQueryResultType.GIF`. @@ -71,7 +76,7 @@ class InlineQueryResultCachedGif(InlineQueryResult): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 @@ -81,31 +86,36 @@ class InlineQueryResultCachedGif(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`): Optional. Content of the message to be sent instead of the gif. + show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med| + + .. versionadded:: 21.3 """ __slots__ = ( - "reply_markup", - "caption_entities", "caption", - "title", + "caption_entities", + "gif_file_id", "input_message_content", "parse_mode", - "gif_file_id", + "reply_markup", + "show_caption_above_media", + "title", ) def __init__( self, id: str, # pylint: disable=redefined-builtin gif_file_id: str, - title: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + title: str | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, + caption_entities: Sequence[MessageEntity] | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.GIF, id, api_kwargs=api_kwargs) @@ -113,9 +123,10 @@ def __init__( self.gif_file_id: str = gif_file_id # Optionals - self.title: Optional[str] = title - self.caption: Optional[str] = caption + self.title: str | None = title + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode - self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.show_caption_above_media: bool | None = show_caption_above_media diff --git a/telegram/_inline/inlinequeryresultcachedmpeg4gif.py b/src/telegram/_inline/inlinequeryresultcachedmpeg4gif.py similarity index 79% rename from telegram/_inline/inlinequeryresultcachedmpeg4gif.py rename to src/telegram/_inline/inlinequeryresultcachedmpeg4gif.py index 64eb805a64a..6832e7febdd 100644 --- a/telegram/_inline/inlinequeryresultcachedmpeg4gif.py +++ b/src/telegram/_inline/inlinequeryresultcachedmpeg4gif.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,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/]. """This module contains the classes that represent Telegram InlineQueryResultMpeg4Gif.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -59,6 +61,9 @@ class InlineQueryResultCachedMpeg4Gif(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`, optional): Content of the message to be sent instead of the MPEG-4 file. + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + + .. versionadded:: 21.3 Attributes: type (:obj:`str`): :tg-const:`telegram.constants.InlineQueryResultType.MPEG4GIF`. @@ -71,7 +76,7 @@ class InlineQueryResultCachedMpeg4Gif(InlineQueryResult): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 @@ -81,31 +86,36 @@ class InlineQueryResultCachedMpeg4Gif(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`): Optional. Content of the message to be sent instead of the MPEG-4 file. + show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med| + + .. versionadded:: 21.3 """ __slots__ = ( - "reply_markup", + "caption", "caption_entities", + "input_message_content", "mpeg4_file_id", - "caption", - "title", "parse_mode", - "input_message_content", + "reply_markup", + "show_caption_above_media", + "title", ) def __init__( self, id: str, # pylint: disable=redefined-builtin mpeg4_file_id: str, - title: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + title: str | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, + caption_entities: Sequence[MessageEntity] | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.MPEG4GIF, id, api_kwargs=api_kwargs) @@ -113,9 +123,10 @@ def __init__( self.mpeg4_file_id: str = mpeg4_file_id # Optionals - self.title: Optional[str] = title - self.caption: Optional[str] = caption + self.title: str | None = title + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode - self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.show_caption_above_media: bool | None = show_caption_above_media diff --git a/telegram/_inline/inlinequeryresultcachedphoto.py b/src/telegram/_inline/inlinequeryresultcachedphoto.py similarity index 78% rename from telegram/_inline/inlinequeryresultcachedphoto.py rename to src/telegram/_inline/inlinequeryresultcachedphoto.py index 9a4387b40c4..774fe3257a4 100644 --- a/telegram/_inline/inlinequeryresultcachedphoto.py +++ b/src/telegram/_inline/inlinequeryresultcachedphoto.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,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/]. """This module contains the classes that represent Telegram InlineQueryResultPhoto""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -60,6 +62,9 @@ class InlineQueryResultCachedPhoto(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`, optional): Content of the message to be sent instead of the photo. + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + + .. versionadded:: 21.3 Attributes: type (:obj:`str`): :tg-const:`telegram.constants.InlineQueryResultType.PHOTO`. @@ -73,7 +78,7 @@ class InlineQueryResultCachedPhoto(InlineQueryResult): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 @@ -83,33 +88,38 @@ class InlineQueryResultCachedPhoto(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`): Optional. Content of the message to be sent instead of the photo. + show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med| + + .. versionadded:: 21.3 """ __slots__ = ( - "reply_markup", - "caption_entities", "caption", - "title", + "caption_entities", "description", + "input_message_content", "parse_mode", "photo_file_id", - "input_message_content", + "reply_markup", + "show_caption_above_media", + "title", ) def __init__( self, id: str, # pylint: disable=redefined-builtin photo_file_id: str, - title: Optional[str] = None, - description: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + title: str | None = None, + description: str | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, + caption_entities: Sequence[MessageEntity] | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.PHOTO, id, api_kwargs=api_kwargs) @@ -117,10 +127,11 @@ def __init__( self.photo_file_id: str = photo_file_id # Optionals - self.title: Optional[str] = title - self.description: Optional[str] = description - self.caption: Optional[str] = caption + self.title: str | None = title + self.description: str | None = description + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode - self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.show_caption_above_media: bool | None = show_caption_above_media diff --git a/telegram/_inline/inlinequeryresultcachedsticker.py b/src/telegram/_inline/inlinequeryresultcachedsticker.py similarity index 86% rename from telegram/_inline/inlinequeryresultcachedsticker.py rename to src/telegram/_inline/inlinequeryresultcachedsticker.py index b16bbc10fc5..437a9ceef69 100644 --- a/telegram/_inline/inlinequeryresultcachedsticker.py +++ b/src/telegram/_inline/inlinequeryresultcachedsticker.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultCachedSticker.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -60,16 +60,16 @@ class InlineQueryResultCachedSticker(InlineQueryResult): """ - __slots__ = ("reply_markup", "input_message_content", "sticker_file_id") + __slots__ = ("input_message_content", "reply_markup", "sticker_file_id") def __init__( self, id: str, # pylint: disable=redefined-builtin sticker_file_id: str, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.STICKER, id, api_kwargs=api_kwargs) @@ -77,5 +77,5 @@ def __init__( self.sticker_file_id: str = sticker_file_id # Optionals - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content diff --git a/telegram/_inline/inlinequeryresultcachedvideo.py b/src/telegram/_inline/inlinequeryresultcachedvideo.py similarity index 79% rename from telegram/_inline/inlinequeryresultcachedvideo.py rename to src/telegram/_inline/inlinequeryresultcachedvideo.py index 4f2943580db..19b602da112 100644 --- a/telegram/_inline/inlinequeryresultcachedvideo.py +++ b/src/telegram/_inline/inlinequeryresultcachedvideo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,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/]. """This module contains the classes that represent Telegram InlineQueryResultCachedVideo.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -56,6 +58,9 @@ class InlineQueryResultCachedVideo(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`, optional): Content of the message to be sent instead of the video. + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + + .. versionadded:: 21.3 Attributes: type (:obj:`str`): :tg-const:`telegram.constants.InlineQueryResultType.VIDEO`. @@ -69,7 +74,7 @@ class InlineQueryResultCachedVideo(InlineQueryResult): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 @@ -79,17 +84,21 @@ class InlineQueryResultCachedVideo(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`): Optional. Content of the message to be sent instead of the video. + show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med| + + .. versionadded:: 21.3 """ __slots__ = ( - "reply_markup", - "caption_entities", "caption", - "title", + "caption_entities", "description", - "parse_mode", "input_message_content", + "parse_mode", + "reply_markup", + "show_caption_above_media", + "title", "video_file_id", ) @@ -98,14 +107,15 @@ def __init__( id: str, # pylint: disable=redefined-builtin video_file_id: str, title: str, - description: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + description: str | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, + caption_entities: Sequence[MessageEntity] | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.VIDEO, id, api_kwargs=api_kwargs) @@ -114,9 +124,10 @@ def __init__( self.title: str = title # Optionals - self.description: Optional[str] = description - self.caption: Optional[str] = caption + self.description: str | None = description + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode - self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.show_caption_above_media: bool | None = show_caption_above_media diff --git a/telegram/_inline/inlinequeryresultcachedvoice.py b/src/telegram/_inline/inlinequeryresultcachedvoice.py similarity index 86% rename from telegram/_inline/inlinequeryresultcachedvoice.py rename to src/telegram/_inline/inlinequeryresultcachedvoice.py index a14af762402..c2114cb8906 100644 --- a/telegram/_inline/inlinequeryresultcachedvoice.py +++ b/src/telegram/_inline/inlinequeryresultcachedvoice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,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/]. """This module contains the classes that represent Telegram InlineQueryResultCachedVoice.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -70,7 +72,7 @@ class InlineQueryResultCachedVoice(InlineQueryResult): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |caption_entities| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |caption_entities| .. versionchanged:: 20.0 @@ -84,13 +86,13 @@ class InlineQueryResultCachedVoice(InlineQueryResult): """ __slots__ = ( - "reply_markup", - "caption_entities", "caption", - "title", + "caption_entities", + "input_message_content", "parse_mode", + "reply_markup", + "title", "voice_file_id", - "input_message_content", ) def __init__( @@ -98,13 +100,13 @@ def __init__( id: str, # pylint: disable=redefined-builtin voice_file_id: str, title: str, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, + caption_entities: Sequence[MessageEntity] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.VOICE, id, api_kwargs=api_kwargs) @@ -113,8 +115,8 @@ def __init__( self.title: str = title # Optionals - self.caption: Optional[str] = caption + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode - self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content diff --git a/telegram/_inline/inlinequeryresultcontact.py b/src/telegram/_inline/inlinequeryresultcontact.py similarity index 54% rename from telegram/_inline/inlinequeryresultcontact.py rename to src/telegram/_inline/inlinequeryresultcontact.py index 6a5653e4137..53c5670820b 100644 --- a/telegram/_inline/inlinequeryresultcontact.py +++ b/src/telegram/_inline/inlinequeryresultcontact.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,15 +18,11 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultContact.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._utils.types import JSONDict -from telegram._utils.warnings_transition import ( - warn_about_deprecated_arg_return_new_arg, - warn_about_deprecated_attr_in_property, -) from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -39,6 +35,9 @@ class InlineQueryResultContact(InlineQueryResult): Alternatively, you can use :attr:`input_message_content` to send a message with the specified content instead of the contact. + .. versionchanged:: 20.5 + |removed_thumb_wildcard_note| + Args: id (:obj:`str`): Unique identifier for this result, :tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`- @@ -52,18 +51,6 @@ class InlineQueryResultContact(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`, optional): Content of the message to be sent instead of the contact. - thumb_url (:obj:`str`, optional): Url of the thumbnail for the result. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_url`. - thumb_width (:obj:`int`, optional): Thumbnail width. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_width`. - thumb_height (:obj:`int`, optional): Thumbnail height. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_height`. thumbnail_url (:obj:`str`, optional): Url of the thumbnail for the result. .. versionadded:: 20.2 @@ -101,15 +88,15 @@ class InlineQueryResultContact(InlineQueryResult): """ __slots__ = ( - "reply_markup", - "thumbnail_width", - "thumbnail_height", - "vcard", "first_name", + "input_message_content", "last_name", "phone_number", - "input_message_content", + "reply_markup", + "thumbnail_height", "thumbnail_url", + "thumbnail_width", + "vcard", ) def __init__( @@ -117,18 +104,15 @@ def __init__( id: str, # pylint: disable=redefined-builtin phone_number: str, first_name: str, - last_name: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, - thumb_url: Optional[str] = None, - thumb_width: Optional[int] = None, - thumb_height: Optional[int] = None, - vcard: Optional[str] = None, - thumbnail_url: Optional[str] = None, - thumbnail_width: Optional[int] = None, - thumbnail_height: Optional[int] = None, + last_name: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, + vcard: str | None = None, + thumbnail_url: str | None = None, + thumbnail_width: int | None = None, + thumbnail_height: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.CONTACT, id, api_kwargs=api_kwargs) @@ -137,70 +121,10 @@ def __init__( self.first_name: str = first_name # Optionals - self.last_name: Optional[str] = last_name - self.vcard: Optional[str] = vcard - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.thumbnail_url: Optional[str] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_url, - new_arg=thumbnail_url, - deprecated_arg_name="thumb_url", - new_arg_name="thumbnail_url", - bot_api_version="6.6", - ) - self.thumbnail_width: Optional[int] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_width, - new_arg=thumbnail_width, - deprecated_arg_name="thumb_width", - new_arg_name="thumbnail_width", - bot_api_version="6.6", - ) - self.thumbnail_height: Optional[int] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_height, - new_arg=thumbnail_height, - deprecated_arg_name="thumb_height", - new_arg_name="thumbnail_height", - bot_api_version="6.6", - ) - - @property - def thumb_url(self) -> Optional[str]: - """:obj:`str`: Optional. Url of the thumbnail for the result. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_url`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_url", - new_attr_name="thumbnail_url", - bot_api_version="6.6", - ) - return self.thumbnail_url - - @property - def thumb_width(self) -> Optional[int]: - """:obj:`str`: Optional. Thumbnail width. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_width`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_width", - new_attr_name="thumbnail_width", - bot_api_version="6.6", - ) - return self.thumbnail_width - - @property - def thumb_height(self) -> Optional[int]: - """:obj:`str`: Optional. Thumbnail height. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_height`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_height", - new_attr_name="thumbnail_height", - bot_api_version="6.6", - ) - return self.thumbnail_height + self.last_name: str | None = last_name + self.vcard: str | None = vcard + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.thumbnail_url: str | None = thumbnail_url + self.thumbnail_width: int | None = thumbnail_width + self.thumbnail_height: int | None = thumbnail_height diff --git a/telegram/_inline/inlinequeryresultdocument.py b/src/telegram/_inline/inlinequeryresultdocument.py similarity index 59% rename from telegram/_inline/inlinequeryresultdocument.py rename to src/telegram/_inline/inlinequeryresultdocument.py index ac81a628f64..1bc2d97a887 100644 --- a/telegram/_inline/inlinequeryresultdocument.py +++ b/src/telegram/_inline/inlinequeryresultdocument.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,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/]. """This module contains the classes that represent Telegram InlineQueryResultDocument""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -25,10 +27,6 @@ from telegram._utils.argumentparsing import parse_sequence_arg from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput -from telegram._utils.warnings_transition import ( - warn_about_deprecated_arg_return_new_arg, - warn_about_deprecated_attr_in_property, -) from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -44,6 +42,9 @@ class InlineQueryResultDocument(InlineQueryResult): .. seealso:: :wiki:`Working with Files and Media ` + .. versionchanged:: 20.5 + |removed_thumb_wildcard_note| + Args: id (:obj:`str`): Unique identifier for this result, :tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`- @@ -66,18 +67,6 @@ class InlineQueryResultDocument(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`, optional): Content of the message to be sent instead of the file. - thumb_url (:obj:`str`, optional): URL of the thumbnail (JPEG only) for the file. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_url`. - thumb_width (:obj:`int`, optional): Thumbnail width. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_width`. - thumb_height (:obj:`int`, optional): Thumbnail height. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_height`. thumbnail_url (:obj:`str`, optional): URL of the thumbnail (JPEG only) for the file. .. versionadded:: 20.2 @@ -99,7 +88,7 @@ class InlineQueryResultDocument(InlineQueryResult): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 @@ -126,18 +115,18 @@ class InlineQueryResultDocument(InlineQueryResult): """ __slots__ = ( - "reply_markup", - "caption_entities", - "document_url", - "thumbnail_width", - "thumbnail_height", "caption", - "title", + "caption_entities", "description", - "parse_mode", + "document_url", + "input_message_content", "mime_type", + "parse_mode", + "reply_markup", + "thumbnail_height", "thumbnail_url", - "input_message_content", + "thumbnail_width", + "title", ) def __init__( @@ -146,20 +135,17 @@ def __init__( document_url: str, title: str, mime_type: str, - caption: Optional[str] = None, - description: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, - thumb_url: Optional[str] = None, - thumb_width: Optional[int] = None, - thumb_height: Optional[int] = None, + caption: str | None = None, + description: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, - thumbnail_url: Optional[str] = None, - thumbnail_width: Optional[int] = None, - thumbnail_height: Optional[int] = None, + caption_entities: Sequence[MessageEntity] | None = None, + thumbnail_url: str | None = None, + thumbnail_width: int | None = None, + thumbnail_height: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.DOCUMENT, id, api_kwargs=api_kwargs) @@ -169,72 +155,12 @@ def __init__( self.mime_type: str = mime_type # Optionals - self.caption: Optional[str] = caption + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode - self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.description: Optional[str] = description - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.thumbnail_url: Optional[str] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_url, - new_arg=thumbnail_url, - deprecated_arg_name="thumb_url", - new_arg_name="thumbnail_url", - bot_api_version="6.6", - ) - self.thumbnail_width: Optional[int] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_width, - new_arg=thumbnail_width, - deprecated_arg_name="thumb_width", - new_arg_name="thumbnail_width", - bot_api_version="6.6", - ) - self.thumbnail_height: Optional[int] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_height, - new_arg=thumbnail_height, - deprecated_arg_name="thumb_height", - new_arg_name="thumbnail_height", - bot_api_version="6.6", - ) - - @property - def thumb_url(self) -> Optional[str]: - """:obj:`str`: Optional. URL of the thumbnail (JPEG only) for the file. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_url`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_url", - new_attr_name="thumbnail_url", - bot_api_version="6.6", - ) - return self.thumbnail_url - - @property - def thumb_width(self) -> Optional[int]: - """:obj:`str`: Optional. Thumbnail width. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_width`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_width", - new_attr_name="thumbnail_width", - bot_api_version="6.6", - ) - return self.thumbnail_width - - @property - def thumb_height(self) -> Optional[int]: - """:obj:`str`: Optional. Thumbnail height. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_height`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_height", - new_attr_name="thumbnail_height", - bot_api_version="6.6", - ) - return self.thumbnail_height + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.description: str | None = description + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.thumbnail_url: str | None = thumbnail_url + self.thumbnail_width: int | None = thumbnail_width + self.thumbnail_height: int | None = thumbnail_height diff --git a/telegram/_inline/inlinequeryresultgame.py b/src/telegram/_inline/inlinequeryresultgame.py similarity index 89% rename from telegram/_inline/inlinequeryresultgame.py rename to src/telegram/_inline/inlinequeryresultgame.py index f05a22bbc8d..579721dcc45 100644 --- a/telegram/_inline/inlinequeryresultgame.py +++ b/src/telegram/_inline/inlinequeryresultgame.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # 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 the classes that represent Telegram InlineQueryResultGame.""" -from typing import Optional from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -47,15 +46,15 @@ class InlineQueryResultGame(InlineQueryResult): """ - __slots__ = ("reply_markup", "game_short_name") + __slots__ = ("game_short_name", "reply_markup") def __init__( self, id: str, # pylint: disable=redefined-builtin game_short_name: str, - reply_markup: Optional[InlineKeyboardMarkup] = None, + reply_markup: InlineKeyboardMarkup | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.GAME, id, api_kwargs=api_kwargs) @@ -63,4 +62,4 @@ def __init__( self.id: str = id self.game_short_name: str = game_short_name - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup + self.reply_markup: InlineKeyboardMarkup | None = reply_markup diff --git a/telegram/_inline/inlinequeryresultgif.py b/src/telegram/_inline/inlinequeryresultgif.py similarity index 50% rename from telegram/_inline/inlinequeryresultgif.py rename to src/telegram/_inline/inlinequeryresultgif.py index 62db88b3d1d..67d4cdf23e5 100644 --- a/telegram/_inline/inlinequeryresultgif.py +++ b/src/telegram/_inline/inlinequeryresultgif.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,18 +17,18 @@ # 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 the classes that represent Telegram InlineQueryResultGif.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput -from telegram._utils.warnings_transition import ( - warn_about_deprecated_arg_return_new_arg, - warn_about_deprecated_attr_in_property, -) +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -43,24 +43,29 @@ class InlineQueryResultGif(InlineQueryResult): .. seealso:: :wiki:`Working with Files and Media ` + .. versionchanged:: 20.5 + |removed_thumb_wildcard_note| + Args: id (:obj:`str`): Unique identifier for this result, :tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`- :tg-const:`telegram.InlineQueryResult.MAX_ID_LENGTH` Bytes. - gif_url (:obj:`str`): A valid URL for the GIF file. File size must not exceed 1MB. + gif_url (:obj:`str`): A valid URL for the GIF file. gif_width (:obj:`int`, optional): Width of the GIF. gif_height (:obj:`int`, optional): Height of the GIF. - gif_duration (:obj:`int`, optional): Duration of the GIF in seconds. - thumbnail_url (:obj:`str`, optional): URL of the static (JPEG or GIF) or animated (MPEG4) - thumbnail for the result. + gif_duration (:obj:`int` | :class:`datetime.timedelta`, optional): Duration of the GIF + in seconds. - Warning: - The Bot API does **not** define this as an optional argument. It is formally - optional for backwards compatibility with the deprecated :paramref:`thumb_url`. - If you pass neither :paramref:`thumbnail_url` nor :paramref:`thumb_url`, - :class:`ValueError` will be raised. + .. versionchanged:: v22.2 + |time-period-input| + thumbnail_url (:obj:`str`): URL of the static (JPEG or GIF) or animated (MPEG4) + thumbnail for the result. .. versionadded:: 20.2 + + .. versionchanged:: 20.5 + |thumbnail_url_mandatory| + thumbnail_mime_type (:obj:`str`, optional): MIME type of the thumbnail, must be one of ``'image/jpeg'``, ``'image/gif'``, or ``'video/mp4'``. Defaults to ``'image/jpeg'``. @@ -79,30 +84,23 @@ class InlineQueryResultGif(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`, optional): Content of the message to be sent instead of the GIF animation. - thumb_mime_type (:obj:`str`, optional): MIME type of the thumbnail, must be one of - ``'image/jpeg'``, ``'image/gif'``, or ``'video/mp4'``. Defaults to ``'image/jpeg'``. + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_mime_type`. - thumb_url (:obj:`str`, optional): URL of the static (JPEG or GIF) or animated (MPEG4) - thumbnail for the result. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_url`. - - Raises: - :class:`ValueError`: If neither :paramref:`thumbnail_url` nor :paramref:`thumb_url` is - supplied or if both are supplied and are not equal. + .. versionadded:: 21.3 Attributes: type (:obj:`str`): :tg-const:`telegram.constants.InlineQueryResultType.GIF`. id (:obj:`str`): Unique identifier for this result, :tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`- :tg-const:`telegram.InlineQueryResult.MAX_ID_LENGTH` Bytes. - gif_url (:obj:`str`): A valid URL for the GIF file. File size must not exceed 1MB. + gif_url (:obj:`str`): A valid URL for the GIF file. gif_width (:obj:`int`): Optional. Width of the GIF. gif_height (:obj:`int`): Optional. Height of the GIF. - gif_duration (:obj:`int`): Optional. Duration of the GIF in seconds. + gif_duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Duration of the GIF + in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| thumbnail_url (:obj:`str`): URL of the static (JPEG or GIF) or animated (MPEG4) thumbnail for the result. @@ -116,7 +114,7 @@ class InlineQueryResultGif(InlineQueryResult): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 @@ -126,110 +124,66 @@ class InlineQueryResultGif(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`): Optional. Content of the message to be sent instead of the GIF animation. + show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med| + + .. versionadded:: 21.3 """ __slots__ = ( - "reply_markup", - "gif_height", - "thumbnail_mime_type", + "_gif_duration", + "caption", "caption_entities", + "gif_height", + "gif_url", "gif_width", - "title", - "caption", - "parse_mode", - "gif_duration", "input_message_content", - "gif_url", + "parse_mode", + "reply_markup", + "show_caption_above_media", + "thumbnail_mime_type", "thumbnail_url", + "title", ) def __init__( self, id: str, # pylint: disable=redefined-builtin gif_url: str, - # thumbnail_url is not optional in Telegram API, but we want to support thumb_url as well, - # so thumbnail_url may not be passed. We will raise ValueError manually if neither - # thumbnail_url nor thumb_url are passed - thumbnail_url: Optional[str] = None, - gif_width: Optional[int] = None, - gif_height: Optional[int] = None, - title: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, - gif_duration: Optional[int] = None, + thumbnail_url: str, + gif_width: int | None = None, + gif_height: int | None = None, + title: str | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, + gif_duration: TimePeriod | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - thumb_mime_type: Optional[str] = None, - caption_entities: Optional[Sequence[MessageEntity]] = None, - thumbnail_mime_type: Optional[str] = None, - # thumb_url is not optional in Telegram API, but it is here, along with thumbnail_url. - thumb_url: Optional[str] = None, + caption_entities: Sequence[MessageEntity] | None = None, + thumbnail_mime_type: str | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): - if not (thumbnail_url or thumb_url): - raise ValueError( - "You must pass either 'thumbnail_url' or 'thumb_url'. Note that 'thumb_url' is " - "deprecated." - ) - # Required super().__init__(InlineQueryResultType.GIF, id, api_kwargs=api_kwargs) with self._unfrozen(): self.gif_url: str = gif_url - self.thumbnail_url: str = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_url, - new_arg=thumbnail_url, - deprecated_arg_name="thumb_url", - new_arg_name="thumbnail_url", - bot_api_version="6.6", - ) + self.thumbnail_url: str = thumbnail_url # Optionals - self.gif_width: Optional[int] = gif_width - self.gif_height: Optional[int] = gif_height - self.gif_duration: Optional[int] = gif_duration - self.title: Optional[str] = title - self.caption: Optional[str] = caption + self.gif_width: int | None = gif_width + self.gif_height: int | None = gif_height + self._gif_duration: dtm.timedelta | None = to_timedelta(gif_duration) + self.title: str | None = title + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode - self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.thumbnail_mime_type: Optional[str] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_mime_type, - new_arg=thumbnail_mime_type, - deprecated_arg_name="thumb_mime_type", - new_arg_name="thumbnail_mime_type", - bot_api_version="6.6", - ) - - @property - def thumb_url(self) -> str: - """:obj:`str`: URL of the static (JPEG or GIF) or animated (MPEG4) thumbnail for the - result. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_url`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_url", - new_attr_name="thumbnail_url", - bot_api_version="6.6", - ) - return self.thumbnail_url + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.thumbnail_mime_type: str | None = thumbnail_mime_type + self.show_caption_above_media: bool | None = show_caption_above_media @property - def thumb_mime_type(self) -> Optional[str]: - """:obj:`str`: Optional. Optional. MIME type of the thumbnail, must be one of - ``'image/jpeg'``, ``'image/gif'``, or ``'video/mp4'``. Defaults to ``'image/jpeg'``. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_mime_type`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_mime_type", - new_attr_name="thumbnail_mime_type", - bot_api_version="6.6", - ) - return self.thumbnail_mime_type + def gif_duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._gif_duration, attribute="gif_duration") diff --git a/telegram/_inline/inlinequeryresultlocation.py b/src/telegram/_inline/inlinequeryresultlocation.py similarity index 59% rename from telegram/_inline/inlinequeryresultlocation.py rename to src/telegram/_inline/inlinequeryresultlocation.py index 84fbe93c932..c4ce7db7346 100644 --- a/telegram/_inline/inlinequeryresultlocation.py +++ b/src/telegram/_inline/inlinequeryresultlocation.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,16 +18,15 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultLocation.""" -from typing import TYPE_CHECKING, ClassVar, Optional +import datetime as dtm +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult -from telegram._utils.types import JSONDict -from telegram._utils.warnings_transition import ( - warn_about_deprecated_arg_return_new_arg, - warn_about_deprecated_attr_in_property, -) +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod if TYPE_CHECKING: from telegram import InputMessageContent @@ -39,6 +38,9 @@ class InlineQueryResultLocation(InlineQueryResult): Alternatively, you can use :attr:`input_message_content` to send a message with the specified content instead of the location. + .. versionchanged:: 20.5 + |removed_thumb_wildcard_note| + Args: id (:obj:`str`): Unique identifier for this result, :tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`- @@ -49,10 +51,13 @@ class InlineQueryResultLocation(InlineQueryResult): horizontal_accuracy (:obj:`float`, optional): The radius of uncertainty for the location, measured in meters; 0- :tg-const:`telegram.InlineQueryResultLocation.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`, optional): Period in seconds for which the location will be - updated, should be between + live_period (:obj:`int` | :class:`datetime.timedelta`, optional): Period in seconds for + which the location will be updated, should be between :tg-const:`telegram.InlineQueryResultLocation.MIN_LIVE_PERIOD` and :tg-const:`telegram.InlineQueryResultLocation.MAX_LIVE_PERIOD`. + + .. versionchanged:: v22.2 + |time-period-input| heading (:obj:`int`, optional): For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.InlineQueryResultLocation.MIN_HEADING` and @@ -66,18 +71,6 @@ class InlineQueryResultLocation(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`, optional): Content of the message to be sent instead of the location. - thumb_url (:obj:`str`, optional): Url of the thumbnail for the result. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_url`. - thumb_width (:obj:`int`, optional): Thumbnail width. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_width`. - thumb_height (:obj:`int`, optional): Thumbnail height. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_height`. thumbnail_url (:obj:`str`, optional): Url of the thumbnail for the result. .. versionadded:: 20.2 @@ -99,10 +92,15 @@ class InlineQueryResultLocation(InlineQueryResult): horizontal_accuracy (:obj:`float`): Optional. The radius of uncertainty for the location, measured in meters; 0- :tg-const:`telegram.InlineQueryResultLocation.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`): Optional. Period in seconds for which the location will be - updated, should be between + live_period (:obj:`int` | :class:`datetime.timedelta`): Optional. Period in seconds for + which the location will be updated, should be between :tg-const:`telegram.InlineQueryResultLocation.MIN_LIVE_PERIOD` and - :tg-const:`telegram.InlineQueryResultLocation.MAX_LIVE_PERIOD`. + :tg-const:`telegram.InlineQueryResultLocation.MAX_LIVE_PERIOD` or + :tg-const:`telegram.constants.LocationLimit.LIVE_PERIOD_FOREVER` for live + locations that can be edited indefinitely. + + .. deprecated:: v22.2 + |time-period-int-deprecated| heading (:obj:`int`): Optional. For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.InlineQueryResultLocation.MIN_HEADING` and @@ -129,18 +127,18 @@ class InlineQueryResultLocation(InlineQueryResult): """ __slots__ = ( - "longitude", - "reply_markup", - "thumbnail_width", - "thumbnail_height", + "_live_period", "heading", - "title", - "live_period", - "proximity_alert_radius", + "horizontal_accuracy", "input_message_content", "latitude", - "horizontal_accuracy", + "longitude", + "proximity_alert_radius", + "reply_markup", + "thumbnail_height", "thumbnail_url", + "thumbnail_width", + "title", ) def __init__( @@ -149,20 +147,17 @@ def __init__( latitude: float, longitude: float, title: str, - live_period: Optional[int] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, - thumb_url: Optional[str] = None, - thumb_width: Optional[int] = None, - thumb_height: Optional[int] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, - thumbnail_url: Optional[str] = None, - thumbnail_width: Optional[int] = None, - thumbnail_height: Optional[int] = None, + live_period: TimePeriod | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, + thumbnail_url: str | None = None, + thumbnail_width: int | None = None, + thumbnail_height: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(constants.InlineQueryResultType.LOCATION, id, api_kwargs=api_kwargs) @@ -172,109 +167,53 @@ def __init__( self.title: str = title # Optionals - self.live_period: Optional[int] = live_period - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.thumbnail_url: Optional[str] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_url, - new_arg=thumbnail_url, - deprecated_arg_name="thumb_url", - new_arg_name="thumbnail_url", - bot_api_version="6.6", - ) - self.thumbnail_width: Optional[int] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_width, - new_arg=thumbnail_width, - deprecated_arg_name="thumb_width", - new_arg_name="thumbnail_width", - bot_api_version="6.6", - ) - self.thumbnail_height: Optional[int] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_height, - new_arg=thumbnail_height, - deprecated_arg_name="thumb_height", - new_arg_name="thumbnail_height", - bot_api_version="6.6", - ) - self.horizontal_accuracy: Optional[float] = horizontal_accuracy - self.heading: Optional[int] = heading - self.proximity_alert_radius: Optional[int] = ( + self._live_period: dtm.timedelta | None = to_timedelta(live_period) + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.thumbnail_url: str | None = thumbnail_url + self.thumbnail_width: int | None = thumbnail_width + self.thumbnail_height: int | None = thumbnail_height + self.horizontal_accuracy: float | None = horizontal_accuracy + self.heading: int | None = heading + self.proximity_alert_radius: int | None = ( int(proximity_alert_radius) if proximity_alert_radius else None ) @property - def thumb_url(self) -> Optional[str]: - """:obj:`str`: Optional. Url of the thumbnail for the result. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_url`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_url", - new_attr_name="thumbnail_url", - bot_api_version="6.6", - ) - return self.thumbnail_url - - @property - def thumb_width(self) -> Optional[int]: - """:obj:`str`: Optional. Thumbnail width. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_width`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_width", - new_attr_name="thumbnail_width", - bot_api_version="6.6", - ) - return self.thumbnail_width - - @property - def thumb_height(self) -> Optional[int]: - """:obj:`str`: Optional. Thumbnail height. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_height`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_height", - new_attr_name="thumbnail_height", - bot_api_version="6.6", - ) - return self.thumbnail_height + def live_period(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._live_period, attribute="live_period") - HORIZONTAL_ACCURACY: ClassVar[int] = constants.LocationLimit.HORIZONTAL_ACCURACY + HORIZONTAL_ACCURACY: Final[int] = constants.LocationLimit.HORIZONTAL_ACCURACY """:const:`telegram.constants.LocationLimit.HORIZONTAL_ACCURACY` .. versionadded:: 20.0 """ - MIN_HEADING: ClassVar[int] = constants.LocationLimit.MIN_HEADING + MIN_HEADING: Final[int] = constants.LocationLimit.MIN_HEADING """:const:`telegram.constants.LocationLimit.MIN_HEADING` .. versionadded:: 20.0 """ - MAX_HEADING: ClassVar[int] = constants.LocationLimit.MAX_HEADING + MAX_HEADING: Final[int] = constants.LocationLimit.MAX_HEADING """:const:`telegram.constants.LocationLimit.MAX_HEADING` .. versionadded:: 20.0 """ - MIN_LIVE_PERIOD: ClassVar[int] = constants.LocationLimit.MIN_LIVE_PERIOD + MIN_LIVE_PERIOD: Final[int] = constants.LocationLimit.MIN_LIVE_PERIOD """:const:`telegram.constants.LocationLimit.MIN_LIVE_PERIOD` .. versionadded:: 20.0 """ - MAX_LIVE_PERIOD: ClassVar[int] = constants.LocationLimit.MAX_LIVE_PERIOD + MAX_LIVE_PERIOD: Final[int] = constants.LocationLimit.MAX_LIVE_PERIOD """:const:`telegram.constants.LocationLimit.MAX_LIVE_PERIOD` .. versionadded:: 20.0 """ - MIN_PROXIMITY_ALERT_RADIUS: ClassVar[int] = constants.LocationLimit.MIN_PROXIMITY_ALERT_RADIUS + MIN_PROXIMITY_ALERT_RADIUS: Final[int] = constants.LocationLimit.MIN_PROXIMITY_ALERT_RADIUS """:const:`telegram.constants.LocationLimit.MIN_PROXIMITY_ALERT_RADIUS` .. versionadded:: 20.0 """ - MAX_PROXIMITY_ALERT_RADIUS: ClassVar[int] = constants.LocationLimit.MAX_PROXIMITY_ALERT_RADIUS + MAX_PROXIMITY_ALERT_RADIUS: Final[int] = constants.LocationLimit.MAX_PROXIMITY_ALERT_RADIUS """:const:`telegram.constants.LocationLimit.MAX_PROXIMITY_ALERT_RADIUS` .. versionadded:: 20.0 diff --git a/telegram/_inline/inlinequeryresultmpeg4gif.py b/src/telegram/_inline/inlinequeryresultmpeg4gif.py similarity index 53% rename from telegram/_inline/inlinequeryresultmpeg4gif.py rename to src/telegram/_inline/inlinequeryresultmpeg4gif.py index 2872743327d..817eda536d7 100644 --- a/telegram/_inline/inlinequeryresultmpeg4gif.py +++ b/src/telegram/_inline/inlinequeryresultmpeg4gif.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,18 +17,18 @@ # 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 the classes that represent Telegram InlineQueryResultMpeg4Gif.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput -from telegram._utils.warnings_transition import ( - warn_about_deprecated_arg_return_new_arg, - warn_about_deprecated_attr_in_property, -) +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -44,24 +44,29 @@ class InlineQueryResultMpeg4Gif(InlineQueryResult): .. seealso:: :wiki:`Working with Files and Media ` + .. versionchanged:: 20.5 + |removed_thumb_wildcard_note| + Args: id (:obj:`str`): Unique identifier for this result, :tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`- :tg-const:`telegram.InlineQueryResult.MAX_ID_LENGTH` Bytes. - mpeg4_url (:obj:`str`): A valid URL for the MP4 file. File size must not exceed 1MB. + mpeg4_url (:obj:`str`): A valid URL for the MP4 file. mpeg4_width (:obj:`int`, optional): Video width. mpeg4_height (:obj:`int`, optional): Video height. - mpeg4_duration (:obj:`int`, optional): Video duration in seconds. - thumbnail_url (:obj:`str`, optional): URL of the static (JPEG or GIF) or animated (MPEG4) - thumbnail for the result. + mpeg4_duration (:obj:`int` | :class:`datetime.timedelta`, optional): Video duration + in seconds. - Warning: - The Bot API does **not** define this as an optional argument. It is formally - optional for backwards compatibility with the deprecated :paramref:`thumb_url`. - If you pass neither :paramref:`thumbnail_url` nor :paramref:`thumb_url`, - :class:`ValueError` will be raised. + .. versionchanged:: v22.2 + |time-period-input| + thumbnail_url (:obj:`str`): URL of the static (JPEG or GIF) or animated (MPEG4) + thumbnail for the result. .. versionadded:: 20.2 + + .. versionchanged:: 20.5 + |thumbnail_url_mandatory| + thumbnail_mime_type (:obj:`str`, optional): MIME type of the thumbnail, must be one of ``'image/jpeg'``, ``'image/gif'``, or ``'video/mp4'``. Defaults to ``'image/jpeg'``. @@ -81,20 +86,23 @@ class InlineQueryResultMpeg4Gif(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`, optional): Content of the message to be sent instead of the video animation. + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| - Raises: - :class:`ValueError`: If neither :paramref:`thumbnail_url` nor :paramref:`thumb_url` is - supplied or if both are supplied and are not equal. + .. versionadded:: 21.3 Attributes: type (:obj:`str`): :tg-const:`telegram.constants.InlineQueryResultType.MPEG4GIF`. id (:obj:`str`): Unique identifier for this result, :tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`- :tg-const:`telegram.InlineQueryResult.MAX_ID_LENGTH` Bytes. - mpeg4_url (:obj:`str`): A valid URL for the MP4 file. File size must not exceed 1MB. + mpeg4_url (:obj:`str`): A valid URL for the MP4 file. mpeg4_width (:obj:`int`): Optional. Video width. mpeg4_height (:obj:`int`): Optional. Video height. - mpeg4_duration (:obj:`int`): Optional. Video duration in seconds. + mpeg4_duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Video duration + in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| thumbnail_url (:obj:`str`): URL of the static (JPEG or GIF) or animated (MPEG4) thumbnail for the result. @@ -108,7 +116,7 @@ class InlineQueryResultMpeg4Gif(InlineQueryResult): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |caption_entities| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |caption_entities| .. versionchanged:: 20.0 @@ -119,110 +127,65 @@ class InlineQueryResultMpeg4Gif(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`): Optional. Content of the message to be sent instead of the video animation. + show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med| + .. versionadded:: 21.3 """ __slots__ = ( - "reply_markup", - "thumbnail_mime_type", - "caption_entities", - "mpeg4_duration", - "mpeg4_width", - "title", + "_mpeg4_duration", "caption", - "parse_mode", + "caption_entities", "input_message_content", - "mpeg4_url", "mpeg4_height", + "mpeg4_url", + "mpeg4_width", + "parse_mode", + "reply_markup", + "show_caption_above_media", + "thumbnail_mime_type", "thumbnail_url", + "title", ) def __init__( self, id: str, # pylint: disable=redefined-builtin mpeg4_url: str, - # thumbnail_url is not optional in Telegram API, but we want to support thumb_url as well, - # so thumbnail_url may not be passed. We will raise ValueError manually if neither - # thumbnail_url nor thumb_url are passed - thumbnail_url: Optional[str] = None, - mpeg4_width: Optional[int] = None, - mpeg4_height: Optional[int] = None, - title: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, - mpeg4_duration: Optional[int] = None, + thumbnail_url: str, + mpeg4_width: int | None = None, + mpeg4_height: int | None = None, + title: str | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, + mpeg4_duration: TimePeriod | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - thumb_mime_type: Optional[str] = None, - caption_entities: Optional[Sequence[MessageEntity]] = None, - thumbnail_mime_type: Optional[str] = None, - # thumb_url is not optional in Telegram API, but it is here, along with thumbnail_url. - thumb_url: Optional[str] = None, + caption_entities: Sequence[MessageEntity] | None = None, + thumbnail_mime_type: str | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): - if not (thumbnail_url or thumb_url): - raise ValueError( - "You must pass either 'thumbnail_url' or 'thumb_url'. Note that 'thumb_url' is " - "deprecated." - ) - # Required super().__init__(InlineQueryResultType.MPEG4GIF, id, api_kwargs=api_kwargs) with self._unfrozen(): self.mpeg4_url: str = mpeg4_url - self.thumbnail_url: str = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_url, - new_arg=thumbnail_url, - deprecated_arg_name="thumb_url", - new_arg_name="thumbnail_url", - bot_api_version="6.6", - ) + self.thumbnail_url: str = thumbnail_url # Optional - self.mpeg4_width: Optional[int] = mpeg4_width - self.mpeg4_height: Optional[int] = mpeg4_height - self.mpeg4_duration: Optional[int] = mpeg4_duration - self.title: Optional[str] = title - self.caption: Optional[str] = caption + self.mpeg4_width: int | None = mpeg4_width + self.mpeg4_height: int | None = mpeg4_height + self._mpeg4_duration: dtm.timedelta | None = to_timedelta(mpeg4_duration) + self.title: str | None = title + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode - self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.thumbnail_mime_type: Optional[str] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_mime_type, - new_arg=thumbnail_mime_type, - deprecated_arg_name="thumb_mime_type", - new_arg_name="thumbnail_mime_type", - bot_api_version="6.6", - ) - - @property - def thumb_url(self) -> str: - """:obj:`str`: URL of the static (JPEG or GIF) or animated (MPEG4) thumbnail for the - result. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_url`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_url", - new_attr_name="thumbnail_url", - bot_api_version="6.6", - ) - return self.thumbnail_url + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.thumbnail_mime_type: str | None = thumbnail_mime_type + self.show_caption_above_media: bool | None = show_caption_above_media @property - def thumb_mime_type(self) -> Optional[str]: - """:obj:`str`: Optional. Optional. MIME type of the thumbnail, must be one of - ``'image/jpeg'``, ``'image/gif'``, or ``'video/mp4'``. Defaults to ``'image/jpeg'``. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_mime_type`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_mime_type", - new_attr_name="thumbnail_mime_type", - bot_api_version="6.6", - ) - return self.thumbnail_mime_type + def mpeg4_duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._mpeg4_duration, attribute="mpeg4_duration") diff --git a/telegram/_inline/inlinequeryresultphoto.py b/src/telegram/_inline/inlinequeryresultphoto.py similarity index 60% rename from telegram/_inline/inlinequeryresultphoto.py rename to src/telegram/_inline/inlinequeryresultphoto.py index b27096162a8..74ca99baa0d 100644 --- a/telegram/_inline/inlinequeryresultphoto.py +++ b/src/telegram/_inline/inlinequeryresultphoto.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,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/]. """This module contains the classes that represent Telegram InlineQueryResultPhoto.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult @@ -25,10 +27,6 @@ from telegram._utils.argumentparsing import parse_sequence_arg from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput -from telegram._utils.warnings_transition import ( - warn_about_deprecated_arg_return_new_arg, - warn_about_deprecated_attr_in_property, -) from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -43,21 +41,22 @@ class InlineQueryResultPhoto(InlineQueryResult): .. seealso:: :wiki:`Working with Files and Media ` + .. versionchanged:: 20.5 + |removed_thumb_url_note| + Args: id (:obj:`str`): Unique identifier for this result, :tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`- :tg-const:`telegram.InlineQueryResult.MAX_ID_LENGTH` Bytes. photo_url (:obj:`str`): A valid URL of the photo. Photo must be in JPEG format. Photo size must not exceed 5MB. - thumbnail_url (:obj:`str`, optional): URL of the thumbnail for the photo. - - Warning: - The Bot API does **not** define this as an optional argument. It is formally - optional for backwards compatibility with the deprecated :paramref:`thumb_url`. - If you pass neither :paramref:`thumbnail_url` nor :paramref:`thumb_url`, - :class:`ValueError` will be raised. + thumbnail_url (:obj:`str`): URL of the thumbnail for the photo. .. versionadded:: 20.2 + + .. versionchanged:: 20.5 + |thumbnail_url_mandatory| + photo_width (:obj:`int`, optional): Width of the photo. photo_height (:obj:`int`, optional): Height of the photo. title (:obj:`str`, optional): Title for the result. @@ -75,14 +74,9 @@ class InlineQueryResultPhoto(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`, optional): Content of the message to be sent instead of the photo. - thumb_url (:obj:`str`, optional): URL of the thumbnail for the photo. + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_url`. - - Raises: - :class:`ValueError`: If neither :paramref:`thumbnail_url` nor :paramref:`thumb_url` is - supplied or if both are supplied and are not equal. + .. versionadded:: 21.3 Attributes: type (:obj:`str`): :tg-const:`telegram.constants.InlineQueryResultType.PHOTO`. @@ -100,7 +94,7 @@ class InlineQueryResultPhoto(InlineQueryResult): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 @@ -110,84 +104,59 @@ class InlineQueryResultPhoto(InlineQueryResult): to the message. input_message_content (:class:`telegram.InputMessageContent`): Optional. Content of the message to be sent instead of the photo. + show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med| + + .. versionadded:: 21.3 """ __slots__ = ( - "photo_url", - "reply_markup", - "caption_entities", - "photo_width", "caption", - "title", + "caption_entities", "description", - "parse_mode", "input_message_content", + "parse_mode", "photo_height", + "photo_url", + "photo_width", + "reply_markup", + "show_caption_above_media", "thumbnail_url", + "title", ) def __init__( self, id: str, # pylint: disable=redefined-builtin photo_url: str, - # thumbnail_url is not optional in Telegram API, but we want to support thumb_url as well, - # so thumbnail_url may not be passed. We will raise ValueError manually if neither - # thumbnail_url nor thumb_url are passed - thumbnail_url: Optional[str] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - title: Optional[str] = None, - description: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + thumbnail_url: str, + photo_width: int | None = None, + photo_height: int | None = None, + title: str | None = None, + description: str | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, - # thumb_url is not optional in Telegram API, but it is here, along with thumbnail_url. - thumb_url: Optional[str] = None, + caption_entities: Sequence[MessageEntity] | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): - if not (thumbnail_url or thumb_url): - raise ValueError( - "You must pass either 'thumbnail_url' or 'thumb_url'. Note that 'thumb_url' is " - "deprecated." - ) - # Required super().__init__(InlineQueryResultType.PHOTO, id, api_kwargs=api_kwargs) with self._unfrozen(): self.photo_url: str = photo_url - self.thumbnail_url: str = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_url, - new_arg=thumbnail_url, - deprecated_arg_name="thumb_url", - new_arg_name="thumbnail_url", - bot_api_version="6.6", - ) + self.thumbnail_url: str = thumbnail_url # Optionals - self.photo_width: Optional[int] = photo_width - self.photo_height: Optional[int] = photo_height - self.title: Optional[str] = title - self.description: Optional[str] = description - self.caption: Optional[str] = caption + self.photo_width: int | None = photo_width + self.photo_height: int | None = photo_height + self.title: str | None = title + self.description: str | None = description + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode - self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - - @property - def thumb_url(self) -> Optional[str]: - """:obj:`str`: URL of the thumbnail for the photo. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_url`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_url", - new_attr_name="thumbnail_url", - bot_api_version="6.6", - ) - return self.thumbnail_url + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.show_caption_above_media: bool | None = show_caption_above_media diff --git a/telegram/_inline/inlinequeryresultsbutton.py b/src/telegram/_inline/inlinequeryresultsbutton.py similarity index 72% rename from telegram/_inline/inlinequeryresultsbutton.py rename to src/telegram/_inline/inlinequeryresultsbutton.py index a200fb8389e..7f124fe7fc8 100644 --- a/telegram/_inline/inlinequeryresultsbutton.py +++ b/src/telegram/_inline/inlinequeryresultsbutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,13 +16,13 @@ # # 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 class that represent a Telegram InlineQueryResultsButton.""" -from typing import TYPE_CHECKING, ClassVar, Optional +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict from telegram._webappinfo import WebAppInfo @@ -43,13 +43,14 @@ class InlineQueryResultsButton(TelegramObject): `Web App `_ that will be launched when the user presses the button. The Web App will be able to switch back to the inline mode using the method - `switchInlineQuery `_ + `switchInlineQuery `_ inside the Web App. start_parameter (:obj:`str`, optional): Deep-linking parameter for the :guilabel:`/start` message sent to the bot when user presses the switch button. - :tg-const:`telegram.InlineQuery.MIN_SWITCH_PM_TEXT_LENGTH`- - :tg-const:`telegram.InlineQuery.MAX_SWITCH_PM_TEXT_LENGTH` characters, - only ``A-Z``, ``a-z``, ``0-9``, ``_`` and ``-`` are allowed. + :tg-const:`telegram.constants.InlineQueryResultsButtonLimit.MIN_START_PARAMETER_LENGTH` + - + :tg-const:`telegram.constants.InlineQueryResultsButtonLimit.MAX_START_PARAMETER_LENGTH` + characters, only ``A-Z``, ``a-z``, ``0-9``, ``_`` and ``-`` are allowed. Example: An inline bot that sends YouTube videos can ask the user to connect the bot to @@ -67,22 +68,22 @@ class InlineQueryResultsButton(TelegramObject): user presses the button. The Web App will be able to switch back to the inline mode using the method ``web_app_switch_inline_query`` inside the Web App. start_parameter (:obj:`str`): Optional. Deep-linking parameter for the - :guilabel:`/start` message sent to the bot when user presses the switch button. - :tg-const:`telegram.InlineQuery.MIN_SWITCH_PM_TEXT_LENGTH`- - :tg-const:`telegram.InlineQuery.MAX_SWITCH_PM_TEXT_LENGTH` characters, - only ``A-Z``, ``a-z``, ``0-9``, ``_`` and ``-`` are allowed. + :tg-const:`telegram.constants.InlineQueryResultsButtonLimit.MIN_START_PARAMETER_LENGTH` + - + :tg-const:`telegram.constants.InlineQueryResultsButtonLimit.MAX_START_PARAMETER_LENGTH` + characters, only ``A-Z``, ``a-z``, ``0-9``, ``_`` and ``-`` are allowed. """ - __slots__ = ("text", "web_app", "start_parameter") + __slots__ = ("start_parameter", "text", "web_app") def __init__( self, text: str, - web_app: Optional[WebAppInfo] = None, - start_parameter: Optional[str] = None, + web_app: WebAppInfo | None = None, + start_parameter: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -90,28 +91,26 @@ def __init__( self.text: str = text # Optional - self.web_app: Optional[WebAppInfo] = web_app - self.start_parameter: Optional[str] = start_parameter + self.web_app: WebAppInfo | None = web_app + self.start_parameter: str | None = start_parameter self._id_attrs = (self.text, self.web_app, self.start_parameter) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["InlineQueryResultsButton"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "InlineQueryResultsButton": """See :meth:`telegram.TelegramObject.de_json`.""" - if not data: - return None - data["web_app"] = WebAppInfo.de_json(data.get("web_app"), bot) + data["web_app"] = de_json_optional(data.get("web_app"), WebAppInfo, bot) return super().de_json(data=data, bot=bot) - MIN_START_PARAMETER_LENGTH: ClassVar[ - int - ] = constants.InlineQueryResultsButtonLimit.MIN_START_PARAMETER_LENGTH + MIN_START_PARAMETER_LENGTH: Final[int] = ( + constants.InlineQueryResultsButtonLimit.MIN_START_PARAMETER_LENGTH + ) """:const:`telegram.constants.InlineQueryResultsButtonLimit.MIN_START_PARAMETER_LENGTH`""" - MAX_START_PARAMETER_LENGTH: ClassVar[ - int - ] = constants.InlineQueryResultsButtonLimit.MAX_START_PARAMETER_LENGTH + MAX_START_PARAMETER_LENGTH: Final[int] = ( + constants.InlineQueryResultsButtonLimit.MAX_START_PARAMETER_LENGTH + ) """:const:`telegram.constants.InlineQueryResultsButtonLimit.MAX_START_PARAMETER_LENGTH`""" diff --git a/telegram/_inline/inlinequeryresultvenue.py b/src/telegram/_inline/inlinequeryresultvenue.py similarity index 58% rename from telegram/_inline/inlinequeryresultvenue.py rename to src/telegram/_inline/inlinequeryresultvenue.py index df2179f6ef4..162ff4ed8c4 100644 --- a/telegram/_inline/inlinequeryresultvenue.py +++ b/src/telegram/_inline/inlinequeryresultvenue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,15 +18,11 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InlineQueryResultVenue.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._utils.types import JSONDict -from telegram._utils.warnings_transition import ( - warn_about_deprecated_arg_return_new_arg, - warn_about_deprecated_attr_in_property, -) from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -43,6 +39,9 @@ class InlineQueryResultVenue(InlineQueryResult): Foursquare details and Google Pace details are mutually exclusive. However, this behaviour is undocumented and might be changed by Telegram. + .. versionchanged:: 20.5 + |removed_thumb_wildcard_note| + Args: id (:obj:`str`): Unique identifier for this result, :tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`- @@ -58,23 +57,11 @@ class InlineQueryResultVenue(InlineQueryResult): google_place_id (:obj:`str`, optional): Google Places identifier of the venue. google_place_type (:obj:`str`, optional): Google Places type of the venue. (See `supported types `_.) + /place-types>`_.) reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached to the message. input_message_content (:class:`telegram.InputMessageContent`, optional): Content of the message to be sent instead of the venue. - thumb_url (:obj:`str`, optional): Url of the thumbnail for the result. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_url`. - thumb_width (:obj:`int`, optional): Thumbnail width. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_width`. - thumb_height (:obj:`int`, optional): Thumbnail height. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_height`. thumbnail_url (:obj:`str`, optional): Url of the thumbnail for the result. .. versionadded:: 20.2 @@ -101,7 +88,7 @@ class InlineQueryResultVenue(InlineQueryResult): google_place_id (:obj:`str`): Optional. Google Places identifier of the venue. google_place_type (:obj:`str`): Optional. Google Places type of the venue. (See `supported types `_.) + /place-types>`_.) reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached to the message. input_message_content (:class:`telegram.InputMessageContent`): Optional. Content of the @@ -119,19 +106,19 @@ class InlineQueryResultVenue(InlineQueryResult): """ __slots__ = ( - "longitude", - "reply_markup", - "google_place_type", - "thumbnail_width", - "thumbnail_height", - "title", "address", "foursquare_id", "foursquare_type", "google_place_id", + "google_place_type", "input_message_content", "latitude", + "longitude", + "reply_markup", + "thumbnail_height", "thumbnail_url", + "thumbnail_width", + "title", ) def __init__( @@ -141,20 +128,17 @@ def __init__( longitude: float, title: str, address: str, - foursquare_id: Optional[str] = None, - foursquare_type: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, - thumb_url: Optional[str] = None, - thumb_width: Optional[int] = None, - thumb_height: Optional[int] = None, - google_place_id: Optional[str] = None, - google_place_type: Optional[str] = None, - thumbnail_url: Optional[str] = None, - thumbnail_width: Optional[int] = None, - thumbnail_height: Optional[int] = None, + foursquare_id: str | None = None, + foursquare_type: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, + google_place_id: str | None = None, + google_place_type: str | None = None, + thumbnail_url: str | None = None, + thumbnail_width: int | None = None, + thumbnail_height: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.VENUE, id, api_kwargs=api_kwargs) @@ -165,72 +149,12 @@ def __init__( self.address: str = address # Optional - self.foursquare_id: Optional[str] = foursquare_id - self.foursquare_type: Optional[str] = foursquare_type - self.google_place_id: Optional[str] = google_place_id - self.google_place_type: Optional[str] = google_place_type - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content - self.thumbnail_url: Optional[str] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_url, - new_arg=thumbnail_url, - deprecated_arg_name="thumb_url", - new_arg_name="thumbnail_url", - bot_api_version="6.6", - ) - self.thumbnail_width: Optional[int] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_width, - new_arg=thumbnail_width, - deprecated_arg_name="thumb_width", - new_arg_name="thumbnail_width", - bot_api_version="6.6", - ) - self.thumbnail_height: Optional[int] = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_height, - new_arg=thumbnail_height, - deprecated_arg_name="thumb_height", - new_arg_name="thumbnail_height", - bot_api_version="6.6", - ) - - @property - def thumb_url(self) -> Optional[str]: - """:obj:`str`: Optional. Url of the thumbnail for the result. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_url`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_url", - new_attr_name="thumbnail_url", - bot_api_version="6.6", - ) - return self.thumbnail_url - - @property - def thumb_width(self) -> Optional[int]: - """:obj:`str`: Optional. Thumbnail width. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_width`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_width", - new_attr_name="thumbnail_width", - bot_api_version="6.6", - ) - return self.thumbnail_width - - @property - def thumb_height(self) -> Optional[int]: - """:obj:`str`: Optional. Thumbnail height. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_height`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_height", - new_attr_name="thumbnail_height", - bot_api_version="6.6", - ) - return self.thumbnail_height + self.foursquare_id: str | None = foursquare_id + self.foursquare_type: str | None = foursquare_type + self.google_place_id: str | None = google_place_id + self.google_place_type: str | None = google_place_type + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.thumbnail_url: str | None = thumbnail_url + self.thumbnail_width: int | None = thumbnail_width + self.thumbnail_height: int | None = thumbnail_height diff --git a/telegram/_inline/inlinequeryresultvideo.py b/src/telegram/_inline/inlinequeryresultvideo.py similarity index 56% rename from telegram/_inline/inlinequeryresultvideo.py rename to src/telegram/_inline/inlinequeryresultvideo.py index f0e5339d22f..a9ab9bc5241 100644 --- a/telegram/_inline/inlinequeryresultvideo.py +++ b/src/telegram/_inline/inlinequeryresultvideo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,18 +17,18 @@ # 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 the classes that represent Telegram InlineQueryResultVideo.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput -from telegram._utils.warnings_transition import ( - warn_about_deprecated_arg_return_new_arg, - warn_about_deprecated_attr_in_property, -) +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -48,6 +48,9 @@ class InlineQueryResultVideo(InlineQueryResult): .. seealso:: :wiki:`Working with Files and Media ` + .. versionchanged:: 20.5 + |removed_thumb_url_note| + Args: id (:obj:`str`): Unique identifier for this result, :tg-const:`telegram.InlineQueryResult.MIN_ID_LENGTH`- @@ -56,20 +59,12 @@ class InlineQueryResultVideo(InlineQueryResult): mime_type (:obj:`str`): Mime type of the content of video url, "text/html" or "video/mp4". thumbnail_url (:obj:`str`, optional): URL of the thumbnail (JPEG only) for the video. - Warning: - The Bot API does **not** define this as an optional argument. It is formally - optional for backwards compatibility with the deprecated :paramref:`thumb_url`. - If you pass neither :paramref:`thumbnail_url` nor :paramref:`thumb_url`, - :class:`ValueError` will be raised. - .. versionadded:: 20.2 - title (:obj:`str`, optional): Title for the result. - Warning: - The Bot API does **not** define this as an optional argument. It is formally - optional to ensure backwards compatibility of :paramref:`thumbnail_url` with the - deprecated :paramref:`thumb_url`, which required that :paramref:`thumbnail_url` - become optional. :class:`TypeError` will be raised if no ``title`` is passed. + .. versionchanged:: 20.5 + |thumbnail_url_mandatory| + + title (:obj:`str`): Title for the result. caption (:obj:`str`, optional): Caption of the video to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -81,7 +76,11 @@ class InlineQueryResultVideo(InlineQueryResult): video_width (:obj:`int`, optional): Video width. video_height (:obj:`int`, optional): Video height. - video_duration (:obj:`int`, optional): Video duration in seconds. + video_duration (:obj:`int` | :class:`datetime.timedelta`, optional): Video duration + in seconds. + + .. versionchanged:: v22.2 + |time-period-input| description (:obj:`str`, optional): Short description of the result. reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached to the message. @@ -89,15 +88,9 @@ class InlineQueryResultVideo(InlineQueryResult): message to be sent instead of the video. This field is required if ``InlineQueryResultVideo`` is used to send an HTML-page as a result (e.g., a YouTube video). - thumb_url (:obj:`str`, optional): URL of the thumbnail (JPEG only) for the video. + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail_url`. - - Raises: - :class:`ValueError`: If neither :paramref:`thumbnail_url` nor :paramref:`thumb_url` is - supplied or if both are supplied and are not equal. - :class:`TypeError`: If no :paramref:`title` is passed. + .. versionadded:: 21.3 Attributes: type (:obj:`str`): :tg-const:`telegram.constants.InlineQueryResultType.VIDEO`. @@ -114,7 +107,7 @@ class InlineQueryResultVideo(InlineQueryResult): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 @@ -124,7 +117,11 @@ class InlineQueryResultVideo(InlineQueryResult): video_width (:obj:`int`): Optional. Video width. video_height (:obj:`int`): Optional. Video height. - video_duration (:obj:`int`): Optional. Video duration in seconds. + video_duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Video duration + in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| description (:obj:`str`): Optional. Short description of the result. reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached to the message. @@ -132,23 +129,27 @@ class InlineQueryResultVideo(InlineQueryResult): message to be sent instead of the video. This field is required if ``InlineQueryResultVideo`` is used to send an HTML-page as a result (e.g., a YouTube video). + show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med| + + .. versionadded:: 21.3 """ __slots__ = ( - "video_url", - "reply_markup", - "caption_entities", + "_video_duration", "caption", - "title", + "caption_entities", "description", - "video_duration", - "parse_mode", - "mime_type", "input_message_content", + "mime_type", + "parse_mode", + "reply_markup", + "show_caption_above_media", + "thumbnail_url", + "title", "video_height", + "video_url", "video_width", - "thumbnail_url", ) def __init__( @@ -156,74 +157,41 @@ def __init__( id: str, # pylint: disable=redefined-builtin video_url: str, mime_type: str, - # thumbnail_url and title are not optional in Telegram API, but we want to support - # thumb_url as well, so thumbnail_url may not be passed if thumb_url is passed. - # We will raise ValueError manually if neither thumbnail_url nor thumb_url are passed. - thumbnail_url: Optional[str] = None, - # title had to be made optional because of thumbnail_url. This is compensated by raising - # TypeError manually if title is not passed. - title: Optional[str] = None, - caption: Optional[str] = None, - video_width: Optional[int] = None, - video_height: Optional[int] = None, - video_duration: Optional[int] = None, - description: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + thumbnail_url: str, + title: str, + caption: str | None = None, + video_width: int | None = None, + video_height: int | None = None, + video_duration: TimePeriod | None = None, + description: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, - # thumb_url is not optional in Telegram API, but it is here, along with thumbnail_url. - thumb_url: Optional[str] = None, + caption_entities: Sequence[MessageEntity] | None = None, + show_caption_above_media: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): - if not (thumbnail_url or thumb_url): - raise ValueError( - "You must pass either 'thumbnail_url' or 'thumb_url'. Note that 'thumb_url' is " - "deprecated." - ) - - if title is None: - raise TypeError( - "InlineQueryResultVideo.__init__() missing a required argument: you forgot to pass" - " either 'title' or 'thumbnail_url'." - ) - # Required super().__init__(InlineQueryResultType.VIDEO, id, api_kwargs=api_kwargs) with self._unfrozen(): self.video_url: str = video_url self.mime_type: str = mime_type - self.thumbnail_url: str = warn_about_deprecated_arg_return_new_arg( - deprecated_arg=thumb_url, - new_arg=thumbnail_url, - deprecated_arg_name="thumb_url", - new_arg_name="thumbnail_url", - bot_api_version="6.6", - ) + self.thumbnail_url: str = thumbnail_url self.title: str = title # Optional - self.caption: Optional[str] = caption + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode - self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.video_width: Optional[int] = video_width - self.video_height: Optional[int] = video_height - self.video_duration: Optional[int] = video_duration - self.description: Optional[str] = description - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.video_width: int | None = video_width + self.video_height: int | None = video_height + self._video_duration: dtm.timedelta | None = to_timedelta(video_duration) + self.description: str | None = description + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + self.show_caption_above_media: bool | None = show_caption_above_media @property - def thumb_url(self) -> str: - """:obj:`str`: URL of the thumbnail (JPEG only) for the video. - - .. deprecated:: 20.2 - |thumbattributedeprecation| :attr:`thumbnail_url`. - """ - warn_about_deprecated_attr_in_property( - deprecated_attr_name="thumb_url", - new_attr_name="thumbnail_url", - bot_api_version="6.6", - ) - return self.thumbnail_url + def video_duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._video_duration, attribute="video_duration") diff --git a/telegram/_inline/inlinequeryresultvoice.py b/src/telegram/_inline/inlinequeryresultvoice.py similarity index 73% rename from telegram/_inline/inlinequeryresultvoice.py rename to src/telegram/_inline/inlinequeryresultvoice.py index abc29914bbd..92119fe121d 100644 --- a/telegram/_inline/inlinequeryresultvoice.py +++ b/src/telegram/_inline/inlinequeryresultvoice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,14 +17,18 @@ # 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 the classes that represent Telegram InlineQueryResultVoice.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inline.inlinequeryresult import InlineQueryResult from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta +from telegram._utils.datetime import get_timedelta_value from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import JSONDict, ODVInput +from telegram._utils.types import JSONDict, ODVInput, TimePeriod from telegram.constants import InlineQueryResultType if TYPE_CHECKING: @@ -55,7 +59,11 @@ class InlineQueryResultVoice(InlineQueryResult): .. versionchanged:: 20.0 |sequenceclassargs| - voice_duration (:obj:`int`, optional): Recording duration in seconds. + voice_duration (:obj:`int` | :class:`datetime.timedelta`, optional): Recording duration + in seconds. + + .. versionchanged:: v22.2 + |time-period-input| reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached to the message. input_message_content (:class:`telegram.InputMessageContent`, optional): Content of the @@ -72,13 +80,17 @@ class InlineQueryResultVoice(InlineQueryResult): 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 * |tupleclassattrs| * |alwaystuple| - voice_duration (:obj:`int`): Optional. Recording duration in seconds. + voice_duration (:obj:`int` | :class:`datetime.timedelta`): Optional. Recording duration + in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached to the message. input_message_content (:class:`telegram.InputMessageContent`): Optional. Content of the @@ -87,14 +99,14 @@ class InlineQueryResultVoice(InlineQueryResult): """ __slots__ = ( - "reply_markup", - "caption_entities", - "voice_duration", + "_voice_duration", "caption", + "caption_entities", + "input_message_content", + "parse_mode", + "reply_markup", "title", "voice_url", - "parse_mode", - "input_message_content", ) def __init__( @@ -102,14 +114,14 @@ def __init__( id: str, # pylint: disable=redefined-builtin voice_url: str, title: str, - voice_duration: Optional[int] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - input_message_content: Optional["InputMessageContent"] = None, + voice_duration: TimePeriod | None = None, + caption: str | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + input_message_content: "InputMessageContent | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence[MessageEntity]] = None, + caption_entities: Sequence[MessageEntity] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__(InlineQueryResultType.VOICE, id, api_kwargs=api_kwargs) @@ -118,9 +130,13 @@ def __init__( self.title: str = title # Optional - self.voice_duration: Optional[int] = voice_duration - self.caption: Optional[str] = caption + self._voice_duration: dtm.timedelta | None = to_timedelta(voice_duration) + self.caption: str | None = caption self.parse_mode: ODVInput[str] = parse_mode - self.caption_entities: Tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.input_message_content: Optional[InputMessageContent] = input_message_content + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.input_message_content: InputMessageContent | None = input_message_content + + @property + def voice_duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._voice_duration, attribute="voice_duration") diff --git a/telegram/_inline/inputcontactmessagecontent.py b/src/telegram/_inline/inputcontactmessagecontent.py similarity index 87% rename from telegram/_inline/inputcontactmessagecontent.py rename to src/telegram/_inline/inputcontactmessagecontent.py index 22ebb77f295..cead1136dd0 100644 --- a/telegram/_inline/inputcontactmessagecontent.py +++ b/src/telegram/_inline/inputcontactmessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # 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 the classes that represent Telegram InputContactMessageContent.""" -from typing import Optional from telegram._inline.inputmessagecontent import InputMessageContent from telegram._utils.types import JSONDict @@ -45,16 +44,16 @@ class InputContactMessageContent(InputMessageContent): """ - __slots__ = ("vcard", "first_name", "last_name", "phone_number") + __slots__ = ("first_name", "last_name", "phone_number", "vcard") def __init__( self, phone_number: str, first_name: str, - last_name: Optional[str] = None, - vcard: Optional[str] = None, + last_name: str | None = None, + vcard: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) with self._unfrozen(): @@ -62,7 +61,7 @@ def __init__( self.phone_number: str = phone_number self.first_name: str = first_name # Optionals - self.last_name: Optional[str] = last_name - self.vcard: Optional[str] = vcard + self.last_name: str | None = last_name + self.vcard: str | None = vcard self._id_attrs = (self.phone_number,) diff --git a/telegram/_inline/inputinvoicemessagecontent.py b/src/telegram/_inline/inputinvoicemessagecontent.py similarity index 62% rename from telegram/_inline/inputinvoicemessagecontent.py rename to src/telegram/_inline/inputinvoicemessagecontent.py index 2fa89fcc99c..536726e3afa 100644 --- a/telegram/_inline/inputinvoicemessagecontent.py +++ b/src/telegram/_inline/inputinvoicemessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,11 +17,13 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains a class that represents a Telegram InputInvoiceMessageContent.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inputmessagecontent import InputMessageContent from telegram._payment.labeledprice import LabeledPrice -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -34,9 +36,11 @@ class InputInvoiceMessageContent(InputMessageContent): Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`title`, :attr:`description`, :attr:`payload`, - :attr:`provider_token`, :attr:`currency` and :attr:`prices` are equal. + :attr:`currency` and :attr:`prices` are equal. .. versionadded:: 13.5 + .. versionchanged:: 21.11 + :attr:`provider_token` is no longer considered for equality comparison. Args: title (:obj:`str`): Product name. :tg-const:`telegram.Invoice.MIN_TITLE_LENGTH`- @@ -47,26 +51,32 @@ class InputInvoiceMessageContent(InputMessageContent): payload (:obj:`str`): Bot-defined invoice payload. :tg-const:`telegram.Invoice.MIN_PAYLOAD_LENGTH`- :tg-const:`telegram.Invoice.MAX_PAYLOAD_LENGTH` bytes. This will not be displayed - to the user, use for your internal processes. - provider_token (:obj:`str`): Payment provider token, obtained via - `@Botfather `_. + to the user, use it for your internal processes. + provider_token (:obj:`str`, optional): Payment provider token, obtained via + `@Botfather `_. Pass an empty string for payments in + |tg_stars|. + + .. versionchanged:: 21.11 + Bot API 7.4 made this parameter is optional and this is now reflected in the + class signature. currency (:obj:`str`): Three-letter ISO 4217 currency code, see more on - `currencies `_ + `currencies `_. + Pass ``XTR`` for payments in |tg_stars|. prices (Sequence[:class:`telegram.LabeledPrice`]): Price breakdown, a list of components (e.g. product price, tax, discount, delivery cost, delivery tax, bonus, - etc.) + etc.). Must contain exactly one item for payments in |tg_stars|. .. versionchanged:: 20.0 |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``. + 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`. @@ -85,20 +95,20 @@ class InputInvoiceMessageContent(InputMessageContent): photo_size (:obj:`int`, optional): Photo size. photo_width (:obj:`int`, optional): Photo width. photo_height (:obj:`int`, optional): Photo height. - need_name (:obj:`bool`, optional): Pass :obj:`True`, if you require the user's full name to - complete the order. + need_name (:obj:`bool`, optional): Pass :obj:`True`, if you require the user's full + name to complete the order. Ignored for payments in |tg_stars|. need_phone_number (:obj:`bool`, optional): Pass :obj:`True`, if you require the user's - phone number to complete the order + phone number to complete the order. Ignored for payments in |tg_stars|. need_email (:obj:`bool`, optional): Pass :obj:`True`, if you require the user's email - address to complete the order. - need_shipping_address (:obj:`bool`, optional): Pass :obj:`True`, if you require the user's - shipping address to complete the order - send_phone_number_to_provider (:obj:`bool`, optional): Pass :obj:`True`, if user's phone - number should be sent to provider. - send_email_to_provider (:obj:`bool`, optional): Pass :obj:`True`, if user's email address - should be sent to provider. - is_flexible (:obj:`bool`, optional): Pass :obj:`True`, if the final price depends on the - shipping method. + address to complete the order. Ignored for payments in |tg_stars|. + need_shipping_address (:obj:`bool`, optional): Pass :obj:`True`, if you require the + user's shipping address to complete the order. Ignored for payments in |tg_stars| + send_phone_number_to_provider (:obj:`bool`, optional): Pass :obj:`True`, if user's + phone number should be sent to provider. Ignored for payments in |tg_stars|. + send_email_to_provider (:obj:`bool`, optional): Pass :obj:`True`, if user's email + address should be sent to provider. Ignored for payments in |tg_stars|. + is_flexible (:obj:`bool`, optional): Pass :obj:`True`, if the final price depends on + the shipping method. Ignored for payments in |tg_stars|. Attributes: title (:obj:`str`): Product name. :tg-const:`telegram.Invoice.MIN_TITLE_LENGTH`- @@ -109,26 +119,28 @@ class InputInvoiceMessageContent(InputMessageContent): payload (:obj:`str`): Bot-defined invoice payload. :tg-const:`telegram.Invoice.MIN_PAYLOAD_LENGTH`- :tg-const:`telegram.Invoice.MAX_PAYLOAD_LENGTH` bytes. This will not be displayed - to the user, use for your internal processes. + to the user, use it for your internal processes. provider_token (:obj:`str`): Payment provider token, obtained via - `@Botfather `_. + `@Botfather `_. Pass an empty string for payments in `Telegram + Stars `_. currency (:obj:`str`): Three-letter ISO 4217 currency code, see more on - `currencies `_ - prices (Tuple[:class:`telegram.LabeledPrice`]): Price breakdown, a list of + `currencies `_. + Pass ``XTR`` for payments in |tg_stars|. + prices (tuple[:class:`telegram.LabeledPrice`]): Price breakdown, a list of components (e.g. product price, tax, discount, delivery cost, delivery tax, bonus, - etc.) + etc.). Must contain exactly one item for payments in |tg_stars|. .. versionchanged:: 20.0 |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``. - 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). + 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). 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`. @@ -146,43 +158,43 @@ class InputInvoiceMessageContent(InputMessageContent): photo_width (:obj:`int`): Optional. Photo width. photo_height (:obj:`int`): Optional. Photo height. need_name (:obj:`bool`): Optional. Pass :obj:`True`, if you require the user's full name to - complete the order. + complete the order. Ignored for payments in |tg_stars|. need_phone_number (:obj:`bool`): Optional. Pass :obj:`True`, if you require the user's - phone number to complete the order + phone number to complete the order. Ignored for payments in |tg_stars|. need_email (:obj:`bool`): Optional. Pass :obj:`True`, if you require the user's email - address to complete the order. + address to complete the order. Ignored for payments in |tg_stars|. need_shipping_address (:obj:`bool`): Optional. Pass :obj:`True`, if you require the user's - shipping address to complete the order + shipping address to complete the order. Ignored for payments in |tg_stars|. send_phone_number_to_provider (:obj:`bool`): Optional. Pass :obj:`True`, if user's phone - number should be sent to provider. + number should be sent to provider. Ignored for payments in |tg_stars|. send_email_to_provider (:obj:`bool`): Optional. Pass :obj:`True`, if user's email address - should be sent to provider. + should be sent to provider. Ignored for payments in |tg_stars|. is_flexible (:obj:`bool`): Optional. Pass :obj:`True`, if the final price depends on the - shipping method. + shipping method. Ignored for payments in |tg_stars|. """ __slots__ = ( - "title", - "description", - "payload", - "provider_token", "currency", - "prices", + "description", + "is_flexible", "max_tip_amount", - "suggested_tip_amounts", - "provider_data", - "photo_url", - "photo_size", - "photo_width", - "photo_height", + "need_email", "need_name", "need_phone_number", - "need_email", "need_shipping_address", - "send_phone_number_to_provider", + "payload", + "photo_height", + "photo_size", + "photo_url", + "photo_width", + "prices", + "provider_data", + "provider_token", "send_email_to_provider", - "is_flexible", + "send_phone_number_to_provider", + "suggested_tip_amounts", + "title", ) def __init__( @@ -190,25 +202,25 @@ def __init__( title: str, description: str, payload: str, - provider_token: str, currency: str, prices: Sequence[LabeledPrice], - max_tip_amount: Optional[int] = None, - suggested_tip_amounts: Optional[Sequence[int]] = None, - provider_data: Optional[str] = None, - photo_url: Optional[str] = None, - photo_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - need_name: Optional[bool] = None, - need_phone_number: Optional[bool] = None, - need_email: Optional[bool] = None, - need_shipping_address: Optional[bool] = None, - send_phone_number_to_provider: Optional[bool] = None, - send_email_to_provider: Optional[bool] = None, - is_flexible: Optional[bool] = None, + provider_token: str | None = None, + max_tip_amount: int | None = None, + suggested_tip_amounts: Sequence[int] | None = None, + provider_data: str | None = None, + photo_url: str | None = None, + photo_size: int | None = None, + photo_width: int | None = None, + photo_height: int | None = None, + need_name: bool | None = None, + need_phone_number: bool | None = None, + need_email: bool | None = None, + need_shipping_address: bool | None = None, + send_phone_number_to_provider: bool | None = None, + send_email_to_provider: bool | None = None, + is_flexible: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) with self._unfrozen(): @@ -216,44 +228,38 @@ def __init__( self.title: str = title self.description: str = description self.payload: str = payload - self.provider_token: str = provider_token self.currency: str = currency - self.prices: Tuple[LabeledPrice, ...] = parse_sequence_arg(prices) + self.prices: tuple[LabeledPrice, ...] = parse_sequence_arg(prices) # Optionals - self.max_tip_amount: Optional[int] = max_tip_amount - self.suggested_tip_amounts: Tuple[int, ...] = parse_sequence_arg(suggested_tip_amounts) - self.provider_data: Optional[str] = provider_data - self.photo_url: Optional[str] = photo_url - self.photo_size: Optional[int] = photo_size - self.photo_width: Optional[int] = photo_width - self.photo_height: Optional[int] = photo_height - self.need_name: Optional[bool] = need_name - self.need_phone_number: Optional[bool] = need_phone_number - self.need_email: Optional[bool] = need_email - self.need_shipping_address: Optional[bool] = need_shipping_address - self.send_phone_number_to_provider: Optional[bool] = send_phone_number_to_provider - self.send_email_to_provider: Optional[bool] = send_email_to_provider - self.is_flexible: Optional[bool] = is_flexible + self.provider_token: str | None = provider_token + self.max_tip_amount: int | None = max_tip_amount + self.suggested_tip_amounts: tuple[int, ...] = parse_sequence_arg(suggested_tip_amounts) + self.provider_data: str | None = provider_data + self.photo_url: str | None = photo_url + self.photo_size: int | None = photo_size + self.photo_width: int | None = photo_width + self.photo_height: int | None = photo_height + self.need_name: bool | None = need_name + self.need_phone_number: bool | None = need_phone_number + self.need_email: bool | None = need_email + self.need_shipping_address: bool | None = need_shipping_address + self.send_phone_number_to_provider: bool | None = send_phone_number_to_provider + self.send_email_to_provider: bool | None = send_email_to_provider + self.is_flexible: bool | None = is_flexible self._id_attrs = ( self.title, self.description, self.payload, - self.provider_token, self.currency, self.prices, ) @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: "Bot" - ) -> Optional["InputInvoiceMessageContent"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "InputInvoiceMessageContent": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["prices"] = LabeledPrice.de_list(data.get("prices"), bot) + data["prices"] = de_list_optional(data.get("prices"), LabeledPrice, bot) return super().de_json(data=data, bot=bot) diff --git a/telegram/_inline/inputlocationmessagecontent.py b/src/telegram/_inline/inputlocationmessagecontent.py similarity index 69% rename from telegram/_inline/inputlocationmessagecontent.py rename to src/telegram/_inline/inputlocationmessagecontent.py index 4f9fa227783..6e2a77e8e1d 100644 --- a/telegram/_inline/inputlocationmessagecontent.py +++ b/src/telegram/_inline/inputlocationmessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,11 +18,14 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InputLocationMessageContent.""" -from typing import ClassVar, Optional +import datetime as dtm +from typing import Final from telegram import constants from telegram._inline.inputmessagecontent import InputMessageContent -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class InputLocationMessageContent(InputMessageContent): @@ -39,10 +42,15 @@ class InputLocationMessageContent(InputMessageContent): horizontal_accuracy (:obj:`float`, optional): The radius of uncertainty for the location, measured in meters; 0- :tg-const:`telegram.InputLocationMessageContent.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`, optional): Period in seconds for which the location will be - updated, should be between + live_period (:obj:`int` | :class:`datetime.timedelta`, optional): Period in seconds for + which the location will be updated, should be between :tg-const:`telegram.InputLocationMessageContent.MIN_LIVE_PERIOD` and - :tg-const:`telegram.InputLocationMessageContent.MAX_LIVE_PERIOD`. + :tg-const:`telegram.InputLocationMessageContent.MAX_LIVE_PERIOD` or + :tg-const:`telegram.constants.LocationLimit.LIVE_PERIOD_FOREVER` for live + locations that can be edited indefinitely. + + .. versionchanged:: v22.2 + |time-period-input| heading (:obj:`int`, optional): For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.InputLocationMessageContent.MIN_HEADING` and @@ -59,10 +67,13 @@ class InputLocationMessageContent(InputMessageContent): horizontal_accuracy (:obj:`float`): Optional. The radius of uncertainty for the location, measured in meters; 0- :tg-const:`telegram.InputLocationMessageContent.HORIZONTAL_ACCURACY`. - live_period (:obj:`int`): Optional. Period in seconds for which the location can be - updated, should be between + live_period (:obj:`int` | :class:`datetime.timedelta`): Optional. Period in seconds for + which the location can be updated, should be between :tg-const:`telegram.InputLocationMessageContent.MIN_LIVE_PERIOD` and :tg-const:`telegram.InputLocationMessageContent.MAX_LIVE_PERIOD`. + + .. deprecated:: v22.2 + |time-period-int-deprecated| heading (:obj:`int`): Optional. For live locations, a direction in which the user is moving, in degrees. Must be between :tg-const:`telegram.InputLocationMessageContent.MIN_HEADING` and @@ -75,20 +86,26 @@ class InputLocationMessageContent(InputMessageContent): """ - __slots__ = ('longitude', 'horizontal_accuracy', 'proximity_alert_radius', 'live_period', - 'latitude', 'heading') + __slots__ = ( + "_live_period", + "heading", + "horizontal_accuracy", + "latitude", + "longitude", + "proximity_alert_radius", + ) # fmt: on def __init__( self, latitude: float, longitude: float, - live_period: Optional[int] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, + live_period: TimePeriod | None = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) with self._unfrozen(): @@ -97,46 +114,50 @@ def __init__( self.longitude: float = longitude # Optionals - self.live_period: Optional[int] = live_period - self.horizontal_accuracy: Optional[float] = horizontal_accuracy - self.heading: Optional[int] = heading - self.proximity_alert_radius: Optional[int] = ( + self._live_period: dtm.timedelta | None = to_timedelta(live_period) + self.horizontal_accuracy: float | None = horizontal_accuracy + self.heading: int | None = heading + self.proximity_alert_radius: int | None = ( int(proximity_alert_radius) if proximity_alert_radius else None ) self._id_attrs = (self.latitude, self.longitude) - HORIZONTAL_ACCURACY: ClassVar[int] = constants.LocationLimit.HORIZONTAL_ACCURACY + @property + def live_period(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._live_period, attribute="live_period") + + HORIZONTAL_ACCURACY: Final[int] = constants.LocationLimit.HORIZONTAL_ACCURACY """:const:`telegram.constants.LocationLimit.HORIZONTAL_ACCURACY` .. versionadded:: 20.0 """ - MIN_HEADING: ClassVar[int] = constants.LocationLimit.MIN_HEADING + MIN_HEADING: Final[int] = constants.LocationLimit.MIN_HEADING """:const:`telegram.constants.LocationLimit.MIN_HEADING` .. versionadded:: 20.0 """ - MAX_HEADING: ClassVar[int] = constants.LocationLimit.MAX_HEADING + MAX_HEADING: Final[int] = constants.LocationLimit.MAX_HEADING """:const:`telegram.constants.LocationLimit.MAX_HEADING` .. versionadded:: 20.0 """ - MIN_LIVE_PERIOD: ClassVar[int] = constants.LocationLimit.MIN_LIVE_PERIOD + MIN_LIVE_PERIOD: Final[int] = constants.LocationLimit.MIN_LIVE_PERIOD """:const:`telegram.constants.LocationLimit.MIN_LIVE_PERIOD` .. versionadded:: 20.0 """ - MAX_LIVE_PERIOD: ClassVar[int] = constants.LocationLimit.MAX_LIVE_PERIOD + MAX_LIVE_PERIOD: Final[int] = constants.LocationLimit.MAX_LIVE_PERIOD """:const:`telegram.constants.LocationLimit.MAX_LIVE_PERIOD` .. versionadded:: 20.0 """ - MIN_PROXIMITY_ALERT_RADIUS: ClassVar[int] = constants.LocationLimit.MIN_PROXIMITY_ALERT_RADIUS + MIN_PROXIMITY_ALERT_RADIUS: Final[int] = constants.LocationLimit.MIN_PROXIMITY_ALERT_RADIUS """:const:`telegram.constants.LocationLimit.MIN_PROXIMITY_ALERT_RADIUS` .. versionadded:: 20.0 """ - MAX_PROXIMITY_ALERT_RADIUS: ClassVar[int] = constants.LocationLimit.MAX_PROXIMITY_ALERT_RADIUS + MAX_PROXIMITY_ALERT_RADIUS: Final[int] = constants.LocationLimit.MAX_PROXIMITY_ALERT_RADIUS """:const:`telegram.constants.LocationLimit.MAX_PROXIMITY_ALERT_RADIUS` .. versionadded:: 20.0 diff --git a/telegram/_inline/inputmessagecontent.py b/src/telegram/_inline/inputmessagecontent.py similarity index 91% rename from telegram/_inline/inputmessagecontent.py rename to src/telegram/_inline/inputmessagecontent.py index e4e26a39834..6e56dc2e43c 100644 --- a/telegram/_inline/inputmessagecontent.py +++ b/src/telegram/_inline/inputmessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the classes that represent Telegram InputMessageContent.""" -from typing import Optional - from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -36,7 +34,7 @@ class InputMessageContent(TelegramObject): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(api_kwargs=api_kwargs) self._freeze() diff --git a/telegram/_inline/inputtextmessagecontent.py b/src/telegram/_inline/inputtextmessagecontent.py similarity index 61% rename from telegram/_inline/inputtextmessagecontent.py rename to src/telegram/_inline/inputtextmessagecontent.py index 676d36af61a..6edde810673 100644 --- a/telegram/_inline/inputtextmessagecontent.py +++ b/src/telegram/_inline/inputtextmessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,14 +17,19 @@ # 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 the classes that represent Telegram InputTextMessageContent.""" -from typing import Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._inline.inputmessagecontent import InputMessageContent from telegram._messageentity import MessageEntity -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import parse_lpo_and_dwpp, parse_sequence_arg from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput +if TYPE_CHECKING: + from telegram._linkpreviewoptions import LinkPreviewOptions + class InputTextMessageContent(InputMessageContent): """ @@ -47,8 +52,24 @@ class InputTextMessageContent(InputMessageContent): .. versionchanged:: 20.0 |sequenceclassargs| + link_preview_options (:obj:`LinkPreviewOptions`, optional): Link preview generation + options for the message. Mutually exclusive with + :paramref:`disable_web_page_preview`. + + .. versionadded:: 20.8 + + Keyword Args: disable_web_page_preview (:obj:`bool`, optional): Disables link previews for links in the - sent message. + sent message. Convenience parameter for setting :paramref:`link_preview_options`. + Mutually exclusive with :paramref:`link_preview_options`. + + .. versionchanged:: 20.8 + Bot API 7.0 introduced :paramref:`link_preview_options` replacing this + argument. PTB will automatically convert this argument to that one, but + for advanced options, please use :paramref:`link_preview_options` directly. + + .. versionchanged:: 21.0 + |keyword_only_arg| Attributes: message_text (:obj:`str`): Text of the message to be sent, @@ -56,35 +77,41 @@ class InputTextMessageContent(InputMessageContent): :tg-const:`telegram.constants.MessageLimit.MAX_TEXT_LENGTH` characters after entities parsing. parse_mode (:obj:`str`): Optional. |parse_mode| - entities (Tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| .. versionchanged:: 20.0 * |tupleclassattrs| * |alwaystuple| - disable_web_page_preview (:obj:`bool`): Optional. Disables link previews for links in the - sent message. + link_preview_options (:obj:`LinkPreviewOptions`): Optional. Link preview generation + options for the message. + + .. versionadded:: 20.8 """ - __slots__ = ("disable_web_page_preview", "parse_mode", "entities", "message_text") + __slots__ = ("entities", "link_preview_options", "message_text", "parse_mode") def __init__( self, message_text: str, parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, - entities: Optional[Sequence[MessageEntity]] = None, + entities: Sequence[MessageEntity] | None = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, *, - api_kwargs: Optional[JSONDict] = None, + disable_web_page_preview: bool | None = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) + with self._unfrozen(): # Required self.message_text: str = message_text # Optionals self.parse_mode: ODVInput[str] = parse_mode - self.entities: Tuple[MessageEntity, ...] = parse_sequence_arg(entities) - self.disable_web_page_preview: ODVInput[bool] = disable_web_page_preview + self.entities: tuple[MessageEntity, ...] = parse_sequence_arg(entities) + self.link_preview_options: ODVInput[LinkPreviewOptions] = parse_lpo_and_dwpp( + disable_web_page_preview, link_preview_options + ) self._id_attrs = (self.message_text,) diff --git a/telegram/_inline/inputvenuemessagecontent.py b/src/telegram/_inline/inputvenuemessagecontent.py similarity index 86% rename from telegram/_inline/inputvenuemessagecontent.py rename to src/telegram/_inline/inputvenuemessagecontent.py index 5b2eab29afa..28c039fb1a9 100644 --- a/telegram/_inline/inputvenuemessagecontent.py +++ b/src/telegram/_inline/inputvenuemessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # 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 the classes that represent Telegram InputVenueMessageContent.""" -from typing import Optional from telegram._inline.inputmessagecontent import InputMessageContent from telegram._utils.types import JSONDict @@ -46,7 +45,7 @@ class InputVenueMessageContent(InputMessageContent): google_place_id (:obj:`str`, optional): Google Places identifier of the venue. google_place_type (:obj:`str`, optional): Google Places type of the venue. (See `supported types `_.) + /place-types>`_.) Attributes: latitude (:obj:`float`): Latitude of the location in degrees. @@ -60,19 +59,19 @@ class InputVenueMessageContent(InputMessageContent): google_place_id (:obj:`str`): Optional. Google Places identifier of the venue. google_place_type (:obj:`str`): Optional. Google Places type of the venue. (See `supported types `_.) + /place-types>`_.) """ __slots__ = ( - "longitude", - "google_place_type", - "title", "address", "foursquare_id", "foursquare_type", "google_place_id", + "google_place_type", "latitude", + "longitude", + "title", ) def __init__( @@ -81,12 +80,12 @@ def __init__( longitude: float, title: str, address: str, - foursquare_id: Optional[str] = None, - foursquare_type: Optional[str] = None, - google_place_id: Optional[str] = None, - google_place_type: Optional[str] = None, + foursquare_id: str | None = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) with self._unfrozen(): @@ -96,10 +95,10 @@ def __init__( self.title: str = title self.address: str = address # Optionals - self.foursquare_id: Optional[str] = foursquare_id - self.foursquare_type: Optional[str] = foursquare_type - self.google_place_id: Optional[str] = google_place_id - self.google_place_type: Optional[str] = google_place_type + self.foursquare_id: str | None = foursquare_id + self.foursquare_type: str | None = foursquare_type + self.google_place_id: str | None = google_place_id + self.google_place_type: str | None = google_place_type self._id_attrs = ( self.latitude, diff --git a/src/telegram/_inline/preparedinlinemessage.py b/src/telegram/_inline/preparedinlinemessage.py new file mode 100644 index 00000000000..b390c228e3e --- /dev/null +++ b/src/telegram/_inline/preparedinlinemessage.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 Prepared inline Message.""" + +import datetime as dtm +from typing import TYPE_CHECKING + +from telegram._telegramobject import TelegramObject +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 PreparedInlineMessage(TelegramObject): + """Describes an inline message to be sent by a user of a Mini App. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`id` is equal. + + .. versionadded:: 21.8 + + Args: + id (:obj:`str`): Unique identifier of the prepared message + expiration_date (:class:`datetime.datetime`): Expiration date of the prepared message. + Expired prepared messages can no longer be used. + |datetime_localization| + + Attributes: + id (:obj:`str`): Unique identifier of the prepared message + expiration_date (:class:`datetime.datetime`): Expiration date of the prepared message. + Expired prepared messages can no longer be used. + |datetime_localization| + """ + + __slots__ = ("expiration_date", "id") + + def __init__( + self, + id: str, # pylint: disable=redefined-builtin + expiration_date: dtm.datetime, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.id: str = id + self.expiration_date: dtm.datetime = expiration_date + + self._id_attrs = (self.id,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PreparedInlineMessage": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["expiration_date"] = from_timestamp(data.get("expiration_date"), tzinfo=loc_tzinfo) + + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_inputchecklist.py b/src/telegram/_inputchecklist.py new file mode 100644 index 00000000000..d8fd06258ca --- /dev/null +++ b/src/telegram/_inputchecklist.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 objects that are related to Telegram input checklists.""" + +from collections.abc import Sequence + +from telegram._messageentity import MessageEntity +from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.types import JSONDict, ODVInput + + +class InputChecklistTask(TelegramObject): + """ + Describes a task to add to a checklist. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal if their :attr:`id` is equal. + + .. versionadded:: 22.3 + + Args: + id (:obj:`int`): + Unique identifier of the task; must be positive and unique among all task identifiers + currently present in the checklist. + text (:obj:`str`): + Text of the task; + :tg-const:`telegram.constants.InputChecklistLimit.MIN_TEXT_LENGTH`\ +-:tg-const:`telegram.constants.InputChecklistLimit.MAX_TEXT_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`, optional): + |parse_mode| + text_entities (Sequence[:class:`telegram.MessageEntity`], optional): + List of special entities that appear in the text, which can be specified instead of + parse_mode. Currently, only bold, italic, underline, strikethrough, spoiler, and + custom_emoji entities are allowed. + + Attributes: + id (:obj:`int`): + Unique identifier of the task; must be positive and unique among all task identifiers + currently present in the checklist. + text (:obj:`str`): + Text of the task; + :tg-const:`telegram.constants.InputChecklistLimit.MIN_TEXT_LENGTH`\ +-:tg-const:`telegram.constants.InputChecklistLimit.MAX_TEXT_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`): + Optional. |parse_mode| + text_entities (Sequence[:class:`telegram.MessageEntity`]): + Optional. List of special entities that appear in the text, which can be specified + instead of parse_mode. Currently, only bold, italic, underline, strikethrough, spoiler, + and custom_emoji entities are allowed. + + """ + + __slots__ = ( + "id", + "parse_mode", + "text", + "text_entities", + ) + + def __init__( + self, + id: int, # pylint: disable=redefined-builtin + text: str, + parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Sequence[MessageEntity] | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.id: int = id + self.text: str = text + self.parse_mode: ODVInput[str] = parse_mode + self.text_entities: tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) + + self._id_attrs = (self.id,) + + self._freeze() + + +class InputChecklist(TelegramObject): + """ + Describes a checklist to create. + + Objects of this class are comparable in terms of equality. + Two objects of this class are considered equal if their :attr:`tasks` is equal. + + .. versionadded:: 22.3 + + Args: + title (:obj:`str`): + Title of the checklist; + :tg-const:`telegram.constants.InputChecklistLimit.MIN_TITLE_LENGTH`\ +-:tg-const:`telegram.constants.InputChecklistLimit.MAX_TITLE_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`, optional): + |parse_mode| + title_entities (Sequence[:class:`telegram.MessageEntity`], optional): + List of special entities that appear in the title, which + can be specified instead of :paramref:`parse_mode`. Currently, only bold, italic, + underline, strikethrough, spoiler, and custom_emoji entities are allowed. + tasks (Sequence[:class:`telegram.InputChecklistTask`]): + List of + :tg-const:`telegram.constants.InputChecklistLimit.MIN_TASK_NUMBER`\ +-:tg-const:`telegram.constants.InputChecklistLimit.MAX_TASK_NUMBER` tasks in + the checklist. + others_can_add_tasks (:obj:`bool`, optional): + Pass :obj:`True` if other users can add tasks to the checklist. + others_can_mark_tasks_as_done (:obj:`bool`, optional): + Pass :obj:`True` if other users can mark tasks as done or not done in the checklist. + + Attributes: + title (:obj:`str`): + Title of the checklist; + :tg-const:`telegram.constants.InputChecklistLimit.MIN_TITLE_LENGTH`\ +-:tg-const:`telegram.constants.InputChecklistLimit.MAX_TITLE_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`): + Optional. |parse_mode| + title_entities (Sequence[:class:`telegram.MessageEntity`]): + Optional. List of special entities that appear in the title, which + can be specified instead of :paramref:`parse_mode`. Currently, only bold, italic, + underline, strikethrough, spoiler, and custom_emoji entities are allowed. + tasks (Sequence[:class:`telegram.InputChecklistTask`]): + List of + :tg-const:`telegram.constants.InputChecklistLimit.MIN_TASK_NUMBER`\ +-:tg-const:`telegram.constants.InputChecklistLimit.MAX_TASK_NUMBER` tasks in + the checklist. + others_can_add_tasks (:obj:`bool`): + Optional. Pass :obj:`True` if other users can add tasks to the checklist. + others_can_mark_tasks_as_done (:obj:`bool`): + Optional. Pass :obj:`True` if other users can mark tasks as done or not done in + the checklist. + + """ + + __slots__ = ( + "others_can_add_tasks", + "others_can_mark_tasks_as_done", + "parse_mode", + "tasks", + "title", + "title_entities", + ) + + def __init__( + self, + title: str, + tasks: Sequence[InputChecklistTask], + parse_mode: ODVInput[str] = DEFAULT_NONE, + title_entities: Sequence[MessageEntity] | None = None, + others_can_add_tasks: bool | None = None, + others_can_mark_tasks_as_done: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.title: str = title + self.tasks: tuple[InputChecklistTask, ...] = parse_sequence_arg(tasks) + self.parse_mode: ODVInput[str] = parse_mode + self.title_entities: tuple[MessageEntity, ...] = parse_sequence_arg(title_entities) + self.others_can_add_tasks: bool | None = others_can_add_tasks + self.others_can_mark_tasks_as_done: bool | None = others_can_mark_tasks_as_done + + self._id_attrs = (self.tasks,) + + self._freeze() diff --git a/telegram/_keyboardbutton.py b/src/telegram/_keyboardbutton.py similarity index 69% rename from telegram/_keyboardbutton.py rename to src/telegram/_keyboardbutton.py index 9f08d7f3c23..a08e966c35e 100644 --- a/telegram/_keyboardbutton.py +++ b/src/telegram/_keyboardbutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,15 +18,14 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram KeyboardButton.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._keyboardbuttonpolltype import KeyboardButtonPollType -from telegram._keyboardbuttonrequest import KeyboardButtonRequestChat, KeyboardButtonRequestUser +from telegram._keyboardbuttonrequest import KeyboardButtonRequestChat, KeyboardButtonRequestUsers from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict -from telegram._utils.warnings import warn from telegram._webappinfo import WebAppInfo -from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram import Bot @@ -34,12 +33,14 @@ class KeyboardButton(TelegramObject): """ - This object represents one button of the reply keyboard. For simple text buttons, :obj:`str` + This object represents one button of the reply keyboard. At most one of the optional fields + must be used to specify type of the button. For simple text buttons, :obj:`str` can be used instead of this object to specify text of the button. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`text`, :attr:`request_contact`, :attr:`request_location`, - :attr:`request_poll`, :attr:`web_app`, :attr:`request_user` and :attr:`request_chat` are equal. + :attr:`request_poll`, :attr:`web_app`, :attr:`request_users` and :attr:`request_chat` are + equal. Note: * Optional fields are mutually exclusive. @@ -49,16 +50,18 @@ class KeyboardButton(TelegramObject): January, 2020. Older clients will display unsupported message. * :attr:`web_app` option will only work in Telegram versions released after 16 April, 2022. Older clients will display unsupported message. - * :attr:`request_user` and :attr:`request_chat` options will only work in Telegram + * :attr:`request_users` and :attr:`request_chat` options will only work in Telegram versions released after 3 February, 2023. Older clients will display unsupported message. + .. versionchanged:: 21.0 + Removed deprecated argument and attribute ``request_user``. .. versionchanged:: 20.0 :attr:`web_app` is considered as well when comparing objects of this type in terms of equality. - .. deprecated:: 20.1 - :paramref:`request_user` and :paramref:`request_chat` will be considered as well when - comparing objects of this type in terms of equality in V21. + .. versionchanged:: 20.5 + :attr:`request_users` and :attr:`request_chat` are considered as well when + comparing objects of this type in terms of equality. Args: text (:obj:`str`): Text of the button. If none of the optional fields are used, it will be @@ -76,12 +79,13 @@ class KeyboardButton(TelegramObject): Available in private chats only. .. versionadded:: 20.0 - request_user (:class:`KeyboardButtonRequestUser`, optional): If specified, pressing the + + request_users (:class:`KeyboardButtonRequestUsers`, optional): If specified, pressing the button will open a list of suitable users. Tapping on any user will send its - identifier to the bot in a :attr:`telegram.Message.user_shared` service message. + identifier to the bot in a :attr:`telegram.Message.users_shared` service message. Available in private chats only. - .. versionadded:: 20.1 + .. versionadded:: 20.8 request_chat (:class:`KeyboardButtonRequestChat`, optional): If specified, pressing the button will open a list of suitable chats. Tapping on a chat will send its identifier to the bot in a :attr:`telegram.Message.chat_shared` service message. @@ -104,12 +108,12 @@ class KeyboardButton(TelegramObject): Available in private chats only. .. versionadded:: 20.0 - request_user (:class:`KeyboardButtonRequestUser`): Optional. If specified, pressing the + request_users (:class:`KeyboardButtonRequestUsers`): Optional. If specified, pressing the button will open a list of suitable users. Tapping on any user will send its - identifier to the bot in a :attr:`telegram.Message.user_shared` service message. + identifier to the bot in a :attr:`telegram.Message.users_shared` service message. Available in private chats only. - .. versionadded:: 20.1 + .. versionadded:: 20.8 request_chat (:class:`KeyboardButtonRequestChat`): Optional. If specified, pressing the button will open a list of suitable chats. Tapping on a chat will send its identifier to the bot in a :attr:`telegram.Message.chat_shared` service message. @@ -119,37 +123,38 @@ class KeyboardButton(TelegramObject): """ __slots__ = ( - "request_location", + "request_chat", "request_contact", + "request_location", "request_poll", + "request_users", "text", "web_app", - "request_user", - "request_chat", ) def __init__( self, text: str, - request_contact: Optional[bool] = None, - request_location: Optional[bool] = None, - request_poll: Optional[KeyboardButtonPollType] = None, - web_app: Optional[WebAppInfo] = None, - request_user: Optional[KeyboardButtonRequestUser] = None, - request_chat: Optional[KeyboardButtonRequestChat] = None, + request_contact: bool | None = None, + request_location: bool | None = None, + request_poll: KeyboardButtonPollType | None = None, + web_app: WebAppInfo | None = None, + request_chat: KeyboardButtonRequestChat | None = None, + request_users: KeyboardButtonRequestUsers | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) + # Required self.text: str = text # Optionals - self.request_contact: Optional[bool] = request_contact - self.request_location: Optional[bool] = request_location - self.request_poll: Optional[KeyboardButtonPollType] = request_poll - self.web_app: Optional[WebAppInfo] = web_app - self.request_user: Optional[KeyboardButtonRequestUser] = request_user - self.request_chat: Optional[KeyboardButtonRequestChat] = request_chat + self.request_contact: bool | None = request_contact + self.request_location: bool | None = request_location + self.request_poll: KeyboardButtonPollType | None = request_poll + self.web_app: WebAppInfo | None = web_app + self.request_users: KeyboardButtonRequestUsers | None = request_users + self.request_chat: KeyboardButtonRequestChat | None = request_chat self._id_attrs = ( self.text, @@ -157,34 +162,32 @@ def __init__( self.request_location, self.request_poll, self.web_app, + self.request_users, + self.request_chat, ) self._freeze() - def __eq__(self, other: object) -> bool: - warn( - "In v21, granular media settings will be considered as well when comparing" - " ChatPermissions instances.", - PTBDeprecationWarning, - stacklevel=2, - ) - return super().__eq__(other) - - def __hash__(self) -> int: - # Intend: Added so support the own __eq__ function (which otherwise breaks hashing) - return super().__hash__() - @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["KeyboardButton"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "KeyboardButton": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None + data["request_poll"] = de_json_optional( + data.get("request_poll"), KeyboardButtonPollType, bot + ) + data["request_users"] = de_json_optional( + data.get("request_users"), KeyboardButtonRequestUsers, bot + ) + data["request_chat"] = de_json_optional( + data.get("request_chat"), KeyboardButtonRequestChat, bot + ) + data["web_app"] = de_json_optional(data.get("web_app"), WebAppInfo, bot) - data["request_poll"] = KeyboardButtonPollType.de_json(data.get("request_poll"), bot) - data["request_user"] = KeyboardButtonRequestUser.de_json(data.get("request_user"), bot) - data["request_chat"] = KeyboardButtonRequestChat.de_json(data.get("request_chat"), bot) - data["web_app"] = WebAppInfo.de_json(data.get("web_app"), bot) + api_kwargs = {} + # This is a deprecated field that TG still returns for backwards compatibility + # Let's filter it out to speed up the de-json process + if request_user := data.get("request_user"): + api_kwargs = {"request_user": request_user} - return super().de_json(data=data, bot=bot) + return super()._de_json(data=data, bot=bot, api_kwargs=api_kwargs) diff --git a/telegram/_keyboardbuttonpolltype.py b/src/telegram/_keyboardbuttonpolltype.py similarity index 88% rename from telegram/_keyboardbuttonpolltype.py rename to src/telegram/_keyboardbuttonpolltype.py index 911caaafa61..86baadb8fa4 100644 --- a/telegram/_keyboardbuttonpolltype.py +++ b/src/telegram/_keyboardbuttonpolltype.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,10 +17,11 @@ # 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 type of a Telegram Poll.""" -from typing import Optional from telegram._telegramobject import TelegramObject +from telegram._utils import enum from telegram._utils.types import JSONDict +from telegram.constants import PollType class KeyboardButtonPollType(TelegramObject): @@ -49,12 +50,12 @@ class KeyboardButtonPollType(TelegramObject): def __init__( self, - type: Optional[str] = None, # pylint: disable=redefined-builtin + type: str | None = None, # pylint: disable=redefined-builtin *, - api_kwargs: Optional[JSONDict] = None, # skipcq: PYL-W0622 + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) - self.type: Optional[str] = type + self.type: str | None = enum.get_member(PollType, type, type) self._id_attrs = (self.type,) diff --git a/telegram/_keyboardbuttonrequest.py b/src/telegram/_keyboardbuttonrequest.py similarity index 57% rename from telegram/_keyboardbuttonrequest.py rename to src/telegram/_keyboardbuttonrequest.py index f9a58500afd..b5e0b050436 100644 --- a/telegram/_keyboardbuttonrequest.py +++ b/src/telegram/_keyboardbuttonrequest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,37 +17,55 @@ # 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 two objects to request chats/users.""" -from typing import TYPE_CHECKING, Optional + +from typing import TYPE_CHECKING from telegram._chatadministratorrights import ChatAdministratorRights from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: from telegram import Bot -class KeyboardButtonRequestUser(TelegramObject): +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.1 + .. versionadded:: 20.8 + This class was previously named ``KeyboardButtonRequestUser``. Args: request_id (:obj:`int`): Signed 32-bit identifier of the request, which will be received - back in the :class:`telegram.UserShared` object. Must be unique within the message. + back in the :class:`telegram.UsersShared` object. Must be unique within the message. user_is_bot (:obj:`bool`, optional): Pass :obj:`True` to request a bot, pass :obj:`False` to request a regular user. If not specified, no additional restrictions are applied. user_is_premium (:obj:`bool`, optional): Pass :obj:`True` to request a premium user, pass :obj:`False` to request a non-premium user. If not specified, no additional restrictions are applied. + max_quantity (:obj:`int`, optional): The maximum number of users to be selected; + :tg-const:`telegram.constants.KeyboardButtonRequestUsersLimit.MIN_QUANTITY` - + :tg-const:`telegram.constants.KeyboardButtonRequestUsersLimit.MAX_QUANTITY`. + Defaults to :tg-const:`telegram.constants.KeyboardButtonRequestUsersLimit.MIN_QUANTITY` + . + + .. versionadded:: 20.8 + request_name (:obj:`bool`, optional): Pass :obj:`True` to request the users' first and last + name. + + .. versionadded:: 21.1 + request_username (:obj:`bool`, optional): Pass :obj:`True` to request the users' username. + + .. versionadded:: 21.1 + request_photo (:obj:`bool`, optional): Pass :obj:`True` to request the users' photo. + + .. versionadded:: 21.1 + Attributes: request_id (:obj:`int`): Identifier of the request. user_is_bot (:obj:`bool`): Optional. Pass :obj:`True` to request a bot, pass :obj:`False` @@ -55,10 +73,32 @@ class KeyboardButtonRequestUser(TelegramObject): user_is_premium (:obj:`bool`): Optional. Pass :obj:`True` to request a premium user, pass :obj:`False` to request a non-premium user. If not specified, no additional restrictions are applied. + max_quantity (:obj:`int`): Optional. The maximum number of users to be selected; + :tg-const:`telegram.constants.KeyboardButtonRequestUsersLimit.MIN_QUANTITY` - + :tg-const:`telegram.constants.KeyboardButtonRequestUsersLimit.MAX_QUANTITY`. + Defaults to :tg-const:`telegram.constants.KeyboardButtonRequestUsersLimit.MIN_QUANTITY` + . + + .. versionadded:: 20.8 + request_name (:obj:`bool`): Optional. Pass :obj:`True` to request the users' first and last + name. + + .. versionadded:: 21.1 + request_username (:obj:`bool`): Optional. Pass :obj:`True` to request the users' username. + + .. versionadded:: 21.1 + request_photo (:obj:`bool`): Optional. Pass :obj:`True` to request the users' photo. + + .. versionadded:: 21.1 + """ __slots__ = ( + "max_quantity", "request_id", + "request_name", + "request_photo", + "request_username", "user_is_bot", "user_is_premium", ) @@ -66,18 +106,26 @@ class KeyboardButtonRequestUser(TelegramObject): def __init__( self, request_id: int, - user_is_bot: Optional[bool] = None, - user_is_premium: Optional[bool] = None, + user_is_bot: bool | None = None, + user_is_premium: bool | None = None, + max_quantity: int | None = None, + request_name: bool | None = None, + request_username: bool | None = None, + request_photo: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, # skipcq: PYL-W0622 + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.request_id: int = request_id # Optionals - self.user_is_bot: Optional[bool] = user_is_bot - self.user_is_premium: Optional[bool] = user_is_premium + self.user_is_bot: bool | None = user_is_bot + self.user_is_premium: bool | None = user_is_premium + self.max_quantity: int | None = max_quantity + self.request_name: bool | None = request_name + self.request_username: bool | None = request_username + self.request_photo: bool | None = request_photo self._id_attrs = (self.request_id,) @@ -86,15 +134,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: @@ -119,6 +164,15 @@ class KeyboardButtonRequestChat(TelegramObject): applied. bot_is_member (:obj:`bool`, optional): Pass :obj:`True` to request a chat with the bot as a member. Otherwise, no additional restrictions are applied. + request_title (:obj:`bool`, optional): Pass :obj:`True` to request the chat's title. + + .. versionadded:: 21.1 + request_username (:obj:`bool`, optional): Pass :obj:`True` to request the chat's username. + + .. versionadded:: 21.1 + request_photo (:obj:`bool`, optional): Pass :obj:`True` to request the chat's photo. + + .. versionadded:: 21.1 Attributes: request_id (:obj:`int`): Identifier of the request. chat_is_channel (:obj:`bool`): Pass :obj:`True` to request a channel chat, pass @@ -126,7 +180,7 @@ class KeyboardButtonRequestChat(TelegramObject): chat_is_forum (:obj:`bool`): Optional. Pass :obj:`True` to request a forum supergroup, pass :obj:`False` to request a non-forum chat. If not specified, no additional restrictions are applied. - chat_has_username (:obj:`bool`, optional): Pass :obj:`True` to request a supergroup or a + chat_has_username (:obj:`bool`): Optional. Pass :obj:`True` to request a supergroup or a channel with a username, pass :obj:`False` to request a chat without a username. If not specified, no additional restrictions are applied. chat_is_created (:obj:`bool`) Optional. Pass :obj:`True` to request a chat owned by the @@ -140,31 +194,46 @@ class KeyboardButtonRequestChat(TelegramObject): applied. bot_is_member (:obj:`bool`) Optional. Pass :obj:`True` to request a chat with the bot as a member. Otherwise, no additional restrictions are applied. + request_title (:obj:`bool`): Optional. Pass :obj:`True` to request the chat's title. + + .. versionadded:: 21.1 + request_username (:obj:`bool`): Optional. Pass :obj:`True` to request the chat's username. + + .. versionadded:: 21.1 + request_photo (:obj:`bool`): Optional. Pass :obj:`True` to request the chat's photo. + + .. versionadded:: 21.1 """ __slots__ = ( - "request_id", - "chat_is_channel", - "chat_is_forum", + "bot_administrator_rights", + "bot_is_member", "chat_has_username", + "chat_is_channel", "chat_is_created", + "chat_is_forum", + "request_id", + "request_photo", + "request_title", + "request_username", "user_administrator_rights", - "bot_administrator_rights", - "bot_is_member", ) def __init__( self, request_id: int, chat_is_channel: bool, - chat_is_forum: Optional[bool] = None, - chat_has_username: Optional[bool] = None, - chat_is_created: Optional[bool] = None, - user_administrator_rights: Optional[ChatAdministratorRights] = None, - bot_administrator_rights: Optional[ChatAdministratorRights] = None, - bot_is_member: Optional[bool] = None, + chat_is_forum: bool | None = None, + chat_has_username: bool | None = None, + chat_is_created: bool | None = None, + user_administrator_rights: ChatAdministratorRights | None = None, + bot_administrator_rights: ChatAdministratorRights | None = None, + bot_is_member: bool | None = None, + request_title: bool | None = None, + request_username: bool | None = None, + request_photo: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, # skipcq: PYL-W0622 + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # required @@ -172,34 +241,30 @@ def __init__( self.chat_is_channel: bool = chat_is_channel # optional - self.chat_is_forum: Optional[bool] = chat_is_forum - self.chat_has_username: Optional[bool] = chat_has_username - self.chat_is_created: Optional[bool] = chat_is_created - self.user_administrator_rights: Optional[ - ChatAdministratorRights - ] = user_administrator_rights - self.bot_administrator_rights: Optional[ChatAdministratorRights] = bot_administrator_rights - self.bot_is_member: Optional[bool] = bot_is_member + self.chat_is_forum: bool | None = chat_is_forum + self.chat_has_username: bool | None = chat_has_username + self.chat_is_created: bool | None = chat_is_created + self.user_administrator_rights: ChatAdministratorRights | None = user_administrator_rights + self.bot_administrator_rights: ChatAdministratorRights | None = bot_administrator_rights + self.bot_is_member: bool | None = bot_is_member + self.request_title: bool | None = request_title + self.request_username: bool | None = request_username + self.request_photo: bool | None = request_photo self._id_attrs = (self.request_id,) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: "Bot" - ) -> Optional["KeyboardButtonRequestChat"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "KeyboardButtonRequestChat": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["user_administrator_rights"] = ChatAdministratorRights.de_json( - data.get("user_administrator_rights"), bot + data["user_administrator_rights"] = de_json_optional( + data.get("user_administrator_rights"), ChatAdministratorRights, bot ) - data["bot_administrator_rights"] = ChatAdministratorRights.de_json( - data.get("bot_administrator_rights"), bot + data["bot_administrator_rights"] = de_json_optional( + data.get("bot_administrator_rights"), ChatAdministratorRights, bot ) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_linkpreviewoptions.py b/src/telegram/_linkpreviewoptions.py new file mode 100644 index 00000000000..5ad13de7a16 --- /dev/null +++ b/src/telegram/_linkpreviewoptions.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 the LinkPreviewOptions class.""" + +from telegram._telegramobject import TelegramObject +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.types import JSONDict, ODVInput + + +class LinkPreviewOptions(TelegramObject): + """ + Describes the options used for link preview generation. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`is_disabled`, :attr:`url`, :attr:`prefer_small_media`, + :attr:`prefer_large_media`, and :attr:`show_above_text` are equal. + + .. versionadded:: 20.8 + + Args: + is_disabled (:obj:`bool`, optional): :obj:`True`, if the link preview is disabled. + url (:obj:`str`, optional): The URL to use for the link preview. If empty, then the first + URL found in the message text will be used. + prefer_small_media (:obj:`bool`, optional): :obj:`True`, if the media in the link preview + is supposed to be shrunk; ignored if the URL isn't explicitly specified or media size + change isn't supported for the preview. + prefer_large_media (:obj:`bool`, optional): :obj:`True`, if the media in the link preview + is supposed to be enlarged; ignored if the URL isn't explicitly specified or media + size change isn't supported for the preview. + show_above_text (:obj:`bool`, optional): :obj:`True`, if the link preview must be shown + above the message text; otherwise, the link preview will be shown below the message + text. + + Attributes: + is_disabled (:obj:`bool`): Optional. :obj:`True`, if the link preview is disabled. + url (:obj:`str`): Optional. The URL to use for the link preview. If empty, then the first + URL found in the message text will be used. + prefer_small_media (:obj:`bool`): Optional. :obj:`True`, if the media in the link preview + is supposed to be shrunk; ignored if the URL isn't explicitly specified or media size + change isn't supported for the preview. + prefer_large_media (:obj:`bool`): Optional. :obj:`True`, if the media in the link preview + is supposed to be enlarged; ignored if the URL isn't explicitly specified or media size + change isn't supported for the preview. + show_above_text (:obj:`bool`): Optional. :obj:`True`, if the link preview must be shown + above the message text; otherwise, the link preview will be shown below the message + text. + """ + + __slots__ = ( + "is_disabled", + "prefer_large_media", + "prefer_small_media", + "show_above_text", + "url", + ) + + def __init__( + self, + is_disabled: ODVInput[bool] = DEFAULT_NONE, + url: ODVInput[str] = DEFAULT_NONE, + prefer_small_media: ODVInput[bool] = DEFAULT_NONE, + prefer_large_media: ODVInput[bool] = DEFAULT_NONE, + show_above_text: ODVInput[bool] = DEFAULT_NONE, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + + # Optionals + + self.is_disabled: ODVInput[bool] = is_disabled + self.url: ODVInput[str] = url + self.prefer_small_media: ODVInput[bool] = prefer_small_media + self.prefer_large_media: ODVInput[bool] = prefer_large_media + self.show_above_text: ODVInput[bool] = show_above_text + + self._id_attrs = ( + self.is_disabled, + self.url, + self.prefer_small_media, + self.prefer_large_media, + self.show_above_text, + ) + self._freeze() diff --git a/telegram/_loginurl.py b/src/telegram/_loginurl.py similarity index 90% rename from telegram/_loginurl.py rename to src/telegram/_loginurl.py index a8a05a07ec6..9b0ce84c377 100644 --- a/telegram/_loginurl.py +++ b/src/telegram/_loginurl.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # 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 LoginUrl.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -81,24 +80,24 @@ class LoginUrl(TelegramObject): """ - __slots__ = ("bot_username", "request_write_access", "url", "forward_text") + __slots__ = ("bot_username", "forward_text", "request_write_access", "url") def __init__( self, url: str, - forward_text: Optional[bool] = None, - bot_username: Optional[str] = None, - request_write_access: Optional[bool] = None, + forward_text: str | None = None, + bot_username: str | None = None, + request_write_access: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.url: str = url # Optional - self.forward_text: Optional[bool] = forward_text - self.bot_username: Optional[str] = bot_username - self.request_write_access: Optional[bool] = request_write_access + self.forward_text: str | None = forward_text + self.bot_username: str | None = bot_username + self.request_write_access: bool | None = request_write_access self._id_attrs = (self.url,) diff --git a/telegram/_menubutton.py b/src/telegram/_menubutton.py similarity index 75% rename from telegram/_menubutton.py rename to src/telegram/_menubutton.py index 0ceae233969..e570c5a6648 100644 --- a/telegram/_menubutton.py +++ b/src/telegram/_menubutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,10 +17,13 @@ # 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 related to Telegram menu buttons.""" -from typing import TYPE_CHECKING, ClassVar, Dict, Optional, Type + +from typing import TYPE_CHECKING, Final from telegram import constants from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict from telegram._webappinfo import WebAppInfo @@ -55,23 +58,30 @@ class MenuButton(TelegramObject): __slots__ = ("type",) def __init__( - self, type: str, *, api_kwargs: Optional[JSONDict] = None # skipcq: PYL-W0622 + self, + type: str, + *, + api_kwargs: JSONDict | None = None, ): # pylint: disable=redefined-builtin super().__init__(api_kwargs=api_kwargs) - self.type: str = type + self.type: str = enum.get_member(constants.MenuButtonType, type, type) self._id_attrs = (self.type,) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["MenuButton"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "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. + data (dict[:obj:`str`, ...]): The JSON data. + 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. @@ -79,13 +89,7 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["MenuButton"] """ data = cls._parse_data(data) - if data is None: - return None - - if not data and cls is MenuButton: - return None - - _class_mapping: Dict[str, Type["MenuButton"]] = { + _class_mapping: dict[str, type[MenuButton]] = { cls.COMMANDS: MenuButtonCommands, cls.WEB_APP: MenuButtonWebApp, cls.DEFAULT: MenuButtonDefault, @@ -95,11 +99,11 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["MenuButton"] return _class_mapping[data.pop("type")].de_json(data, bot=bot) return super().de_json(data=data, bot=bot) - COMMANDS: ClassVar[str] = constants.MenuButtonType.COMMANDS + COMMANDS: Final[str] = constants.MenuButtonType.COMMANDS """:const:`telegram.constants.MenuButtonType.COMMANDS`""" - WEB_APP: ClassVar[str] = constants.MenuButtonType.WEB_APP + WEB_APP: Final[str] = constants.MenuButtonType.WEB_APP """:const:`telegram.constants.MenuButtonType.WEB_APP`""" - DEFAULT: ClassVar[str] = constants.MenuButtonType.DEFAULT + DEFAULT: Final[str] = constants.MenuButtonType.DEFAULT """:const:`telegram.constants.MenuButtonType.DEFAULT`""" @@ -115,7 +119,7 @@ class MenuButtonCommands(MenuButton): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(type=constants.MenuButtonType.COMMANDS, api_kwargs=api_kwargs) self._freeze() @@ -135,7 +139,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`. @@ -143,12 +150,14 @@ 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") - def __init__(self, text: str, web_app: WebAppInfo, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, text: str, web_app: WebAppInfo, *, api_kwargs: JSONDict | None = None): super().__init__(type=constants.MenuButtonType.WEB_APP, api_kwargs=api_kwargs) with self._unfrozen(): self.text: str = text @@ -157,14 +166,11 @@ 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: JSONDict, bot: "Bot | None" = None) -> "MenuButtonWebApp": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["web_app"] = WebAppInfo.de_json(data.get("web_app"), bot) + data["web_app"] = de_json_optional(data.get("web_app"), WebAppInfo, bot) return super().de_json(data=data, bot=bot) # type: ignore[return-value] @@ -179,6 +185,6 @@ class MenuButtonDefault(MenuButton): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, *, api_kwargs: JSONDict | None = None): super().__init__(type=constants.MenuButtonType.DEFAULT, api_kwargs=api_kwargs) self._freeze() diff --git a/src/telegram/_message.py b/src/telegram/_message.py new file mode 100644 index 00000000000..1a9b4ffbd86 --- /dev/null +++ b/src/telegram/_message.py @@ -0,0 +1,5764 @@ +#!/usr/bin/env python +# pylint: disable=too-many-instance-attributes, too-many-arguments +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 Message.""" + +import datetime as dtm +import re +from collections.abc import Sequence +from html import escape +from typing import TYPE_CHECKING, TypedDict + +from telegram._chat import Chat +from telegram._chatbackground import ChatBackground +from telegram._chatboost import ChatBoostAdded +from telegram._checklists import Checklist, ChecklistTasksAdded, ChecklistTasksDone +from telegram._dice import Dice +from telegram._directmessagepricechanged import DirectMessagePriceChanged +from telegram._directmessagestopic import DirectMessagesTopic +from telegram._files.animation import Animation +from telegram._files.audio import Audio +from telegram._files.contact import Contact +from telegram._files.document import Document +from telegram._files.location import Location +from telegram._files.photosize import PhotoSize +from telegram._files.sticker import Sticker +from telegram._files.venue import Venue +from telegram._files.video import Video +from telegram._files.videonote import VideoNote +from telegram._files.voice import Voice +from telegram._forumtopic import ( + ForumTopicClosed, + ForumTopicCreated, + ForumTopicEdited, + ForumTopicReopened, + GeneralForumTopicHidden, + GeneralForumTopicUnhidden, +) +from telegram._games.game import Game +from telegram._gifts import GiftInfo +from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup +from telegram._inputchecklist import InputChecklist +from telegram._linkpreviewoptions import LinkPreviewOptions +from telegram._messageautodeletetimerchanged import MessageAutoDeleteTimerChanged +from telegram._messageentity import MessageEntity +from telegram._paidmedia import PaidMediaInfo +from telegram._paidmessagepricechanged import PaidMessagePriceChanged +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 +from telegram._reply import ReplyParameters +from telegram._shared import ChatShared, UsersShared +from telegram._story import Story +from telegram._telegramobject import TelegramObject +from telegram._uniquegift import UniqueGiftInfo +from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue +from telegram._utils.entities import parse_message_entities, parse_message_entity +from telegram._utils.strings import TextEncoding +from telegram._utils.types import ( + CorrectOptionID, + JSONDict, + MarkdownVersion, + ODVInput, + TimePeriod, +) +from telegram._utils.warnings import warn +from telegram._videochat import ( + VideoChatEnded, + VideoChatParticipantsInvited, + VideoChatScheduled, + VideoChatStarted, +) +from telegram._webappdata import WebAppData +from telegram._writeaccessallowed import WriteAccessAllowed +from telegram.constants import ZERO_DATE, MessageAttachmentType, ParseMode +from telegram.helpers import escape_markdown +from telegram.warnings import PTBDeprecationWarning + +if TYPE_CHECKING: + from telegram import ( + Bot, + ExternalReplyInfo, + GameHighScore, + Giveaway, + GiveawayCompleted, + GiveawayCreated, + GiveawayWinners, + InputMedia, + InputMediaAudio, + InputMediaDocument, + InputMediaPhoto, + InputMediaVideo, + InputPaidMedia, + InputPollOption, + LabeledPrice, + MessageId, + MessageOrigin, + ReactionType, + SuggestedPostApprovalFailed, + SuggestedPostApproved, + SuggestedPostDeclined, + SuggestedPostInfo, + SuggestedPostPaid, + SuggestedPostParameters, + SuggestedPostRefunded, + TextQuote, + ) + from telegram._utils.types import FileInput, ReplyMarkup + + +class _ReplyKwargs(TypedDict): + __slots__ = ("chat_id", "reply_parameters") # type: ignore[misc] + + chat_id: str | int + reply_parameters: ReplyParameters + + +class MaybeInaccessibleMessage(TelegramObject): + """Base class for Telegram Message Objects. + + Currently, that includes :class:`telegram.Message` and :class:`telegram.InaccessibleMessage`. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`message_id` and :attr:`chat` are equal + + .. versionchanged:: 21.0 + ``__bool__`` is no longer overriden and defaults to Pythons standard implementation. + + .. versionadded:: 20.8 + + Args: + message_id (:obj:`int`): Unique message identifier. + date (:class:`datetime.datetime`): Date the message was sent in Unix time or 0 in Unix + time. Converted to :class:`datetime.datetime` + + |datetime_localization| + chat (:class:`telegram.Chat`): Conversation the message belongs to. + + Attributes: + message_id (:obj:`int`): Unique message identifier. + date (:class:`datetime.datetime`): Date the message was sent in Unix time or 0 in Unix + time. Converted to :class:`datetime.datetime` + + |datetime_localization| + chat (:class:`telegram.Chat`): Conversation the message belongs to. + """ + + __slots__ = ("chat", "date", "message_id") + + def __init__( + self, + chat: Chat, + message_id: int, + date: dtm.datetime, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.chat: Chat = chat + self.message_id: int = message_id + self.date: dtm.datetime = date + + self._id_attrs = (self.message_id, self.chat) + + self._freeze() + + @property + def is_accessible(self) -> bool: + """Convenience attribute. :obj:`True`, if the date is not 0 in Unix time. + + .. versionadded:: 20.8 + """ + return self.date != ZERO_DATE + + @classmethod + def _de_json( + cls, + data: JSONDict | None, + bot: "Bot | None" = None, + api_kwargs: JSONDict | None = None, + ) -> "MaybeInaccessibleMessage | None": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + if cls is MaybeInaccessibleMessage: + if data["date"] == 0: + return InaccessibleMessage.de_json(data=data, bot=bot) + return Message.de_json(data=data, bot=bot) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + # this is to include the Literal from InaccessibleMessage + if data["date"] == 0: + data["date"] = ZERO_DATE + else: + data["date"] = from_timestamp(data.get("date"), tzinfo=loc_tzinfo) + + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + return super()._de_json(data=data, bot=bot, api_kwargs=api_kwargs) + + +class InaccessibleMessage(MaybeInaccessibleMessage): + """This object represents an inaccessible message. + + These are messages that are e.g. deleted. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`message_id` and :attr:`chat` are equal + + .. versionadded:: 20.8 + + Args: + message_id (:obj:`int`): Unique message identifier. + chat (:class:`telegram.Chat`): Chat the message belongs to. + + Attributes: + message_id (:obj:`int`): Unique message identifier. + date (:class:`constants.ZERO_DATE`): Always :tg-const:`telegram.constants.ZERO_DATE`. + The field can be used to differentiate regular and inaccessible messages. + chat (:class:`telegram.Chat`): Chat the message belongs to. + """ + + __slots__ = () + + def __init__( + self, + chat: Chat, + message_id: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(chat=chat, message_id=message_id, date=ZERO_DATE, api_kwargs=api_kwargs) + self._freeze() + + +class Message(MaybeInaccessibleMessage): + # fmt: off + """This object represents a message. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`message_id` and :attr:`chat` are equal. + + Note: + In Python :keyword:`from` is a reserved word. Use :paramref:`from_user` instead. + + .. versionchanged:: 21.0 + Removed deprecated arguments and attributes ``user_shared``, ``forward_from``, + ``forward_from_chat``, ``forward_from_message_id``, ``forward_signature``, + ``forward_sender_name`` and ``forward_date``. + + .. versionchanged:: 20.8 + * This class is now a subclass of :class:`telegram.MaybeInaccessibleMessage`. + * The :paramref:`pinned_message` now can be either :class:`telegram.Message` or + :class:`telegram.InaccessibleMessage`. + + .. versionchanged:: 20.0 + + * The arguments and attributes ``voice_chat_scheduled``, ``voice_chat_started`` and + ``voice_chat_ended``, ``voice_chat_participants_invited`` were renamed to + :paramref:`video_chat_scheduled`/:attr:`video_chat_scheduled`, + :paramref:`video_chat_started`/:attr:`video_chat_started`, + :paramref:`video_chat_ended`/:attr:`video_chat_ended` and + :paramref:`video_chat_participants_invited`/:attr:`video_chat_participants_invited`, + respectively, in accordance to Bot API 6.0. + * The following are now keyword-only arguments in Bot methods: + ``{read, write, connect, pool}_timeout``, ``api_kwargs``, ``contact``, ``quote``, + ``filename``, ``loaction``, ``venue``. Use a named argument for those, + and notice that some positional arguments changed position as a result. + + Args: + message_id (:obj:`int`): Unique message identifier inside this chat. In specific instances + (e.g., message containing a video sent to a big chat), the server might automatically + schedule a message instead of sending it immediately. In such cases, this field will be + ``0`` and the relevant message will be unusable until it is actually sent. + from_user (:class:`telegram.User`, optional): Sender of the message; may be empty for + messages sent to channels. For backward compatibility, if the message was sent on + behalf of a chat, the field contains a fake sender user in non-channel chats. + sender_chat (:class:`telegram.Chat`, optional): Sender of the message when sent on behalf + of a chat. For example, the supergroup itself for messages sent by its anonymous + administrators or a linked channel for messages automatically forwarded to the + channel's discussion group. For backward compatibility, if the message was sent on + behalf of a chat, the field from contains a fake sender user in non-channel chats. + date (:class:`datetime.datetime`): Date the message was sent in Unix time. Converted to + :class:`datetime.datetime`. + + .. versionchanged:: 20.3 + |datetime_localization| + chat (:class:`telegram.Chat`): Conversation the message belongs to. + is_automatic_forward (:obj:`bool`, optional): :obj:`True`, if the message is a channel + post that was automatically forwarded to the connected discussion group. + + .. versionadded:: 13.9 + reply_to_message (:class:`telegram.Message`, optional): For replies, the original message. + Note that the Message object in this field will not contain further + ``reply_to_message`` fields even if it itself is a reply. + edit_date (:class:`datetime.datetime`, optional): Date the message was last edited in Unix + time. Converted to :class:`datetime.datetime`. + + .. versionchanged:: 20.3 + |datetime_localization| + has_protected_content (:obj:`bool`, optional): :obj:`True`, if the message can't be + forwarded. + + .. versionadded:: 13.9 + is_from_offline (:obj:`bool`, optional): :obj:`True`, if the message was sent + by an implicit action, for example, as an away or a greeting business message, + or as a scheduled message. + + .. versionadded:: 21.1 + media_group_id (:obj:`str`, optional): The unique identifier of a media message group this + message belongs to. + text (:obj:`str`, optional): For text messages, the actual UTF-8 text of the message, + 0-:tg-const:`telegram.constants.MessageLimit.MAX_TEXT_LENGTH` characters. + entities (Sequence[:class:`telegram.MessageEntity`], optional): For text messages, special + entities like usernames, URLs, bot commands, etc. that appear in the text. See + :attr:`parse_entity` and :attr:`parse_entities` methods for how to use properly. + This list is empty if the message does not contain entities. + + .. versionchanged:: 20.0 + |sequenceclassargs| + + link_preview_options (:class:`telegram.LinkPreviewOptions`, optional): Options used for + link preview generation for the message, if it is a text message and link preview + options were changed. + + .. versionadded:: 20.8 + + suggested_post_info (:class:`telegram.SuggestedPostInfo`, optional): Information about + suggested post parameters if the message is a suggested post in a channel direct + messages chat. If the message is an approved or declined suggested post, then it can't + be edited. + + .. versionadded:: 22.4 + + effect_id (:obj:`str`, optional): Unique identifier of the message effect added to the + message. + + .. 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 + caption. See :attr:`Message.parse_caption_entity` and :attr:`parse_caption_entities` + methods for how to use properly. This list is empty if the message does not contain + caption entities. + + .. versionchanged:: 20.0 + |sequenceclassargs| + + show_caption_above_media (:obj:`bool`, optional): |show_cap_above_med| + + .. versionadded:: 21.3 + audio (:class:`telegram.Audio`, optional): Message is an audio file, information + about the file. + document (:class:`telegram.Document`, optional): Message is a general file, information + about the file. + animation (:class:`telegram.Animation`, optional): Message is an animation, information + 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. + :ref:`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. + + .. versionchanged:: 20.0 + |sequenceclassargs| + + sticker (:class:`telegram.Sticker`, optional): Message is a sticker, information + about the sticker. + story (:class:`telegram.Story`, optional): Message is a forwarded story. + + .. versionadded:: 20.5 + video (:class:`telegram.Video`, optional): Message is a video, information about the + 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 + 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 + these members). This list is empty if the message does not contain new chat members. + + .. versionchanged:: 20.0 + |sequenceclassargs| + + 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. + location (:class:`telegram.Location`, optional): Message is a shared location, information + about the location. + venue (:class:`telegram.Venue`, optional): Message is a venue, information about the + venue. For backward compatibility, when this field is set, the location field will + also be set. + left_chat_member (:class:`telegram.User`, optional): A member was removed from the group, + information about them (this member may be the bot itself). + new_chat_title (:obj:`str`, optional): A chat title was changed to this value. + new_chat_photo (Sequence[:class:`telegram.PhotoSize`], optional): A chat photo was changed + to this value. This list is empty if the message does not contain a new chat photo. + + .. versionchanged:: 20.0 + |sequenceclassargs| + + delete_chat_photo (:obj:`bool`, optional): Service message: The chat photo was deleted. + group_chat_created (:obj:`bool`, optional): Service message: The group has been created. + supergroup_chat_created (:obj:`bool`, optional): Service message: The supergroup has been + created. This field can't be received in a message coming through updates, because bot + can't be a member of a supergroup when it is created. It can only be found in + :attr:`reply_to_message` if someone replies to a very first message in a directly + created supergroup. + channel_chat_created (:obj:`bool`, optional): Service message: The channel has been + created. This field can't be received in a message coming through updates, because bot + can't be a member of a channel when it is created. It can only be found in + :attr:`reply_to_message` if someone replies to a very first message in a channel. + message_auto_delete_timer_changed (:class:`telegram.MessageAutoDeleteTimerChanged`, \ + optional): Service message: auto-delete timer settings changed in the chat. + + .. versionadded:: 13.4 + migrate_to_chat_id (:obj:`int`, optional): The group has been migrated to a supergroup + with the specified identifier. + migrate_from_chat_id (:obj:`int`, optional): The supergroup has been migrated from a group + with the specified identifier. + pinned_message (:class:`telegram.MaybeInaccessibleMessage`, optional): Specified message + was pinned. Note that the Message object in this field will not contain further + :attr:`reply_to_message` fields even if it is itself a reply. + + .. versionchanged:: 20.8 + This attribute now is either :class:`telegram.Message` or + :class:`telegram.InaccessibleMessage`. + invoice (:class:`telegram.Invoice`, optional): Message is an invoice for a payment, + information about the invoice. + :ref:`More about payments >> `. + successful_payment (:class:`telegram.SuccessfulPayment`, optional): Message is a service + message about a successful payment, information about the payment. + :ref:`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. + paid_star_count (:obj:`int`, optional): The number of Telegram Stars that were paid by the + sender of the message to send it + + .. versionadded:: 22.1 + passport_data (:class:`telegram.PassportData`, optional): Telegram Passport data. + poll (:class:`telegram.Poll`, optional): Message is a native poll, + information about the poll. + dice (:class:`telegram.Dice`, optional): Message is a dice with random value. + via_bot (:class:`telegram.User`, optional): Bot through which message was sent. + proximity_alert_triggered (:class:`telegram.ProximityAlertTriggered`, optional): Service + message. A user in the chat triggered another user's proximity alert while sharing + Live Location. + video_chat_scheduled (:class:`telegram.VideoChatScheduled`, optional): Service message: + video chat scheduled. + + .. versionadded:: 20.0 + video_chat_started (:class:`telegram.VideoChatStarted`, optional): Service message: video + chat started. + + .. versionadded:: 20.0 + video_chat_ended (:class:`telegram.VideoChatEnded`, optional): Service message: video chat + ended. + + .. versionadded:: 20.0 + video_chat_participants_invited (:class:`telegram.VideoChatParticipantsInvited` optional): + Service message: new participants invited to a video chat. + + .. versionadded:: 20.0 + web_app_data (:class:`telegram.WebAppData`, optional): Service message: data sent by a Web + App. + + .. versionadded:: 20.0 + reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached + to the message. :paramref:`~telegram.InlineKeyboardButton.login_url` buttons are + represented as ordinary url buttons. + is_topic_message (:obj:`bool`, optional): :obj:`True`, if the message is sent to a topic + in a forum supergroup or a private chat with the bot. + + .. versionadded:: 20.0 + message_thread_id (:obj:`int`, optional): Unique identifier of a message thread or forum + topic to which the message belongs; for supergroups and private chats only. + + .. versionadded:: 20.0 + forum_topic_created (:class:`telegram.ForumTopicCreated`, optional): Service message: + forum topic created. + + .. versionadded:: 20.0 + forum_topic_closed (:class:`telegram.ForumTopicClosed`, optional): Service message: + forum topic closed. + + .. versionadded:: 20.0 + forum_topic_reopened (:class:`telegram.ForumTopicReopened`, optional): Service message: + forum topic reopened. + + .. versionadded:: 20.0 + forum_topic_edited (:class:`telegram.ForumTopicEdited`, optional): Service message: + forum topic edited. + + .. versionadded:: 20.0 + general_forum_topic_hidden (:class:`telegram.GeneralForumTopicHidden`, optional): + Service message: General forum topic hidden. + + .. versionadded:: 20.0 + general_forum_topic_unhidden (:class:`telegram.GeneralForumTopicUnhidden`, optional): + Service message: General forum topic unhidden. + + .. versionadded:: 20.0 + write_access_allowed (:class:`telegram.WriteAccessAllowed`, optional): Service message: + the user allowed the bot to write messages after adding it to the attachment or side + menu, launching a Web App from a link, or accepting an explicit request from a Web App + sent by the method + `requestWriteAccess `_. + + .. versionadded:: 20.0 + has_media_spoiler (:obj:`bool`, optional): :obj:`True`, if the message media is covered + by a spoiler animation. + + .. versionadded:: 20.0 + checklist (:class:`telegram.Checklist`, optional): Message is a checklist + + .. versionadded:: 22.3 + users_shared (:class:`telegram.UsersShared`, optional): Service message: users were shared + with the bot + + .. versionadded:: 20.8 + chat_shared (:class:`telegram.ChatShared`, optional):Service message: a chat was shared + with the bot. + + .. versionadded:: 20.1 + gift (:class:`telegram.GiftInfo`, optional): Service message: a regular gift was sent + or received. + + .. versionadded:: 22.1 + unique_gift (:class:`telegram.UniqueGiftInfo`, optional): Service message: a unique gift + was sent or received + + .. versionadded:: 22.1 + gift_upgrade_sent (:class:`telegram.GiftInfo`, optional): Service message: upgrade of a + gift was purchased after the gift was sent + + .. versionadded:: 22.6 + giveaway_created (:class:`telegram.GiveawayCreated`, optional): Service message: a + scheduled giveaway was created + + .. versionadded:: 20.8 + giveaway (:class:`telegram.Giveaway`, optional): The message is a scheduled giveaway + message + + .. versionadded:: 20.8 + giveaway_winners (:class:`telegram.GiveawayWinners`, optional): A giveaway with public + winners was completed + + .. versionadded:: 20.8 + giveaway_completed (:class:`telegram.GiveawayCompleted`, optional): Service message: a + giveaway without public winners was completed + + .. versionadded:: 20.8 + paid_message_price_changed (:class:`telegram.PaidMessagePriceChanged`, optional): Service + message: the price for paid messages has changed in the chat + + .. versionadded:: 22.1 + suggested_post_approved (:class:`telegram.SuggestedPostApproved`, optional): Service + message: a suggested post was approved. + + .. versionadded:: 22.4 + suggested_post_approval_failed (:class:`telegram.SuggestedPostApproved`, optional): Service + message: approval of a suggested post has failed. + + .. versionadded:: 22.4 + suggested_post_declined (:class:`telegram.SuggestedPostDeclined`, optional): Service + message: a suggested post was declined. + + .. versionadded:: 22.4 + suggested_post_paid (:class:`telegram.SuggestedPostPaid`, optional): Service + message: payment for a suggested post was received. + + .. versionadded:: 22.4 + suggested_post_refunded (:class:`telegram.SuggestedPostRefunded`, optional): Service + message: payment for a suggested post was refunded. + + .. versionadded:: 22.4 + external_reply (:class:`telegram.ExternalReplyInfo`, optional): Information about the + message that is being replied to, which may come from another chat or forum topic. + + .. versionadded:: 20.8 + quote (:class:`telegram.TextQuote`, optional): For replies that quote part of the original + message, the quoted part of the message. + + .. versionadded:: 20.8 + forward_origin (:class:`telegram.MessageOrigin`, optional): Information about the original + message for forwarded messages + + .. versionadded:: 20.8 + reply_to_story (:class:`telegram.Story`, optional): For replies to a story, the original + story. + + .. versionadded:: 21.0 + boost_added (:class:`telegram.ChatBoostAdded`, optional): Service message: user boosted + the chat. + + .. versionadded:: 21.0 + sender_boost_count (:obj:`int`, optional): If the sender of the + message boosted the chat, the number of boosts added by the user. + + .. versionadded:: 21.0 + business_connection_id (:obj:`str`, optional): Unique identifier of the business connection + from which the message was received. If non-empty, the message belongs to a chat of the + corresponding business account that is independent from any potential bot chat which + might share the same identifier. + + .. versionadded:: 21.1 + + sender_business_bot (:class:`telegram.User`, optional): The bot that actually sent the + message on behalf of the business account. Available only for outgoing messages sent + on behalf of the connected business account. + + .. versionadded:: 21.1 + + chat_background_set (:class:`telegram.ChatBackground`, optional): Service message: chat + background set. + + .. versionadded:: 21.2 + checklist_tasks_done (:class:`telegram.ChecklistTasksDone`, optional): Service message: + some tasks in a checklist were marked as done or not done + + .. versionadded:: 22.3 + checklist_tasks_added (:class:`telegram.ChecklistTasksAdded`, optional): Service message: + tasks were added to a checklist + + .. versionadded:: 22.3 + paid_media (:class:`telegram.PaidMediaInfo`, optional): Message contains paid media; + information about the paid media. + + .. versionadded:: 21.4 + refunded_payment (:class:`telegram.RefundedPayment`, optional): Message is a service + message about a refunded payment, information about the payment. + + .. versionadded:: 21.4 + direct_message_price_changed (:class:`telegram.DirectMessagePriceChanged`, optional): + Service message: the price for paid messages in the corresponding direct messages chat + of a channel has changed. + + .. versionadded:: 22.3 + is_paid_post (:obj:`bool`, optional): :obj:`True`, if the message is a paid post. Note that + such posts must not be deleted for 24 hours to receive the payment and can't be edited. + + .. versionadded:: 22.4 + direct_messages_topic (:class:`telegram.DirectMessagesTopic`, optional): Information about + the direct messages chat topic that contains the message. + + .. versionadded:: 22.4 + reply_to_checklist_task_id (:obj:`int`, optional): Identifier of the specific checklist + task that is being replied to. + + .. versionadded:: 22.4 + + Attributes: + message_id (:obj:`int`): Unique message identifier inside this chat. In specific instances + (e.g., message containing a video sent to a big chat), the server might automatically + schedule a message instead of sending it immediately. In such cases, this field will be + ``0`` and the relevant message will be unusable until it is actually sent. + from_user (:class:`telegram.User`): Optional. Sender of the message; may be empty for + messages sent to channels. For backward compatibility, if the message was sent on + behalf of a chat, the field contains a fake sender user in non-channel chats. + sender_chat (:class:`telegram.Chat`): Optional. Sender of the message when sent on behalf + of a chat. For example, the supergroup itself for messages sent by its anonymous + administrators or a linked channel for messages automatically forwarded to the + channel's discussion group. For backward compatibility, if the message was sent on + behalf of a chat, the field from contains a fake sender user in non-channel chats. + date (:class:`datetime.datetime`): Date the message was sent in Unix time. Converted to + :class:`datetime.datetime`. + + .. versionchanged:: 20.3 + |datetime_localization| + chat (:class:`telegram.Chat`): Conversation the message belongs to. + is_automatic_forward (:obj:`bool`): Optional. :obj:`True`, if the message is a channel + post that was automatically forwarded to the connected discussion group. + + .. versionadded:: 13.9 + reply_to_message (:class:`telegram.Message`): Optional. For replies, the original message. + Note that the Message object in this field will not contain further + ``reply_to_message`` fields even if it itself is a reply. + edit_date (:class:`datetime.datetime`): Optional. Date the message was last edited in Unix + time. Converted to :class:`datetime.datetime`. + + .. versionchanged:: 20.3 + |datetime_localization| + has_protected_content (:obj:`bool`): Optional. :obj:`True`, if the message can't be + forwarded. + + .. versionadded:: 13.9 + is_from_offline (:obj:`bool`): Optional. :obj:`True`, if the message was sent + by an implicit action, for example, as an away or a greeting business message, + or as a scheduled message. + + .. versionadded:: 21.1 + media_group_id (:obj:`str`): Optional. The unique identifier of a media message group this + message belongs to. + text (:obj:`str`): Optional. For text messages, the actual UTF-8 text of the message, + 0-:tg-const:`telegram.constants.MessageLimit.MAX_TEXT_LENGTH` characters. + entities (tuple[:class:`telegram.MessageEntity`]): Optional. For text messages, special + entities like usernames, URLs, bot commands, etc. that appear in the text. See + :attr:`parse_entity` and :attr:`parse_entities` methods for how to use properly. + This list is empty if the message does not contain entities. + + .. versionchanged:: 20.0 + |tupleclassattrs| + + link_preview_options (:class:`telegram.LinkPreviewOptions`): Optional. Options used for + link preview generation for the message, if it is a text message and link preview + options were changed. + + .. versionadded:: 20.8 + + suggested_post_info (:class:`telegram.SuggestedPostInfo`): Optional. Information about + suggested post parameters if the message is a suggested post in a channel direct + messages chat. If the message is an approved or declined suggested post, then it can't + be edited. + + .. versionadded:: 22.4 + + effect_id (:obj:`str`): Optional. Unique identifier of the message effect added to the + message. + + .. versionadded:: 21.3 + + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. For messages with a + Caption. Special entities like usernames, URLs, bot commands, etc. that appear in the + caption. See :attr:`Message.parse_caption_entity` and :attr:`parse_caption_entities` + methods for how to use properly. This list is empty if the message does not contain + caption entities. + + .. versionchanged:: 20.0 + |tupleclassattrs| + + show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med| + + .. versionadded:: 21.3 + audio (:class:`telegram.Audio`): Optional. Message is an audio file, information + about the file. + + .. seealso:: :wiki:`Working with Files and Media ` + document (:class:`telegram.Document`): Optional. Message is a general file, information + about the file. + + .. seealso:: :wiki:`Working with Files and Media ` + animation (:class:`telegram.Animation`): Optional. Message is an animation, information + about the animation. For backward compatibility, when this field is set, the document + field will also be set. + + .. seealso:: :wiki:`Working with Files and Media ` + game (:class:`telegram.Game`): Optional. Message is a game, information about the game. + :ref:`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. + + .. seealso:: :wiki:`Working with Files and Media ` + + .. versionchanged:: 20.0 + |tupleclassattrs| + + sticker (:class:`telegram.Sticker`): Optional. Message is a sticker, information + about the sticker. + + .. seealso:: :wiki:`Working with Files and Media ` + story (:class:`telegram.Story`): Optional. Message is a forwarded story. + + .. versionadded:: 20.5 + video (:class:`telegram.Video`): Optional. Message is a video, information about the + video. + + .. seealso:: :wiki:`Working with Files and Media ` + voice (:class:`telegram.Voice`): Optional. Message is a voice message, information about + the file. + + .. seealso:: :wiki:`Working with Files and Media ` + video_note (:class:`telegram.VideoNote`): Optional. Message is a + `video note `_, information + about the video message. + + .. seealso:: :wiki:`Working with Files and Media ` + new_chat_members (tuple[: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 + these members). This list is empty if the message does not contain new chat members. + + .. versionchanged:: 20.0 + |tupleclassattrs| + 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. + location (:class:`telegram.Location`): Optional. Message is a shared location, information + about the location. + venue (:class:`telegram.Venue`): Optional. Message is a venue, information about the + venue. For backward compatibility, when this field is set, the location field will + also be set. + left_chat_member (:class:`telegram.User`): Optional. A member was removed from the group, + information about them (this member may be the bot itself). + new_chat_title (:obj:`str`): Optional. A chat title was changed to this value. + new_chat_photo (tuple[:class:`telegram.PhotoSize`]): A chat photo was changed to + this value. This list is empty if the message does not contain a new chat photo. + + .. versionchanged:: 20.0 + |tupleclassattrs| + + delete_chat_photo (:obj:`bool`): Optional. Service message: The chat photo was deleted. + group_chat_created (:obj:`bool`): Optional. Service message: The group has been created. + supergroup_chat_created (:obj:`bool`): Optional. Service message: The supergroup has been + created. This field can't be received in a message coming through updates, because bot + can't be a member of a supergroup when it is created. It can only be found in + :attr:`reply_to_message` if someone replies to a very first message in a directly + created supergroup. + channel_chat_created (:obj:`bool`): Optional. Service message: The channel has been + created. This field can't be received in a message coming through updates, because bot + can't be a member of a channel when it is created. It can only be found in + :attr:`reply_to_message` if someone replies to a very first message in a channel. + message_auto_delete_timer_changed (:class:`telegram.MessageAutoDeleteTimerChanged`): + Optional. Service message: auto-delete timer settings changed in the chat. + + .. versionadded:: 13.4 + migrate_to_chat_id (:obj:`int`): Optional. The group has been migrated to a supergroup + with the specified identifier. + migrate_from_chat_id (:obj:`int`): Optional. The supergroup has been migrated from a group + with the specified identifier. + pinned_message (:class:`telegram.MaybeInaccessibleMessage`): Optional. Specified message + was pinned. Note that the Message object in this field will not contain further + :attr:`reply_to_message` fields even if it is itself a reply. + + .. versionchanged:: 20.8 + This attribute now is either :class:`telegram.Message` or + :class:`telegram.InaccessibleMessage`. + invoice (:class:`telegram.Invoice`): Optional. Message is an invoice for a payment, + information about the invoice. + :ref:`More about payments >> `. + successful_payment (:class:`telegram.SuccessfulPayment`): Optional. Message is a service + message about a successful payment, information about the payment. + :ref:`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. + paid_star_count (:obj:`int`): Optional. The number of Telegram Stars that were paid by the + sender of the message to send it + + .. versionadded:: 22.1 + passport_data (:class:`telegram.PassportData`): Optional. Telegram Passport data. + + Examples: + :any:`Passport Bot ` + poll (:class:`telegram.Poll`): Optional. Message is a native poll, + information about the poll. + dice (:class:`telegram.Dice`): Optional. Message is a dice with random value. + via_bot (:class:`telegram.User`): Optional. Bot through which message was sent. + proximity_alert_triggered (:class:`telegram.ProximityAlertTriggered`): Optional. Service + message. A user in the chat triggered another user's proximity alert while sharing + Live Location. + video_chat_scheduled (:class:`telegram.VideoChatScheduled`): Optional. Service message: + video chat scheduled. + + .. versionadded:: 20.0 + video_chat_started (:class:`telegram.VideoChatStarted`): Optional. Service message: video + chat started. + + .. versionadded:: 20.0 + video_chat_ended (:class:`telegram.VideoChatEnded`): Optional. Service message: video chat + ended. + + .. versionadded:: 20.0 + video_chat_participants_invited (:class:`telegram.VideoChatParticipantsInvited`): Optional. + Service message: new participants invited to a video chat. + + .. versionadded:: 20.0 + web_app_data (:class:`telegram.WebAppData`): Optional. Service message: data sent by a Web + App. + + .. versionadded:: 20.0 + reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached + to the message. :paramref:`~telegram.InlineKeyboardButton.login_url` buttons are + represented as ordinary url buttons. + is_topic_message (:obj:`bool`): Optional. :obj:`True`, if the message is sent to a topic + in a forum supergroup or a private chat with the bot. + + .. versionadded:: 20.0 + message_thread_id (:obj:`int`): Optional. Unique identifier of a message thread or forum + topic to which the message belongs; for supergroups and private chats only. + + .. versionadded:: 20.0 + forum_topic_created (:class:`telegram.ForumTopicCreated`): Optional. Service message: + forum topic created. + + .. versionadded:: 20.0 + forum_topic_closed (:class:`telegram.ForumTopicClosed`): Optional. Service message: + forum topic closed. + + .. versionadded:: 20.0 + forum_topic_reopened (:class:`telegram.ForumTopicReopened`): Optional. Service message: + forum topic reopened. + + .. versionadded:: 20.0 + forum_topic_edited (:class:`telegram.ForumTopicEdited`): Optional. Service message: + forum topic edited. + + .. versionadded:: 20.0 + general_forum_topic_hidden (:class:`telegram.GeneralForumTopicHidden`): Optional. + Service message: General forum topic hidden. + + .. versionadded:: 20.0 + general_forum_topic_unhidden (:class:`telegram.GeneralForumTopicUnhidden`): Optional. + Service message: General forum topic unhidden. + + .. versionadded:: 20.0 + write_access_allowed (:class:`telegram.WriteAccessAllowed`): Optional. Service message: + the user allowed the bot added to the attachment menu to write messages. + + .. versionadded:: 20.0 + has_media_spoiler (:obj:`bool`): Optional. :obj:`True`, if the message media is covered + by a spoiler animation. + + .. versionadded:: 20.0 + checklist (:class:`telegram.Checklist`): Optional. Message is a checklist + + .. versionadded:: 22.3 + users_shared (:class:`telegram.UsersShared`): Optional. Service message: users were shared + with the bot + + .. versionadded:: 20.8 + chat_shared (:class:`telegram.ChatShared`): Optional. Service message: a chat was shared + with the bot. + + .. versionadded:: 20.1 + gift (:class:`telegram.GiftInfo`): Optional. Service message: a regular gift was sent + or received. + + .. versionadded:: 22.1 + unique_gift (:class:`telegram.UniqueGiftInfo`): Optional. Service message: a unique gift + was sent or received + + .. versionadded:: 22.1 + gift_upgrade_sent (:class:`telegram.GiftInfo`): Optional. Service message: upgrade of a + gift was purchased after the gift was sent + + .. versionadded:: 22.6 + giveaway_created (:class:`telegram.GiveawayCreated`): Optional. Service message: a + scheduled giveaway was created + + .. versionadded:: 20.8 + giveaway (:class:`telegram.Giveaway`): Optional. The message is a scheduled giveaway + message + + .. versionadded:: 20.8 + giveaway_winners (:class:`telegram.GiveawayWinners`): Optional. A giveaway with public + winners was completed + + .. versionadded:: 20.8 + giveaway_completed (:class:`telegram.GiveawayCompleted`): Optional. Service message: a + giveaway without public winners was completed + + .. versionadded:: 20.8 + paid_message_price_changed (:class:`telegram.PaidMessagePriceChanged`): Optional. Service + message: the price for paid messages has changed in the chat + + .. versionadded:: 22.1 + suggested_post_approved (:class:`telegram.SuggestedPostApproved`): Optional. Service + message: a suggested post was approved. + + .. versionadded:: 22.4 + suggested_post_approval_failed (:class:`telegram.SuggestedPostApproved`): Optional. Service + message: approval of a suggested post has failed. + + .. versionadded:: 22.4 + suggested_post_declined (:class:`telegram.SuggestedPostDeclined`): Optional. Service + message: a suggested post was declined. + + .. versionadded:: 22.4 + suggested_post_paid (:class:`telegram.SuggestedPostPaid`): Optional. Service + message: payment for a suggested post was received. + + .. versionadded:: 22.4 + suggested_post_refunded (:class:`telegram.SuggestedPostRefunded`): Optional. Service + message: payment for a suggested post was refunded. + + .. versionadded:: 22.4 + external_reply (:class:`telegram.ExternalReplyInfo`): Optional. Information about the + message that is being replied to, which may come from another chat or forum topic. + + .. versionadded:: 20.8 + quote (:class:`telegram.TextQuote`): Optional. For replies that quote part of the original + message, the quoted part of the message. + + .. versionadded:: 20.8 + forward_origin (:class:`telegram.MessageOrigin`): Optional. Information about the original + message for forwarded messages + + .. versionadded:: 20.8 + reply_to_story (:class:`telegram.Story`): Optional. For replies to a story, the original + story. + + .. versionadded:: 21.0 + boost_added (:class:`telegram.ChatBoostAdded`): Optional. Service message: user boosted + the chat. + + .. versionadded:: 21.0 + sender_boost_count (:obj:`int`): Optional. If the sender of the + message boosted the chat, the number of boosts added by the user. + + .. versionadded:: 21.0 + + business_connection_id (:obj:`str`): Optional. Unique identifier of the business connection + from which the message was received. If non-empty, the message belongs to a chat of the + corresponding business account that is independent from any potential bot chat which + might share the same identifier. + + .. versionadded:: 21.1 + + sender_business_bot (:class:`telegram.User`): Optional. The bot that actually sent the + message on behalf of the business account. Available only for outgoing messages sent + on behalf of the connected business account. + + .. versionadded:: 21.1 + + chat_background_set (:class:`telegram.ChatBackground`): Optional. Service message: chat + background set + + .. versionadded:: 21.2 + checklist_tasks_done (:class:`telegram.ChecklistTasksDone`): Optional. Service message: + some tasks in a checklist were marked as done or not done + + .. versionadded:: 22.3 + checklist_tasks_added (:class:`telegram.ChecklistTasksAdded`): Optional. Service message: + tasks were added to a checklist + + .. versionadded:: 22.3 + paid_media (:class:`telegram.PaidMediaInfo`): Optional. Message contains paid media; + information about the paid media. + + .. versionadded:: 21.4 + refunded_payment (:class:`telegram.RefundedPayment`): Optional. Message is a service + message about a refunded payment, information about the payment. + + .. versionadded:: 21.4 + direct_message_price_changed (:class:`telegram.DirectMessagePriceChanged`): + Optional. Service message: the price for paid messages in the corresponding direct + messages chat of a channel has changed. + + .. versionadded:: 22.3 + is_paid_post (:obj:`bool`): Optional. :obj:`True`, if the message is a paid post. Note that + such posts must not be deleted for 24 hours to receive the payment and can't be edited. + + .. versionadded:: 22.4 + direct_messages_topic (:class:`telegram.DirectMessagesTopic`): Optional. Information about + the direct messages chat topic that contains the message. + + .. versionadded:: 22.4 + reply_to_checklist_task_id (:obj:`int`): Optional. Identifier of the specific checklist + task that is being replied to. + + .. versionadded:: 22.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 + :exc:`ValueError` when encountering a custom emoji. + + .. |blockquote_no_md1_support| replace:: Since block quotation entities are not supported + by :attr:`~telegram.constants.ParseMode.MARKDOWN`, this method now raises a + :exc:`ValueError` when encountering a block quotation. + + .. |reply_same_thread| replace:: If :paramref:`message_thread_id` is not provided, + this will reply to the same thread (topic) of the original message. + + .. |quote_removed| replace:: Removed deprecated parameter ``quote``. Use :paramref:`do_quote` + instead. + """ + + # fmt: on + __slots__ = ( + "_effective_attachment", + "animation", + "audio", + "author_signature", + "boost_added", + "business_connection_id", + "caption", + "caption_entities", + "channel_chat_created", + "chat_background_set", + "chat_shared", + "checklist", + "checklist_tasks_added", + "checklist_tasks_done", + "connected_website", + "contact", + "delete_chat_photo", + "dice", + "direct_message_price_changed", + "direct_messages_topic", + "document", + "edit_date", + "effect_id", + "entities", + "external_reply", + "forum_topic_closed", + "forum_topic_created", + "forum_topic_edited", + "forum_topic_reopened", + "forward_origin", + "from_user", + "game", + "general_forum_topic_hidden", + "general_forum_topic_unhidden", + "gift", + "gift_upgrade_sent", + "giveaway", + "giveaway_completed", + "giveaway_created", + "giveaway_winners", + "group_chat_created", + "has_media_spoiler", + "has_protected_content", + "invoice", + "is_automatic_forward", + "is_from_offline", + "is_paid_post", + "is_topic_message", + "left_chat_member", + "link_preview_options", + "location", + "media_group_id", + "message_auto_delete_timer_changed", + "message_thread_id", + "migrate_from_chat_id", + "migrate_to_chat_id", + "new_chat_members", + "new_chat_photo", + "new_chat_title", + "paid_media", + "paid_message_price_changed", + "paid_star_count", + "passport_data", + "photo", + "pinned_message", + "poll", + "proximity_alert_triggered", + "quote", + "refunded_payment", + "reply_markup", + "reply_to_checklist_task_id", + "reply_to_message", + "reply_to_story", + "sender_boost_count", + "sender_business_bot", + "sender_chat", + "show_caption_above_media", + "sticker", + "story", + "successful_payment", + "suggested_post_approval_failed", + "suggested_post_approved", + "suggested_post_declined", + "suggested_post_info", + "suggested_post_paid", + "suggested_post_refunded", + "supergroup_chat_created", + "text", + "unique_gift", + "users_shared", + "venue", + "via_bot", + "video", + "video_chat_ended", + "video_chat_participants_invited", + "video_chat_scheduled", + "video_chat_started", + "video_note", + "voice", + "web_app_data", + "write_access_allowed", + ) + + def __init__( + self, + message_id: int, + date: dtm.datetime, + chat: Chat, + from_user: User | None = None, + reply_to_message: "Message | None" = None, + edit_date: dtm.datetime | None = None, + text: str | None = None, + entities: Sequence["MessageEntity"] | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, + audio: Audio | None = None, + document: Document | None = None, + game: Game | None = None, + photo: Sequence[PhotoSize] | None = None, + sticker: Sticker | None = None, + video: Video | None = None, + voice: Voice | None = None, + video_note: VideoNote | None = None, + new_chat_members: Sequence[User] | None = None, + caption: str | None = None, + contact: "Contact | None" = None, + location: "Location | None" = None, + venue: Venue | None = None, + left_chat_member: User | None = None, + new_chat_title: str | None = None, + new_chat_photo: Sequence[PhotoSize] | None = None, + delete_chat_photo: bool | None = None, + group_chat_created: bool | None = None, + supergroup_chat_created: bool | None = None, + channel_chat_created: bool | None = None, + migrate_to_chat_id: int | None = None, + migrate_from_chat_id: int | None = None, + pinned_message: MaybeInaccessibleMessage | None = None, + invoice: Invoice | None = None, + successful_payment: SuccessfulPayment | None = None, + author_signature: str | None = None, + media_group_id: str | None = None, + connected_website: str | None = None, + animation: Animation | None = None, + passport_data: PassportData | None = None, + poll: Poll | None = None, + reply_markup: InlineKeyboardMarkup | None = None, + dice: Dice | None = None, + via_bot: User | None = None, + proximity_alert_triggered: ProximityAlertTriggered | None = None, + sender_chat: Chat | None = None, + video_chat_started: VideoChatStarted | None = None, + video_chat_ended: VideoChatEnded | None = None, + video_chat_participants_invited: VideoChatParticipantsInvited | None = None, + message_auto_delete_timer_changed: MessageAutoDeleteTimerChanged | None = None, + video_chat_scheduled: VideoChatScheduled | None = None, + is_automatic_forward: bool | None = None, + has_protected_content: bool | None = None, + web_app_data: WebAppData | None = None, + is_topic_message: bool | None = None, + message_thread_id: int | None = None, + forum_topic_created: ForumTopicCreated | None = None, + forum_topic_closed: ForumTopicClosed | None = None, + forum_topic_reopened: ForumTopicReopened | None = None, + forum_topic_edited: ForumTopicEdited | None = None, + general_forum_topic_hidden: GeneralForumTopicHidden | None = None, + general_forum_topic_unhidden: GeneralForumTopicUnhidden | None = None, + write_access_allowed: WriteAccessAllowed | None = None, + has_media_spoiler: bool | None = None, + chat_shared: ChatShared | None = None, + story: Story | None = None, + giveaway: "Giveaway | None" = None, + giveaway_completed: "GiveawayCompleted | None" = None, + giveaway_created: "GiveawayCreated | None" = None, + giveaway_winners: "GiveawayWinners | None" = None, + users_shared: UsersShared | None = None, + link_preview_options: LinkPreviewOptions | None = None, + external_reply: "ExternalReplyInfo | None" = None, + quote: "TextQuote | None" = None, + forward_origin: "MessageOrigin | None" = None, + reply_to_story: Story | None = None, + boost_added: ChatBoostAdded | None = None, + sender_boost_count: int | None = None, + business_connection_id: str | None = None, + sender_business_bot: User | None = None, + is_from_offline: bool | None = None, + chat_background_set: ChatBackground | None = None, + effect_id: str | None = None, + show_caption_above_media: bool | None = None, + paid_media: PaidMediaInfo | None = None, + refunded_payment: RefundedPayment | None = None, + gift: GiftInfo | None = None, + unique_gift: UniqueGiftInfo | None = None, + paid_message_price_changed: PaidMessagePriceChanged | None = None, + paid_star_count: int | None = None, + direct_message_price_changed: DirectMessagePriceChanged | None = None, + checklist: Checklist | None = None, + checklist_tasks_done: ChecklistTasksDone | None = None, + checklist_tasks_added: ChecklistTasksAdded | None = None, + is_paid_post: bool | None = None, + direct_messages_topic: DirectMessagesTopic | None = None, + reply_to_checklist_task_id: int | None = None, + suggested_post_declined: "SuggestedPostDeclined | None" = None, + suggested_post_paid: "SuggestedPostPaid | None" = None, + suggested_post_refunded: "SuggestedPostRefunded | None" = None, + suggested_post_info: "SuggestedPostInfo | None" = None, + suggested_post_approved: "SuggestedPostApproved | None" = None, + suggested_post_approval_failed: "SuggestedPostApprovalFailed | None" = None, + gift_upgrade_sent: GiftInfo | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(chat=chat, message_id=message_id, date=date, api_kwargs=api_kwargs) + + with self._unfrozen(): + # Required + self.message_id: int = message_id + # Optionals + self.from_user: User | None = from_user + self.sender_chat: Chat | None = sender_chat + self.date: dtm.datetime = date + self.chat: Chat = chat + self.is_automatic_forward: bool | None = is_automatic_forward + self.reply_to_message: Message | None = reply_to_message + self.edit_date: dtm.datetime | None = edit_date + self.has_protected_content: bool | None = has_protected_content + self.text: str | None = text + self.entities: tuple[MessageEntity, ...] = parse_sequence_arg(entities) + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.audio: Audio | None = audio + self.game: Game | None = game + self.document: Document | None = document + self.photo: tuple[PhotoSize, ...] = parse_sequence_arg(photo) + self.sticker: Sticker | None = sticker + self.video: Video | None = video + self.voice: Voice | None = voice + self.video_note: VideoNote | None = video_note + self.caption: str | None = caption + self.contact: Contact | None = contact + self.location: Location | None = location + self.venue: Venue | None = venue + self.new_chat_members: tuple[User, ...] = parse_sequence_arg(new_chat_members) + self.left_chat_member: User | None = left_chat_member + self.new_chat_title: str | None = new_chat_title + self.new_chat_photo: tuple[PhotoSize, ...] = parse_sequence_arg(new_chat_photo) + self.delete_chat_photo: bool | None = bool(delete_chat_photo) + self.group_chat_created: bool | None = bool(group_chat_created) + self.supergroup_chat_created: bool | None = bool(supergroup_chat_created) + self.migrate_to_chat_id: int | None = migrate_to_chat_id + self.migrate_from_chat_id: int | None = migrate_from_chat_id + self.channel_chat_created: bool | None = bool(channel_chat_created) + self.message_auto_delete_timer_changed: MessageAutoDeleteTimerChanged | None = ( + message_auto_delete_timer_changed + ) + self.pinned_message: MaybeInaccessibleMessage | None = pinned_message + self.invoice: Invoice | None = invoice + self.successful_payment: SuccessfulPayment | None = successful_payment + self.connected_website: str | None = connected_website + self.author_signature: str | None = author_signature + self.media_group_id: str | None = media_group_id + self.animation: Animation | None = animation + self.passport_data: PassportData | None = passport_data + self.poll: Poll | None = poll + self.dice: Dice | None = dice + self.via_bot: User | None = via_bot + self.proximity_alert_triggered: ProximityAlertTriggered | None = ( + proximity_alert_triggered + ) + self.video_chat_scheduled: VideoChatScheduled | None = video_chat_scheduled + self.video_chat_started: VideoChatStarted | None = video_chat_started + self.video_chat_ended: VideoChatEnded | None = video_chat_ended + self.video_chat_participants_invited: VideoChatParticipantsInvited | None = ( + video_chat_participants_invited + ) + self.reply_markup: InlineKeyboardMarkup | None = reply_markup + self.web_app_data: WebAppData | None = web_app_data + self.is_topic_message: bool | None = is_topic_message + self.message_thread_id: int | None = message_thread_id + self.forum_topic_created: ForumTopicCreated | None = forum_topic_created + self.forum_topic_closed: ForumTopicClosed | None = forum_topic_closed + self.forum_topic_reopened: ForumTopicReopened | None = forum_topic_reopened + self.forum_topic_edited: ForumTopicEdited | None = forum_topic_edited + self.general_forum_topic_hidden: GeneralForumTopicHidden | None = ( + general_forum_topic_hidden + ) + self.general_forum_topic_unhidden: GeneralForumTopicUnhidden | None = ( + general_forum_topic_unhidden + ) + self.write_access_allowed: WriteAccessAllowed | None = write_access_allowed + self.has_media_spoiler: bool | None = has_media_spoiler + self.checklist: Checklist | None = checklist + self.users_shared: UsersShared | None = users_shared + self.chat_shared: ChatShared | None = chat_shared + self.story: Story | None = story + self.giveaway: Giveaway | None = giveaway + self.giveaway_completed: GiveawayCompleted | None = giveaway_completed + self.giveaway_created: GiveawayCreated | None = giveaway_created + self.giveaway_winners: GiveawayWinners | None = giveaway_winners + self.link_preview_options: LinkPreviewOptions | None = link_preview_options + self.external_reply: ExternalReplyInfo | None = external_reply + self.quote: TextQuote | None = quote + self.forward_origin: MessageOrigin | None = forward_origin + self.reply_to_story: Story | None = reply_to_story + self.boost_added: ChatBoostAdded | None = boost_added + self.sender_boost_count: int | None = sender_boost_count + self.business_connection_id: str | None = business_connection_id + self.sender_business_bot: User | None = sender_business_bot + self.is_from_offline: bool | None = is_from_offline + self.chat_background_set: ChatBackground | None = chat_background_set + self.checklist_tasks_done: ChecklistTasksDone | None = checklist_tasks_done + self.checklist_tasks_added: ChecklistTasksAdded | None = checklist_tasks_added + self.effect_id: str | None = effect_id + self.show_caption_above_media: bool | None = show_caption_above_media + self.paid_media: PaidMediaInfo | None = paid_media + self.refunded_payment: RefundedPayment | None = refunded_payment + self.gift: GiftInfo | None = gift + self.unique_gift: UniqueGiftInfo | None = unique_gift + self.paid_message_price_changed: PaidMessagePriceChanged | None = ( + paid_message_price_changed + ) + self.paid_star_count: int | None = paid_star_count + self.direct_message_price_changed: DirectMessagePriceChanged | None = ( + direct_message_price_changed + ) + self.is_paid_post: bool | None = is_paid_post + self.direct_messages_topic: DirectMessagesTopic | None = direct_messages_topic + self.reply_to_checklist_task_id: int | None = reply_to_checklist_task_id + self.suggested_post_declined: SuggestedPostDeclined | None = suggested_post_declined + self.suggested_post_paid: SuggestedPostPaid | None = suggested_post_paid + self.suggested_post_refunded: SuggestedPostRefunded | None = suggested_post_refunded + self.suggested_post_info: SuggestedPostInfo | None = suggested_post_info + self.suggested_post_approved: SuggestedPostApproved | None = suggested_post_approved + self.suggested_post_approval_failed: SuggestedPostApprovalFailed | None = ( + suggested_post_approval_failed + ) + self.gift_upgrade_sent: GiftInfo | None = gift_upgrade_sent + + self._effective_attachment = DEFAULT_NONE + + self._id_attrs = (self.message_id, self.chat) + + @property + def chat_id(self) -> int: + """:obj:`int`: Shortcut for :attr:`telegram.Chat.id` for :attr:`chat`.""" + return self.chat.id + + @property + def id(self) -> int: + """ + :obj:`int`: Shortcut for :attr:`message_id`. + + .. versionadded:: 20.0 + """ + return self.message_id + + @property + def link(self) -> str | None: + """:obj:`str`: Convenience property. If the chat of the message is not + a private chat or normal group, returns a t.me link of the message. + + .. versionchanged:: 20.3 + For messages that are replies or part of a forum topic, the link now points + to the corresponding thread view. + """ + if self.chat.type not in [Chat.PRIVATE, Chat.GROUP]: + # the else block gets rid of leading -100 for supergroups: + to_link = self.chat.username if self.chat.username else f"c/{str(self.chat.id)[4:]}" + baselink = f"https://t.me/{to_link}/{self.message_id}" + + # adds the thread for topics and replies + if (self.is_topic_message and self.message_thread_id) or self.reply_to_message: + baselink = f"{baselink}?thread={self.message_thread_id}" + return baselink + return None + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Message": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["from_user"] = de_json_optional(data.pop("from", None), User, bot) + data["sender_chat"] = de_json_optional(data.get("sender_chat"), Chat, bot) + data["entities"] = de_list_optional(data.get("entities"), MessageEntity, bot) + data["caption_entities"] = de_list_optional( + data.get("caption_entities"), MessageEntity, bot + ) + data["reply_to_message"] = de_json_optional(data.get("reply_to_message"), Message, bot) + data["edit_date"] = from_timestamp(data.get("edit_date"), tzinfo=loc_tzinfo) + data["audio"] = de_json_optional(data.get("audio"), Audio, bot) + data["document"] = de_json_optional(data.get("document"), Document, bot) + data["animation"] = de_json_optional(data.get("animation"), Animation, bot) + data["game"] = de_json_optional(data.get("game"), Game, bot) + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + data["story"] = de_json_optional(data.get("story"), Story, bot) + data["video"] = de_json_optional(data.get("video"), Video, bot) + data["voice"] = de_json_optional(data.get("voice"), Voice, bot) + data["video_note"] = de_json_optional(data.get("video_note"), VideoNote, bot) + data["contact"] = de_json_optional(data.get("contact"), Contact, bot) + data["location"] = de_json_optional(data.get("location"), Location, bot) + data["venue"] = de_json_optional(data.get("venue"), Venue, bot) + data["new_chat_members"] = de_list_optional(data.get("new_chat_members"), User, bot) + data["left_chat_member"] = de_json_optional(data.get("left_chat_member"), User, bot) + data["new_chat_photo"] = de_list_optional(data.get("new_chat_photo"), PhotoSize, bot) + data["message_auto_delete_timer_changed"] = de_json_optional( + data.get("message_auto_delete_timer_changed"), MessageAutoDeleteTimerChanged, bot + ) + data["pinned_message"] = de_json_optional( + data.get("pinned_message"), MaybeInaccessibleMessage, bot + ) + data["invoice"] = de_json_optional(data.get("invoice"), Invoice, bot) + data["successful_payment"] = de_json_optional( + data.get("successful_payment"), SuccessfulPayment, bot + ) + data["passport_data"] = de_json_optional(data.get("passport_data"), PassportData, bot) + data["poll"] = de_json_optional(data.get("poll"), Poll, bot) + data["dice"] = de_json_optional(data.get("dice"), Dice, bot) + data["via_bot"] = de_json_optional(data.get("via_bot"), User, bot) + data["proximity_alert_triggered"] = de_json_optional( + data.get("proximity_alert_triggered"), ProximityAlertTriggered, bot + ) + data["reply_markup"] = de_json_optional( + data.get("reply_markup"), InlineKeyboardMarkup, bot + ) + data["video_chat_scheduled"] = de_json_optional( + data.get("video_chat_scheduled"), VideoChatScheduled, bot + ) + data["video_chat_started"] = de_json_optional( + data.get("video_chat_started"), VideoChatStarted, bot + ) + data["video_chat_ended"] = de_json_optional( + data.get("video_chat_ended"), VideoChatEnded, bot + ) + data["video_chat_participants_invited"] = de_json_optional( + data.get("video_chat_participants_invited"), VideoChatParticipantsInvited, bot + ) + data["web_app_data"] = de_json_optional(data.get("web_app_data"), WebAppData, bot) + data["forum_topic_closed"] = de_json_optional( + data.get("forum_topic_closed"), ForumTopicClosed, bot + ) + data["forum_topic_created"] = de_json_optional( + data.get("forum_topic_created"), ForumTopicCreated, bot + ) + data["forum_topic_reopened"] = de_json_optional( + data.get("forum_topic_reopened"), ForumTopicReopened, bot + ) + data["forum_topic_edited"] = de_json_optional( + data.get("forum_topic_edited"), ForumTopicEdited, bot + ) + data["general_forum_topic_hidden"] = de_json_optional( + data.get("general_forum_topic_hidden"), GeneralForumTopicHidden, bot + ) + data["general_forum_topic_unhidden"] = de_json_optional( + data.get("general_forum_topic_unhidden"), GeneralForumTopicUnhidden, bot + ) + data["write_access_allowed"] = de_json_optional( + data.get("write_access_allowed"), WriteAccessAllowed, bot + ) + data["users_shared"] = de_json_optional(data.get("users_shared"), UsersShared, bot) + data["chat_shared"] = de_json_optional(data.get("chat_shared"), ChatShared, bot) + data["chat_background_set"] = de_json_optional( + data.get("chat_background_set"), ChatBackground, bot + ) + data["paid_media"] = de_json_optional(data.get("paid_media"), PaidMediaInfo, bot) + data["refunded_payment"] = de_json_optional( + data.get("refunded_payment"), RefundedPayment, bot + ) + data["gift"] = de_json_optional(data.get("gift"), GiftInfo, bot) + data["unique_gift"] = de_json_optional(data.get("unique_gift"), UniqueGiftInfo, bot) + data["paid_message_price_changed"] = de_json_optional( + data.get("paid_message_price_changed"), PaidMessagePriceChanged, bot + ) + + # Unfortunately, this needs to be here due to cyclic imports + from telegram._giveaway import ( # pylint: disable=C0415 # noqa: PLC0415 + Giveaway, + GiveawayCompleted, + GiveawayCreated, + GiveawayWinners, + ) + from telegram._messageorigin import ( # pylint: disable=C0415 # noqa: PLC0415 + MessageOrigin, + ) + from telegram._reply import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + ExternalReplyInfo, + TextQuote, + ) + from telegram._suggestedpost import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + SuggestedPostApprovalFailed, + SuggestedPostApproved, + SuggestedPostDeclined, + SuggestedPostInfo, + SuggestedPostPaid, + SuggestedPostRefunded, + ) + + data["giveaway"] = de_json_optional(data.get("giveaway"), Giveaway, bot) + data["giveaway_completed"] = de_json_optional( + data.get("giveaway_completed"), GiveawayCompleted, bot + ) + data["giveaway_created"] = de_json_optional( + data.get("giveaway_created"), GiveawayCreated, bot + ) + data["giveaway_winners"] = de_json_optional( + data.get("giveaway_winners"), GiveawayWinners, bot + ) + data["link_preview_options"] = de_json_optional( + data.get("link_preview_options"), LinkPreviewOptions, bot + ) + data["external_reply"] = de_json_optional( + data.get("external_reply"), ExternalReplyInfo, bot + ) + data["quote"] = de_json_optional(data.get("quote"), TextQuote, bot) + data["forward_origin"] = de_json_optional(data.get("forward_origin"), MessageOrigin, bot) + data["reply_to_story"] = de_json_optional(data.get("reply_to_story"), Story, bot) + data["boost_added"] = de_json_optional(data.get("boost_added"), ChatBoostAdded, bot) + data["sender_business_bot"] = de_json_optional(data.get("sender_business_bot"), User, bot) + data["direct_message_price_changed"] = de_json_optional( + data.get("direct_message_price_changed"), DirectMessagePriceChanged, bot + ) + data["checklist"] = de_json_optional(data.get("checklist"), Checklist, bot) + data["checklist_tasks_done"] = de_json_optional( + data.get("checklist_tasks_done"), ChecklistTasksDone, bot + ) + data["checklist_tasks_added"] = de_json_optional( + data.get("checklist_tasks_added"), ChecklistTasksAdded, bot + ) + data["direct_messages_topic"] = de_json_optional( + data.get("direct_messages_topic"), DirectMessagesTopic, bot + ) + data["suggested_post_declined"] = de_json_optional( + data.get("suggested_post_declined"), SuggestedPostDeclined, bot + ) + data["suggested_post_paid"] = de_json_optional( + data.get("suggested_post_paid"), SuggestedPostPaid, bot + ) + data["suggested_post_refunded"] = de_json_optional( + data.get("suggested_post_refunded"), SuggestedPostRefunded, bot + ) + data["suggested_post_info"] = de_json_optional( + data.get("suggested_post_info"), SuggestedPostInfo, bot + ) + data["suggested_post_approved"] = de_json_optional( + data.get("suggested_post_approved"), SuggestedPostApproved, bot + ) + data["suggested_post_approval_failed"] = de_json_optional( + data.get("suggested_post_approval_failed"), SuggestedPostApprovalFailed, bot + ) + data["gift_upgrade_sent"] = de_json_optional(data.get("gift_upgrade_sent"), GiftInfo, bot) + + api_kwargs = {} + # This is a deprecated field that TG still returns for backwards compatibility + # Let's filter it out to speed up the de-json process + for key in ( + "user_shared", + "forward_from", + "forward_from_chat", + "forward_from_message_id", + "forward_signature", + "forward_sender_name", + "forward_date", + ): + if entry := data.get(key): + api_kwargs = {key: entry} + + return super()._de_json( # type: ignore[return-value] + data=data, bot=bot, api_kwargs=api_kwargs + ) + + @property + def effective_attachment( + self, + ) -> ( + Animation + | Audio + | Contact + | Dice + | Document + | Game + | Invoice + | Location + | PassportData + | Sequence[PhotoSize] + | PaidMediaInfo + | Poll + | Sticker + | Story + | SuccessfulPayment + | Venue + | Video + | VideoNote + | Voice + | None + ): + """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` + * :class:`telegram.Contact` + * :class:`telegram.Document` + * :class:`telegram.Animation` + * :class:`telegram.Game` + * :class:`telegram.Invoice` + * :class:`telegram.Location` + * :class:`telegram.PassportData` + * list[:class:`telegram.PhotoSize`] + * :class:`telegram.PaidMediaInfo` + * :class:`telegram.Poll` + * :class:`telegram.Sticker` + * :class:`telegram.Story` + * :class:`telegram.SuccessfulPayment` + * :class:`telegram.Venue` + * :class:`telegram.Video` + * :class:`telegram.VideoNote` + * :class:`telegram.Voice` + + Otherwise :obj:`None` is returned. + + .. seealso:: :wiki:`Working with Files and Media ` + + .. versionchanged:: 20.0 + :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 + + 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 + + return self._effective_attachment # type: ignore[return-value] + + def _do_quote( + self, do_quote: bool | None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE + ) -> ReplyParameters | None: + """Modify kwargs for replying with or without quoting.""" + # `Defaults` handling for allow_sending_without_reply is not necessary, as + # `ReplyParameters` have special defaults handling in (ExtBot)._insert_defaults + if do_quote is not None: + if do_quote: + return ReplyParameters( + self.message_id, allow_sending_without_reply=allow_sending_without_reply + ) + + else: + # Unfortunately we need some ExtBot logic here because it's hard to move shortcut + # logic into ExtBot + if hasattr(self.get_bot(), "defaults") and self.get_bot().defaults: # type: ignore + default_quote = self.get_bot().defaults.do_quote # type: ignore[attr-defined] + else: + default_quote = None + if (default_quote is None and self.chat.type != Chat.PRIVATE) or default_quote: + return ReplyParameters( + self.message_id, allow_sending_without_reply=allow_sending_without_reply + ) + + return None + + def compute_quote_position_and_entities( + self, quote: str, index: int | None = None + ) -> tuple[int, tuple[MessageEntity, ...] | None]: + """ + Use this function to compute position and entities of a quote in the message text or + caption. Useful for filling the parameters + :paramref:`~telegram.ReplyParameters.quote_position` and + :paramref:`~telegram.ReplyParameters.quote_entities` of :class:`telegram.ReplyParameters` + when replying to a message. + + Example: + + Given a message with the text ``"Hello, world! Hello, world!"``, the following code + will return the position and entities of the second occurrence of ``"Hello, world!"``. + + .. code-block:: python + + message.compute_quote_position_and_entities("Hello, world!", 1) + + .. versionadded:: 20.8 + + Args: + quote (:obj:`str`): Part of the message which is to be quoted. This is + expected to have plain text without formatting entities. + index (:obj:`int`, optional): 0-based index of the occurrence of the quote in the + message. If not specified, the first occurrence is used. + + Returns: + tuple[:obj:`int`, :obj:`None` | tuple[:class:`~telegram.MessageEntity`, ...]]: On + success, a tuple containing information about quote position and entities is returned. + + Raises: + RuntimeError: If the message has neither :attr:`text` nor :attr:`caption`. + ValueError: If the requested index of quote doesn't exist in the message. + """ + if not (text := (self.text or self.caption)): + raise RuntimeError("This message has neither text nor caption.") + + # Telegram wants the position in UTF-16 code units, so we have to calculate in that space + utf16_text = text.encode(TextEncoding.UTF_16_LE) + utf16_quote = quote.encode(TextEncoding.UTF_16_LE) + effective_index = index or 0 + + matches = list(re.finditer(re.escape(utf16_quote), utf16_text)) + if (length := len(matches)) < effective_index + 1: + raise ValueError( + f"You requested the {index}-th occurrence of '{quote}', but this text appears " + f"only {length} times." + ) + + position = len(utf16_text[: matches[effective_index].start()]) // 2 + length = len(utf16_quote) // 2 + end_position = position + length + + entities = [] + for entity in self.entities or self.caption_entities: + if position <= entity.offset + entity.length and entity.offset <= end_position: + # shift the offset by the position of the quote + offset = max(0, entity.offset - position) + # trim the entity length to the length of the overlap with the quote + e_length = min(end_position, entity.offset + entity.length) - max( + position, entity.offset + ) + if e_length <= 0: + continue + + # create a new entity with the correct offset and length + # looping over slots rather manually accessing the attributes + # is more future-proof + kwargs = {attr: getattr(entity, attr) for attr in entity.__slots__} + kwargs["offset"] = offset + kwargs["length"] = e_length + entities.append(MessageEntity(**kwargs)) + + return position, tuple(entities) or None + + def build_reply_arguments( + self, + quote: str | None = None, + quote_index: int | None = None, + target_chat_id: int | (str | None) = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + ) -> _ReplyKwargs: + """ + Builds a dictionary with the keys ``chat_id`` and ``reply_parameters``. This dictionary can + be used to reply to a message with the given quote and target chat. + + Examples: + + Usage with :meth:`telegram.Bot.send_message`: + + .. code-block:: python + + await bot.send_message( + text="This is a reply", + **message.build_reply_arguments(quote="Quoted Text") + ) + + Usage with :meth:`reply_text`, replying in the same chat: + + .. code-block:: python + + await message.reply_text( + "This is a reply", + do_quote=message.build_reply_arguments(quote="Quoted Text") + ) + + Usage with :meth:`reply_text`, replying in a different chat: + + .. code-block:: python + + await message.reply_text( + "This is a reply", + do_quote=message.build_reply_arguments( + quote="Quoted Text", + target_chat_id=-100123456789 + ) + ) + + .. versionadded:: 20.8 + + Args: + quote (:obj:`str`, optional): Passed in :meth:`compute_quote_position_and_entities` + as parameter :paramref:`~compute_quote_position_and_entities.quote` to compute + quote entities. Defaults to :obj:`None`. + quote_index (:obj:`int`, optional): Passed in + :meth:`compute_quote_position_and_entities` as parameter + :paramref:`~compute_quote_position_and_entities.quote_index` to compute quote + position. Defaults to :obj:`None`. + target_chat_id (:obj:`int` | :obj:`str`, optional): |chat_id_channel| + Defaults to :attr:`chat_id`. + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Will be applied only if the reply happens in the same chat and forum topic. + message_thread_id (:obj:`int`, optional): |message_thread_id| + + Returns: + :obj:`dict`: + """ + target_chat_is_self = target_chat_id in (None, self.chat_id, f"@{self.chat.username}") + + if target_chat_is_self and message_thread_id in ( + None, + self.message_thread_id, + ): + # defaults handling will take place in `Bot._insert_defaults` + effective_aswr: ODVInput[bool] = allow_sending_without_reply + else: + effective_aswr = None + + quote_position, quote_entities = ( + self.compute_quote_position_and_entities(quote, quote_index) if quote else (None, None) + ) + return { # type: ignore[typeddict-item] + "reply_parameters": ReplyParameters( + chat_id=None if target_chat_is_self else self.chat_id, + message_id=self.message_id, + quote=quote, + quote_position=quote_position, + quote_entities=quote_entities, + allow_sending_without_reply=effective_aswr, + ), + "chat_id": target_chat_id or self.chat_id, + } + + async def _parse_quote_arguments( + self, + do_quote: bool | _ReplyKwargs | None, + reply_to_message_id: int | None, + reply_parameters: "ReplyParameters | None", + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + ) -> tuple[str | int, ReplyParameters]: + if allow_sending_without_reply is not DEFAULT_NONE and reply_parameters is not None: + raise ValueError( + "`allow_sending_without_reply` and `reply_parameters` are mutually exclusive." + ) + if reply_to_message_id is not None and reply_parameters is not None: + raise ValueError( + "`reply_to_message_id` and `reply_parameters` are mutually exclusive." + ) + + chat_id: str | int = self.chat_id + + # reply_parameters and reply_to_message_id overrule the do_quote parameter + if reply_parameters is not None: + effective_reply_parameters = reply_parameters + elif reply_to_message_id is not None: + effective_reply_parameters = ReplyParameters( + message_id=reply_to_message_id, + allow_sending_without_reply=allow_sending_without_reply, + ) + elif isinstance(do_quote, dict): + if allow_sending_without_reply is not DEFAULT_NONE: + raise ValueError( + "`allow_sending_without_reply` and `dict`-value input for `do_quote` are " + "mutually exclusive." + ) + + effective_reply_parameters = do_quote["reply_parameters"] + chat_id = do_quote["chat_id"] + else: + effective_reply_parameters = self._do_quote(do_quote, allow_sending_without_reply) + + return chat_id, effective_reply_parameters + + def _parse_message_thread_id( + self, + chat_id: str | int, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + ) -> int | None: + # values set by user have the highest priority + if not isinstance(message_thread_id, DefaultValue): + return message_thread_id + + # self.message_thread_id can be used for send_*.param.message_thread_id only if the + # thread is a forum topic (in supergroups or private chats). It does not work if the + # thread is a chain of replies to a message in a normal group. In that case, + # self.message_thread_id is just the message_id of the first message in the chain. + if not self.is_topic_message: + return None + + # Setting message_thread_id=self.message_thread_id only makes sense if we're replying in + # the same chat. + return self.message_thread_id if chat_id in {self.chat_id, self.chat.username} else None + + def _extract_direct_messages_topic_id(self) -> int | None: + """Return the topic id of the direct messages chat, if it is present.""" + return self.direct_messages_topic.topic_id if self.direct_messages_topic else None + + async def reply_text( + self, + text: str, + parse_mode: ODVInput[str] = DEFAULT_NONE, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + disable_web_page_preview: bool | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_message( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_message`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_message( + chat_id=chat_id, + text=text, + parse_mode=parse_mode, + disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + entities=entities, + protect_content=protect_content, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_text_draft( + self, + draft_id: int, + text: str, + parse_mode: ODVInput[str] = DEFAULT_NONE, + entities: Sequence["MessageEntity"] | None = None, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.send_message_draft( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_message_draft`. + + Note: + |reply_same_thread| + + .. versionadded:: 22.6 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + message_thread_id = self._parse_message_thread_id(self.chat_id, message_thread_id) + return await self.get_bot().send_message_draft( + chat_id=self.chat_id, + draft_id=draft_id, + text=text, + parse_mode=parse_mode, + entities=entities, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def reply_markdown( + self, + text: str, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + disable_web_page_preview: bool | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_message( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + parse_mode=ParseMode.MARKDOWN, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + Sends a message with Markdown version 1 formatting. + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_message`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Note: + :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by + Telegram for backward compatibility. You should use :meth:`reply_markdown_v2` instead. + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_message( + chat_id=chat_id, + text=text, + parse_mode=ParseMode.MARKDOWN, + disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + entities=entities, + protect_content=protect_content, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_markdown_v2( + self, + text: str, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + disable_web_page_preview: bool | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_message( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + parse_mode=ParseMode.MARKDOWN_V2, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + business_connection_id=self.business_connection_id, + *args, + **kwargs, + ) + + Sends a message with markdown version 2 formatting. + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_message`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_message( + chat_id=chat_id, + text=text, + parse_mode=ParseMode.MARKDOWN_V2, + disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + entities=entities, + protect_content=protect_content, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_html( + self, + text: str, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + disable_web_page_preview: bool | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_message( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + parse_mode=ParseMode.HTML, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + business_connection_id=self.business_connection_id, + *args, + **kwargs, + ) + + Sends a message with HTML formatting. + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_message`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_message( + chat_id=chat_id, + text=text, + parse_mode=ParseMode.HTML, + disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + entities=entities, + protect_content=protect_content, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_media_group( + self, + media: Sequence[ + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + ], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + ) -> tuple["Message", ...]: + """Shortcut for:: + + await bot.send_media_group( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_media_group`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + tuple[:class:`telegram.Message`]: An array of the sent Messages. + + Raises: + :class:`telegram.error.TelegramError` + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_media_group( + chat_id=chat_id, + media=media, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + protect_content=protect_content, + message_thread_id=message_thread_id, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + ) + + async def reply_photo( + self, + photo: "FileInput | PhotoSize", + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + has_spoiler: bool | None = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_photo( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_photo`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_photo( + chat_id=chat_id, + photo=photo, + caption=caption, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + parse_mode=parse_mode, + caption_entities=caption_entities, + filename=filename, + protect_content=protect_content, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + has_spoiler=has_spoiler, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_audio( + self, + audio: "FileInput | Audio", + duration: TimePeriod | None = None, + performer: str | None = None, + title: str | None = None, + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_audio( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_audio`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_audio( + chat_id=chat_id, + audio=audio, + duration=duration, + performer=performer, + title=title, + caption=caption, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + parse_mode=parse_mode, + caption_entities=caption_entities, + filename=filename, + protect_content=protect_content, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + thumbnail=thumbnail, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_document( + self, + document: "FileInput | Document", + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + disable_content_type_detection: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_document( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_document`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_document( + chat_id=chat_id, + document=document, + filename=filename, + caption=caption, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + parse_mode=parse_mode, + api_kwargs=api_kwargs, + disable_content_type_detection=disable_content_type_detection, + caption_entities=caption_entities, + protect_content=protect_content, + message_thread_id=message_thread_id, + thumbnail=thumbnail, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_animation( + self, + animation: "FileInput | Animation", + duration: TimePeriod | None = None, + width: int | None = None, + height: int | None = None, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + caption_entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_animation( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_animation`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_animation( + chat_id=chat_id, + animation=animation, + duration=duration, + width=width, + height=height, + caption=caption, + parse_mode=parse_mode, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + caption_entities=caption_entities, + filename=filename, + protect_content=protect_content, + message_thread_id=message_thread_id, + has_spoiler=has_spoiler, + thumbnail=thumbnail, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_sticker( + self, + sticker: "FileInput | Sticker", + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + emoji: str | None = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_sticker( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_sticker`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_sticker( + chat_id=chat_id, + sticker=sticker, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + protect_content=protect_content, + message_thread_id=message_thread_id, + emoji=emoji, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_video( + self, + video: "FileInput | Video", + duration: TimePeriod | None = None, + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + width: int | None = None, + height: int | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + supports_streaming: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + cover: "FileInput | None" = None, + start_timestamp: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_video( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_video`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_video( + chat_id=chat_id, + video=video, + duration=duration, + caption=caption, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + width=width, + height=height, + parse_mode=parse_mode, + supports_streaming=supports_streaming, + api_kwargs=api_kwargs, + caption_entities=caption_entities, + filename=filename, + protect_content=protect_content, + message_thread_id=message_thread_id, + has_spoiler=has_spoiler, + thumbnail=thumbnail, + cover=cover, + start_timestamp=start_timestamp, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_video_note( + self, + video_note: "FileInput | VideoNote", + duration: TimePeriod | None = None, + length: int | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_video_note( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_video_note`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_video_note( + chat_id=chat_id, + video_note=video_note, + duration=duration, + length=length, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + filename=filename, + protect_content=protect_content, + message_thread_id=message_thread_id, + thumbnail=thumbnail, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_voice( + self, + voice: "FileInput | Voice", + duration: TimePeriod | None = None, + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_voice( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_voice`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_voice( + chat_id=chat_id, + voice=voice, + duration=duration, + caption=caption, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + parse_mode=parse_mode, + api_kwargs=api_kwargs, + caption_entities=caption_entities, + filename=filename, + protect_content=protect_content, + message_thread_id=message_thread_id, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_location( + self, + latitude: float | None = None, + longitude: float | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + live_period: TimePeriod | None = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + location: "Location | None" = None, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_location( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_location`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_location( + chat_id=chat_id, + latitude=latitude, + longitude=longitude, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + location=location, + live_period=live_period, + api_kwargs=api_kwargs, + horizontal_accuracy=horizontal_accuracy, + heading=heading, + proximity_alert_radius=proximity_alert_radius, + protect_content=protect_content, + message_thread_id=message_thread_id, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_venue( + self, + latitude: float | None = None, + longitude: float | None = None, + title: str | None = None, + address: str | None = None, + foursquare_id: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + venue: "Venue | None" = None, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_venue( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_venue`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_venue( + chat_id=chat_id, + latitude=latitude, + longitude=longitude, + title=title, + address=address, + foursquare_id=foursquare_id, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + venue=venue, + foursquare_type=foursquare_type, + api_kwargs=api_kwargs, + google_place_id=google_place_id, + google_place_type=google_place_type, + protect_content=protect_content, + message_thread_id=message_thread_id, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_contact( + self, + phone_number: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + vcard: str | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + contact: "Contact | None" = None, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_contact( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_contact`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_contact( + chat_id=chat_id, + phone_number=phone_number, + first_name=first_name, + last_name=last_name, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + contact=contact, + vcard=vcard, + api_kwargs=api_kwargs, + protect_content=protect_content, + message_thread_id=message_thread_id, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + allow_paid_broadcast=allow_paid_broadcast, + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_poll( + self, + question: str, + options: Sequence["str | InputPollOption"], + is_anonymous: bool | None = None, + type: str | None = None, # pylint: disable=redefined-builtin + allows_multiple_answers: bool | None = None, + correct_option_id: CorrectOptionID | None = None, + is_closed: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + explanation: str | None = None, + explanation_parse_mode: ODVInput[str] = DEFAULT_NONE, + open_period: TimePeriod | None = None, + close_date: int | (dtm.datetime | None) = None, + explanation_entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + question_parse_mode: ODVInput[str] = DEFAULT_NONE, + question_entities: Sequence["MessageEntity"] | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_poll( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_poll`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_poll( + chat_id=chat_id, + question=question, + options=options, + is_anonymous=is_anonymous, + type=type, + allows_multiple_answers=allows_multiple_answers, + correct_option_id=correct_option_id, + is_closed=is_closed, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + explanation=explanation, + explanation_parse_mode=explanation_parse_mode, + open_period=open_period, + close_date=close_date, + api_kwargs=api_kwargs, + explanation_entities=explanation_entities, + protect_content=protect_content, + message_thread_id=message_thread_id, + business_connection_id=self.business_connection_id, + question_parse_mode=question_parse_mode, + question_entities=question_entities, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + ) + + async def reply_dice( + self, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + emoji: str | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_dice( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_dice`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_dice( + chat_id=chat_id, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + emoji=emoji, + api_kwargs=api_kwargs, + protect_content=protect_content, + message_thread_id=message_thread_id, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def reply_checklist( + self, + checklist: InputChecklist, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_effect_id: str | None = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_checklist( + business_connection_id=self.business_connection_id, + chat_id=update.effective_message.chat_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_checklist`. + + .. versionadded:: 22.3 + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + return await self.get_bot().send_checklist( + business_connection_id=self.business_connection_id, + chat_id=chat_id, # type: ignore[arg-type] + checklist=checklist, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + protect_content=protect_content, + message_effect_id=message_effect_id, + ) + + async def reply_chat_action( + self, + action: str, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.send_chat_action( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_chat_action`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionadded:: 13.2 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().send_chat_action( + chat_id=self.chat_id, + message_thread_id=self._parse_message_thread_id(self.chat_id, message_thread_id), + action=action, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + business_connection_id=self.business_connection_id, + ) + + async def reply_game( + self, + game_short_name: str, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "InlineKeyboardMarkup | None" = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_game( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_game`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + .. versionadded:: 13.2 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_game( + chat_id=chat_id, # type: ignore[arg-type] + game_short_name=game_short_name, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + protect_content=protect_content, + message_thread_id=message_thread_id, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + ) + + async def reply_invoice( + self, + title: str, + description: str, + payload: str, + currency: str, + prices: Sequence["LabeledPrice"], + provider_token: str | None = None, + start_parameter: str | None = None, + photo_url: str | None = None, + photo_size: int | None = None, + photo_width: int | None = None, + photo_height: int | None = None, + need_name: bool | None = None, + need_phone_number: bool | None = None, + need_email: bool | None = None, + need_shipping_address: bool | None = None, + is_flexible: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "InlineKeyboardMarkup | None" = None, + provider_data: str | (object | None) = None, + send_phone_number_to_provider: bool | None = None, + send_email_to_provider: bool | None = None, + max_tip_amount: int | None = None, + suggested_tip_amounts: Sequence[int] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_invoice( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_invoice`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Warning: + As of API 5.2 :paramref:`start_parameter ` + is an optional argument and therefore the + order of the arguments had to be changed. Use keyword arguments to make sure that the + arguments are passed correctly. + + .. versionadded:: 13.2 + + .. versionchanged:: 13.5 + As of Bot API 5.2, the parameter + :paramref:`start_parameter ` is optional. + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_invoice( + chat_id=chat_id, + title=title, + description=description, + payload=payload, + provider_token=provider_token, + currency=currency, + prices=prices, + start_parameter=start_parameter, + photo_url=photo_url, + photo_size=photo_size, + photo_width=photo_width, + photo_height=photo_height, + need_name=need_name, + need_phone_number=need_phone_number, + need_email=need_email, + need_shipping_address=need_shipping_address, + is_flexible=is_flexible, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + provider_data=provider_data, + send_phone_number_to_provider=send_phone_number_to_provider, + send_email_to_provider=send_email_to_provider, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + max_tip_amount=max_tip_amount, + suggested_tip_amounts=suggested_tip_amounts, + protect_content=protect_content, + message_thread_id=message_thread_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + + async def forward( + self, + chat_id: int | str, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + video_start_timestamp: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.forward_message( + from_chat_id=update.effective_message.chat_id, + message_id=update.effective_message.message_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.forward_message`. + + Note: + Since the release of Bot API 5.5 it can be impossible to forward messages from + some chats. Use the attributes :attr:`telegram.Message.has_protected_content` and + :attr:`telegram.ChatFullInfo.has_protected_content` to check this. + + As a workaround, it is still possible to use :meth:`copy`. However, this + behaviour is undocumented and might be changed by Telegram. + + Returns: + :class:`telegram.Message`: On success, instance representing the message forwarded. + + """ + return await self.get_bot().forward_message( + chat_id=chat_id, + from_chat_id=self.chat_id, + message_id=self.message_id, + video_start_timestamp=video_start_timestamp, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + suggested_post_parameters=suggested_post_parameters, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + message_effect_id=message_effect_id, + ) + + async def copy( + self, + chat_id: int | str, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "MessageId": + """Shortcut for:: + + await bot.copy_message( + chat_id=chat_id, + from_chat_id=update.effective_message.chat_id, + message_id=update.effective_message.message_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.copy_message`. + + Returns: + :class:`telegram.MessageId`: On success, returns the MessageId of the sent message. + + """ + return await self.get_bot().copy_message( + chat_id=chat_id, + from_chat_id=self.chat_id, + message_id=self.message_id, + caption=caption, + video_start_timestamp=video_start_timestamp, + parse_mode=parse_mode, + caption_entities=caption_entities, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + allow_sending_without_reply=allow_sending_without_reply, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + protect_content=protect_content, + message_thread_id=message_thread_id, + show_caption_above_media=show_caption_above_media, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, + ) + + async def reply_copy( + self, + from_chat_id: str | int, + message_id: int, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "MessageId": + """Shortcut for:: + + await bot.copy_message( + chat_id=message.chat.id, + message_thread_id=update.effective_message.message_thread_id, + message_id=message_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.copy_message`. + + .. versionchanged:: 21.1 + |reply_same_thread| + + .. versionchanged:: 22.0 + |quote_removed| + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.MessageId`: On success, returns the MessageId of the sent message. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().copy_message( + chat_id=chat_id, + from_chat_id=from_chat_id, + message_id=message_id, + caption=caption, + video_start_timestamp=video_start_timestamp, + parse_mode=parse_mode, + caption_entities=caption_entities, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + protect_content=protect_content, + message_thread_id=message_thread_id, + show_caption_above_media=show_caption_above_media, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, + ) + + async def reply_paid_media( + self, + star_count: int, + media: Sequence["InputPaidMedia"], + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + payload: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_paid_media( + chat_id=message.chat.id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=message.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_paid_media`. + + .. versionadded:: 21.7 + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + Returns: + :class:`telegram.Message`: On success, the sent message is returned. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_paid_media( + chat_id=chat_id, + caption=caption, + star_count=star_count, + media=media, + payload=payload, + business_connection_id=self.business_connection_id, + parse_mode=parse_mode, + caption_entities=caption_entities, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + protect_content=protect_content, + show_caption_above_media=show_caption_above_media, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + message_thread_id=message_thread_id, + ) + + async def edit_text( + self, + text: str, + parse_mode: ODVInput[str] = DEFAULT_NONE, + reply_markup: "InlineKeyboardMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + *, + disable_web_page_preview: bool | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": + """Shortcut for:: + + await bot.edit_message_text( + 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`. + + Note: + You can only edit messages that the bot sent itself (i.e. of the ``bot.send_*`` family + 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. + + """ + return await self.get_bot().edit_message_text( + chat_id=self.chat_id, + message_id=self.message_id, + text=text, + parse_mode=parse_mode, + disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + entities=entities, + inline_message_id=None, + business_connection_id=self.business_connection_id, + ) + + async def edit_caption( + self, + caption: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": + """Shortcut for:: + + await bot.edit_message_caption( + 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_caption`. + + Note: + You can only edit messages that the bot sent itself (i.e. of the ``bot.send_*`` family + 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. + + """ + return await self.get_bot().edit_message_caption( + chat_id=self.chat_id, + message_id=self.message_id, + caption=caption, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + parse_mode=parse_mode, + api_kwargs=api_kwargs, + 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_checklist( + self, + checklist: InputChecklist, + reply_markup: "InlineKeyboardMarkup | None" = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.edit_message_checklist( + business_connection_id=message.business_connection_id, + chat_id=message.chat_id, + message_id=message.message_id, + *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.edit_message_checklist`. + + .. versionadded:: 22.3 + + Note: + You can only edit messages that the bot sent itself (i.e. of the ``bot.send_*`` family + 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. + + Returns: + :class:`telegram.Message`: On success, the edited Message is returned. + + """ + return await self.get_bot().edit_message_checklist( + business_connection_id=self.business_connection_id, + chat_id=self.chat_id, + message_id=self.message_id, + checklist=checklist, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def edit_media( + self, + media: "InputMedia", + reply_markup: "InlineKeyboardMarkup | None" = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": + """Shortcut for:: + + await bot.edit_message_media( + 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_media`. + + Note: + You can only edit messages that the bot sent itself(i.e. of the ``bot.send_*`` family + 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. + + """ + return await self.get_bot().edit_message_media( + media=media, + chat_id=self.chat_id, + message_id=self.message_id, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + inline_message_id=None, + business_connection_id=self.business_connection_id, + ) + + async def edit_reply_markup( + self, + reply_markup: "InlineKeyboardMarkup | None" = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": + """Shortcut for:: + + await bot.edit_message_reply_markup( + 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_reply_markup`. + + Note: + You can only edit messages that the bot sent itself (i.e. of the ``bot.send_*`` family + 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. + """ + return await self.get_bot().edit_message_reply_markup( + chat_id=self.chat_id, + message_id=self.message_id, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + inline_message_id=None, + business_connection_id=self.business_connection_id, + ) + + async def edit_live_location( + self, + latitude: float | None = None, + longitude: float | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, + live_period: TimePeriod | None = None, + *, + location: "Location | None" = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": + """Shortcut for:: + + await bot.edit_message_live_location( + 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_live_location`. + + Note: + You can only edit messages that the bot sent itself (i.e. of the ``bot.send_*`` family + 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. + """ + return await self.get_bot().edit_message_live_location( + chat_id=self.chat_id, + message_id=self.message_id, + latitude=latitude, + longitude=longitude, + location=location, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + horizontal_accuracy=horizontal_accuracy, + heading=heading, + 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( + self, + reply_markup: "InlineKeyboardMarkup | None" = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": + """Shortcut for:: + + await bot.stop_message_live_location( + 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_message_live_location`. + + Note: + You can only edit messages that the bot sent itself (i.e. of the ``bot.send_*`` family + 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. + """ + return await self.get_bot().stop_message_live_location( + chat_id=self.chat_id, + message_id=self.message_id, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + inline_message_id=None, + business_connection_id=self.business_connection_id, + ) + + async def set_game_score( + self, + user_id: int, + score: int, + force: bool | None = None, + disable_edit_message: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message | bool": + """Shortcut for:: + + await bot.set_game_score( + chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.set_game_score`. + + Note: + You can only edit messages that the bot sent itself (i.e. of the ``bot.send_*`` family + 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. + + Returns: + :class:`telegram.Message`: On success, if edited message is sent by the bot, the + edited Message is returned, otherwise :obj:`True` is returned. + """ + return await self.get_bot().set_game_score( + chat_id=self.chat_id, + message_id=self.message_id, + user_id=user_id, + score=score, + force=force, + disable_edit_message=disable_edit_message, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + inline_message_id=None, + ) + + async def get_game_high_scores( + self, + user_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> tuple["GameHighScore", ...]: + """Shortcut for:: + + await bot.get_game_high_scores( + chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.get_game_high_scores`. + + Note: + You can only edit messages that the bot sent itself (i.e. of the ``bot.send_*`` family + 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. + + Returns: + tuple[:class:`telegram.GameHighScore`] + """ + return await self.get_bot().get_game_high_scores( + chat_id=self.chat_id, + message_id=self.message_id, + user_id=user_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + inline_message_id=None, + ) + + async def delete( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for either:: + + await bot.delete_message( + chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs + ) + + or:: + + await bot.delete_business_messages( + business_connection_id=self.business_connection_id, + message_ids=[self.message_id], + *args, + **kwargs, + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.delete_message` and :meth:`telegram.Bot.delete_business_messages`. + + .. versionchanged:: 22.4 + Calls either :meth:`telegram.Bot.delete_message` + or :meth:`telegram.Bot.delete_business_messages` based + on :attr:`business_connection_id`. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + if self.business_connection_id: + return await self.get_bot().delete_business_messages( + business_connection_id=self.business_connection_id, + message_ids=[self.message_id], + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + return await self.get_bot().delete_message( + chat_id=self.chat_id, + message_id=self.message_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def stop_poll( + self, + reply_markup: "InlineKeyboardMarkup | None" = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> Poll: + """Shortcut for:: + + await bot.stop_poll( + 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. + + """ + return await self.get_bot().stop_poll( + chat_id=self.chat_id, + message_id=self.message_id, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + business_connection_id=self.business_connection_id, + ) + + async def pin( + self, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.pin_chat_message( + 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.pin_chat_message`. + + .. versionchanged:: 21.5 + Now also passes :attr:`business_connection_id` to + :meth:`telegram.Bot.pin_chat_message`. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().pin_chat_message( + chat_id=self.chat_id, + message_id=self.message_id, + business_connection_id=self.business_connection_id, + disable_notification=disable_notification, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def unpin( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.unpin_chat_message( + 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.unpin_chat_message`. + + .. versionchanged:: 21.5 + Now also passes :attr:`business_connection_id` to + :meth:`telegram.Bot.pin_chat_message`. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().unpin_chat_message( + chat_id=self.chat_id, + message_id=self.message_id, + business_connection_id=self.business_connection_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def edit_forum_topic( + self, + name: str | None = None, + icon_custom_emoji_id: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.edit_forum_topic( + chat_id=message.chat_id, message_thread_id=message.message_thread_id, *args, + **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.edit_forum_topic`. + + .. versionadded:: 20.0 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().edit_forum_topic( + chat_id=self.chat_id, + message_thread_id=self.message_thread_id, + name=name, + icon_custom_emoji_id=icon_custom_emoji_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def close_forum_topic( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.close_forum_topic( + chat_id=message.chat_id, message_thread_id=message.message_thread_id, *args, + **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.close_forum_topic`. + + .. versionadded:: 20.0 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().close_forum_topic( + chat_id=self.chat_id, + message_thread_id=self.message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def reopen_forum_topic( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.reopen_forum_topic( + chat_id=message.chat_id, message_thread_id=message.message_thread_id, *args, + **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.reopen_forum_topic`. + + .. versionadded:: 20.0 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().reopen_forum_topic( + chat_id=self.chat_id, + message_thread_id=self.message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_forum_topic( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.delete_forum_topic( + chat_id=message.chat_id, message_thread_id=message.message_thread_id, *args, + **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.delete_forum_topic`. + + .. versionadded:: 20.0 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().delete_forum_topic( + chat_id=self.chat_id, + message_thread_id=self.message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def unpin_all_forum_topic_messages( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.unpin_all_forum_topic_messages( + chat_id=message.chat_id, message_thread_id=message.message_thread_id, *args, + **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.unpin_all_forum_topic_messages`. + + .. versionadded:: 20.0 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().unpin_all_forum_topic_messages( + chat_id=self.chat_id, + message_thread_id=self.message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def set_reaction( + self, + reaction: "Sequence[ReactionType] | ReactionType | Sequence[str] | str | None" = None, + is_big: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.set_message_reaction(chat_id=message.chat_id, message_id=message.message_id, + *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.set_message_reaction`. + + .. versionadded:: 20.8 + + Returns: + :obj:`bool` On success, :obj:`True` is returned. + """ + return await self.get_bot().set_message_reaction( + chat_id=self.chat_id, + message_id=self.message_id, + reaction=reaction, + is_big=is_big, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def read_business_message( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.read_business_message( + 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.read_business_message`. + + .. versionadded:: 22.1 + + Returns: + :obj:`bool` On success, :obj:`True` is returned. + """ + return await self.get_bot().read_business_message( + chat_id=self.chat_id, + message_id=self.message_id, + business_connection_id=self.business_connection_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def approve_suggested_post( + self, + send_date: int | dtm.datetime | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.approve_suggested_post( + chat_id=message.chat_id, + message_id=message.message_id, + *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.approve_suggested_post`. + + .. versionadded:: 22.4 + + Returns: + :obj:`bool` On success, :obj:`True` is returned. + """ + return await self.get_bot().approve_suggested_post( + chat_id=self.chat_id, + message_id=self.message_id, + send_date=send_date, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def decline_suggested_post( + self, + comment: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.decline_suggested_post( + chat_id=message.chat_id, + message_id=message.message_id, + *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.decline_suggested_post`. + + .. versionadded:: 22.4 + + Returns: + :obj:`bool` On success, :obj:`True` is returned. + """ + return await self.get_bot().decline_suggested_post( + chat_id=self.chat_id, + message_id=self.message_id, + comment=comment, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + def parse_entity(self, entity: MessageEntity) -> str: + """Returns the text from a given :class:`telegram.MessageEntity`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``Message.text`` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to this message. + + Returns: + :obj:`str`: The text of the given entity. + + Raises: + RuntimeError: If the message has no text. + + """ + if not self.text: + raise RuntimeError("This Message has no 'text'.") + + return parse_message_entity(self.text, entity) + + def parse_caption_entity(self, entity: MessageEntity) -> str: + """Returns the text from a given :class:`telegram.MessageEntity`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``Message.caption`` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to this message. + + Returns: + :obj:`str`: The text of the given entity. + + Raises: + RuntimeError: If the message has no caption. + + """ + if not self.caption: + raise RuntimeError("This Message has no 'caption'.") + + return parse_message_entity(self.caption, entity) + + def parse_entities(self, types: list[str | None] | None = None) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this message filtered by their + :attr:`telegram.MessageEntity.type` attribute as the key, and the text that each entity + belongs to as the value of the :obj:`dict`. + + Note: + This method should always be used instead of the :attr:`entities` attribute, since it + calculates the correct substring from the message text based on UTF-16 codepoints. + See :attr:`parse_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of :class:`telegram.MessageEntity` types as + strings. If the ``type`` attribute of an entity is contained in this list, it will + be returned. Defaults to a list of all types. All types can be found as constants + in :class:`telegram.MessageEntity`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + + """ + return parse_message_entities(self.text, self.entities, types=types) + + def parse_caption_entities( + self, types: list[str | None] | None = None + ) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this message's caption filtered by their + :attr:`telegram.MessageEntity.type` attribute as the key, and the text that each entity + belongs to as the value of the :obj:`dict`. + + Note: + This method should always be used instead of the :attr:`caption_entities` attribute, + since it calculates the correct substring from the message text based on UTF-16 + codepoints. See :attr:`parse_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of :class:`telegram.MessageEntity` types as + strings. If the ``type`` attribute of an entity is contained in this list, it will + be returned. Defaults to a list of all types. All types can be found as constants + in :class:`telegram.MessageEntity`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + + """ + return parse_message_entities(self.caption, self.caption_entities, types=types) + + @classmethod + def _parse_html( + cls, + message_text: str | None, + entities: dict[MessageEntity, str], + urled: bool = False, + offset: int = 0, + ) -> str | None: + if message_text is None: + return None + + utf_16_text = message_text.encode(TextEncoding.UTF_16_LE) + html_text = "" + last_offset = 0 + + sorted_entities = sorted(entities.items(), key=lambda item: item[0].offset) + parsed_entities = [] + + for entity, text in sorted_entities: + if entity in parsed_entities: + continue + + nested_entities = { + e: t + for (e, t) in sorted_entities + if e.offset >= entity.offset + and e.offset + e.length <= entity.offset + entity.length + and e != entity + } + parsed_entities.extend(list(nested_entities.keys())) + + if nested_entities: + escaped_text = cls._parse_html( + text, nested_entities, urled=urled, offset=entity.offset + ) + else: + escaped_text = escape(text) + + if entity.type == MessageEntity.TEXT_LINK: + insert = f'{escaped_text}' + elif entity.type == MessageEntity.TEXT_MENTION and entity.user: + insert = f'{escaped_text}' + elif entity.type == MessageEntity.URL and urled: + insert = f'{escaped_text}' + elif entity.type == MessageEntity.BLOCKQUOTE: + insert = f"
{escaped_text}
" + elif entity.type == MessageEntity.EXPANDABLE_BLOCKQUOTE: + insert = f"
{escaped_text}
" + elif entity.type == MessageEntity.BOLD: + insert = f"{escaped_text}" + elif entity.type == MessageEntity.ITALIC: + insert = f"{escaped_text}" + elif entity.type == MessageEntity.CODE: + insert = f"{escaped_text}" + elif entity.type == MessageEntity.PRE: + if entity.language: + insert = f'
{escaped_text}
' + else: + insert = f"
{escaped_text}
" + elif entity.type == MessageEntity.UNDERLINE: + insert = f"{escaped_text}" + elif entity.type == MessageEntity.STRIKETHROUGH: + insert = f"{escaped_text}" + elif entity.type == MessageEntity.SPOILER: + insert = f'{escaped_text}' + elif entity.type == MessageEntity.CUSTOM_EMOJI: + insert = f'{escaped_text}' + else: + insert = escaped_text + + # Make sure to escape the text that is not part of the entity + # if we're in a nested entity, this is still required, since in that case this + # text is part of the parent entity + html_text += ( + escape( + utf_16_text[last_offset * 2 : (entity.offset - offset) * 2].decode( + TextEncoding.UTF_16_LE + ) + ) + + insert + ) + + last_offset = entity.offset - offset + entity.length + + # see comment above + html_text += escape(utf_16_text[last_offset * 2 :].decode(TextEncoding.UTF_16_LE)) + + return html_text + + @property + def text_html(self) -> str: + """Creates an HTML-formatted string from the markup entities found in the message. + + Use this if you want to retrieve the message text with the entities formatted as HTML in + the same way the original message was formatted. + + Warning: + |text_html| + + .. versionchanged:: 13.10 + Spoiler entities are now formatted as HTML. + + .. versionchanged:: 20.3 + Custom emoji entities are now supported. + + .. versionchanged:: 20.8 + Blockquote entities are now supported. + + Returns: + :obj:`str`: Message text with entities formatted as HTML. + + """ + return self._parse_html(self.text, self.parse_entities(), urled=False) + + @property + def text_html_urled(self) -> str: + """Creates an HTML-formatted string from the markup entities found in the message. + + Use this if you want to retrieve the message text with the entities formatted as HTML. + This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. + + Warning: + |text_html| + + .. versionchanged:: 13.10 + Spoiler entities are now formatted as HTML. + + .. versionchanged:: 20.3 + Custom emoji entities are now supported. + + .. versionchanged:: 20.8 + Blockquote entities are now supported. + + Returns: + :obj:`str`: Message text with entities formatted as HTML. + + """ + return self._parse_html(self.text, self.parse_entities(), urled=True) + + @property + def caption_html(self) -> str: + """Creates an HTML-formatted string from the markup entities found in the message's + caption. + + Use this if you want to retrieve the message caption with the caption entities formatted as + HTML in the same way the original message was formatted. + + Warning: + |text_html| + + .. versionchanged:: 13.10 + Spoiler entities are now formatted as HTML. + + .. versionchanged:: 20.3 + Custom emoji entities are now supported. + + .. versionchanged:: 20.8 + Blockquote entities are now supported. + + Returns: + :obj:`str`: Message caption with caption entities formatted as HTML. + """ + return self._parse_html(self.caption, self.parse_caption_entities(), urled=False) + + @property + def caption_html_urled(self) -> str: + """Creates an HTML-formatted string from the markup entities found in the message's + caption. + + Use this if you want to retrieve the message caption with the caption entities formatted as + HTML. This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. + + Warning: + |text_html| + + .. versionchanged:: 13.10 + Spoiler entities are now formatted as HTML. + + .. versionchanged:: 20.3 + Custom emoji entities are now supported. + + .. versionchanged:: 20.8 + Blockquote entities are now supported. + + Returns: + :obj:`str`: Message caption with caption entities formatted as HTML. + """ + return self._parse_html(self.caption, self.parse_caption_entities(), urled=True) + + @classmethod + def _parse_markdown( + cls, + message_text: str | None, + entities: dict[MessageEntity, str], + urled: bool = False, + version: MarkdownVersion = 1, + offset: int = 0, + ) -> str | None: + if version == 1: + for entity_type in ( + MessageEntity.EXPANDABLE_BLOCKQUOTE, + MessageEntity.BLOCKQUOTE, + MessageEntity.CUSTOM_EMOJI, + MessageEntity.SPOILER, + MessageEntity.STRIKETHROUGH, + MessageEntity.UNDERLINE, + ): + if any(entity.type == entity_type for entity in entities): + name = entity_type.name.title().replace("_", " ") # type:ignore[attr-defined] + raise ValueError(f"{name} entities are not supported for Markdown version 1") + + if message_text is None: + return None + + utf_16_text = message_text.encode(TextEncoding.UTF_16_LE) + markdown_text = "" + last_offset = 0 + + sorted_entities = sorted(entities.items(), key=lambda item: item[0].offset) + parsed_entities = [] + + for entity, text in sorted_entities: + if entity in parsed_entities: + continue + + nested_entities = { + e: t + for (e, t) in sorted_entities + if e.offset >= entity.offset + and e.offset + e.length <= entity.offset + entity.length + and e != entity + } + parsed_entities.extend(list(nested_entities.keys())) + + if nested_entities: + if version < 2: + raise ValueError("Nested entities are not supported for Markdown version 1") + + escaped_text = cls._parse_markdown( + text, + nested_entities, + urled=urled, + offset=entity.offset, + version=version, + ) + else: + escaped_text = escape_markdown(text, version=version) + + if entity.type == MessageEntity.TEXT_LINK: + if version == 1: + url = entity.url + else: + # Links need special escaping. Also can't have entities nested within + url = escape_markdown( + entity.url, version=version, entity_type=MessageEntity.TEXT_LINK + ) + insert = f"[{escaped_text}]({url})" + elif entity.type == MessageEntity.TEXT_MENTION and entity.user: + insert = f"[{escaped_text}](tg://user?id={entity.user.id})" + elif entity.type == MessageEntity.URL and urled: + link = text if version == 1 else escaped_text + insert = f"[{link}]({text})" + elif entity.type == MessageEntity.BOLD: + insert = f"*{escaped_text}*" + elif entity.type == MessageEntity.ITALIC: + insert = f"_{escaped_text}_" + elif entity.type == MessageEntity.CODE: + # Monospace needs special escaping. Also can't have entities nested within + insert = f"`{escape_markdown(text, version, MessageEntity.CODE)}`" + elif entity.type == MessageEntity.PRE: + # Monospace needs special escaping. Also can't have entities nested within + code = escape_markdown(text, version=version, entity_type=MessageEntity.PRE) + if entity.language: + prefix = f"```{entity.language}\n" + elif code.startswith("\\"): + prefix = "```" + else: + prefix = "```\n" + insert = f"{prefix}{code}```" + elif entity.type == MessageEntity.UNDERLINE: + insert = f"__{escaped_text}__" + elif entity.type == MessageEntity.STRIKETHROUGH: + insert = f"~{escaped_text}~" + elif entity.type == MessageEntity.SPOILER: + insert = f"||{escaped_text}||" + elif entity.type in (MessageEntity.BLOCKQUOTE, MessageEntity.EXPANDABLE_BLOCKQUOTE): + insert = ">" + "\n>".join(escaped_text.splitlines()) + if entity.type == MessageEntity.EXPANDABLE_BLOCKQUOTE: + insert = f"{insert}||" + elif entity.type == MessageEntity.CUSTOM_EMOJI: + # This should never be needed because ids are numeric but the documentation + # specifically mentions it so here we are + custom_emoji_id = escape_markdown( + entity.custom_emoji_id, + version=version, + entity_type=MessageEntity.CUSTOM_EMOJI, + ) + insert = f"![{escaped_text}](tg://emoji?id={custom_emoji_id})" + else: + insert = escaped_text + + # Make sure to escape the text that is not part of the entity + # if we're in a nested entity, this is still required, since in that case this + # text is part of the parent entity + markdown_text += ( + escape_markdown( + utf_16_text[last_offset * 2 : (entity.offset - offset) * 2].decode( + TextEncoding.UTF_16_LE + ), + version=version, + ) + + insert + ) + + last_offset = entity.offset - offset + entity.length + + # see comment above + markdown_text += escape_markdown( + utf_16_text[last_offset * 2 :].decode(TextEncoding.UTF_16_LE), + version=version, + ) + + return markdown_text + + @property + def text_markdown(self) -> str: + """Creates an Markdown-formatted string from the markup entities found in the message + using :class:`telegram.constants.ParseMode.MARKDOWN`. + + Use this if you want to retrieve the message text with the entities formatted as Markdown + in the same way the original message was formatted. + + Warning: + |text_markdown| + + Note: + :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by + Telegram for backward compatibility. You should use :meth:`text_markdown_v2` instead. + + .. versionchanged:: 20.5 + |custom_emoji_no_md1_support| + + .. versionchanged:: 20.8 + |blockquote_no_md1_support| + + Returns: + :obj:`str`: Message text with entities formatted as Markdown. + + Raises: + :exc:`ValueError`: If the message contains underline, strikethrough, spoiler, + blockquote or nested entities. + + """ + return self._parse_markdown(self.text, self.parse_entities(), urled=False) + + @property + def text_markdown_v2(self) -> str: + """Creates an Markdown-formatted string from the markup entities found in the message + using :class:`telegram.constants.ParseMode.MARKDOWN_V2`. + + Use this if you want to retrieve the message text with the entities formatted as Markdown + in the same way the original message was formatted. + + Warning: + |text_markdown| + + .. versionchanged:: 13.10 + Spoiler entities are now formatted as Markdown V2. + + .. versionchanged:: 20.3 + Custom emoji entities are now supported. + + .. versionchanged:: 20.8 + Blockquote entities are now supported. + + Returns: + :obj:`str`: Message text with entities formatted as Markdown. + """ + return self._parse_markdown(self.text, self.parse_entities(), urled=False, version=2) + + @property + def text_markdown_urled(self) -> str: + """Creates an Markdown-formatted string from the markup entities found in the message + using :class:`telegram.constants.ParseMode.MARKDOWN`. + + Use this if you want to retrieve the message text with the entities formatted as Markdown. + This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. + + Warning: + |text_markdown| + + Note: + :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by + Telegram for backward compatibility. You should use :meth:`text_markdown_v2_urled` + instead. + + .. versionchanged:: 20.5 + |custom_emoji_no_md1_support| + + .. versionchanged:: 20.8 + |blockquote_no_md1_support| + + Returns: + :obj:`str`: Message text with entities formatted as Markdown. + + Raises: + :exc:`ValueError`: If the message contains underline, strikethrough, spoiler, + blockquote or nested entities. + + """ + return self._parse_markdown(self.text, self.parse_entities(), urled=True) + + @property + def text_markdown_v2_urled(self) -> str: + """Creates an Markdown-formatted string from the markup entities found in the message + using :class:`telegram.constants.ParseMode.MARKDOWN_V2`. + + Use this if you want to retrieve the message text with the entities formatted as Markdown. + This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. + + Warning: + |text_markdown| + + .. versionchanged:: 13.10 + Spoiler entities are now formatted as Markdown V2. + + .. versionchanged:: 20.3 + Custom emoji entities are now supported. + + .. versionchanged:: 20.8 + Blockquote entities are now supported. + + Returns: + :obj:`str`: Message text with entities formatted as Markdown. + """ + return self._parse_markdown(self.text, self.parse_entities(), urled=True, version=2) + + @property + def caption_markdown(self) -> str: + """Creates an Markdown-formatted string from the markup entities found in the message's + caption using :class:`telegram.constants.ParseMode.MARKDOWN`. + + Use this if you want to retrieve the message caption with the caption entities formatted as + Markdown in the same way the original message was formatted. + + Warning: + |text_markdown| + + Note: + :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by + Telegram for backward compatibility. You should use :meth:`caption_markdown_v2` + .. versionchanged:: 20.5 + |custom_emoji_no_md1_support| + + .. versionchanged:: 20.8 + |blockquote_no_md1_support| + + Returns: + :obj:`str`: Message caption with caption entities formatted as Markdown. + + Raises: + :exc:`ValueError`: If the message contains underline, strikethrough, spoiler, + blockquote or nested entities. + + """ + return self._parse_markdown(self.caption, self.parse_caption_entities(), urled=False) + + @property + def caption_markdown_v2(self) -> str: + """Creates an Markdown-formatted string from the markup entities found in the message's + caption using :class:`telegram.constants.ParseMode.MARKDOWN_V2`. + + Use this if you want to retrieve the message caption with the caption entities formatted as + Markdown in the same way the original message was formatted. + + Warning: + |text_markdown| + + .. versionchanged:: 13.10 + Spoiler entities are now formatted as Markdown V2. + + .. versionchanged:: 20.3 + Custom emoji entities are now supported. + + .. versionchanged:: 20.8 + Blockquote entities are now supported. + + Returns: + :obj:`str`: Message caption with caption entities formatted as Markdown. + """ + return self._parse_markdown( + self.caption, self.parse_caption_entities(), urled=False, version=2 + ) + + @property + def caption_markdown_urled(self) -> str: + """Creates an Markdown-formatted string from the markup entities found in the message's + caption using :class:`telegram.constants.ParseMode.MARKDOWN`. + + Use this if you want to retrieve the message caption with the caption entities formatted as + Markdown. This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. + + Warning: + |text_markdown| + + Note: + :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by + Telegram for backward compatibility. You should use + :meth:`caption_markdown_v2_urled` instead. + + .. versionchanged:: 20.5 + |custom_emoji_no_md1_support| + + .. versionchanged:: 20.8 + |blockquote_no_md1_support| + + Returns: + :obj:`str`: Message caption with caption entities formatted as Markdown. + + Raises: + :exc:`ValueError`: If the message contains underline, strikethrough, spoiler, + blockquote or nested entities. + + """ + return self._parse_markdown(self.caption, self.parse_caption_entities(), urled=True) + + @property + def caption_markdown_v2_urled(self) -> str: + """Creates an Markdown-formatted string from the markup entities found in the message's + caption using :class:`telegram.constants.ParseMode.MARKDOWN_V2`. + + Use this if you want to retrieve the message caption with the caption entities formatted as + Markdown. This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. + + Warning: + |text_markdown| + + .. versionchanged:: 13.10 + Spoiler entities are now formatted as Markdown V2. + + .. versionchanged:: 20.3 + Custom emoji entities are now supported. + + .. versionchanged:: 20.8 + Blockquote entities are now supported. + + Returns: + :obj:`str`: Message caption with caption entities formatted as Markdown. + """ + return self._parse_markdown( + self.caption, self.parse_caption_entities(), urled=True, version=2 + ) diff --git a/telegram/_messageautodeletetimerchanged.py b/src/telegram/_messageautodeletetimerchanged.py similarity index 57% rename from telegram/_messageautodeletetimerchanged.py rename to src/telegram/_messageautodeletetimerchanged.py index 9a96b93c868..9a6e0a42852 100644 --- a/telegram/_messageautodeletetimerchanged.py +++ b/src/telegram/_messageautodeletetimerchanged.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,10 +20,12 @@ deletion. """ -from typing import Optional +import datetime as dtm from telegram._telegramobject import TelegramObject -from telegram._utils.types import JSONDict +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod class MessageAutoDeleteTimerChanged(TelegramObject): @@ -35,26 +37,38 @@ class MessageAutoDeleteTimerChanged(TelegramObject): .. versionadded:: 13.4 Args: - message_auto_delete_time (:obj:`int`): New auto-delete time for messages in the - chat. + message_auto_delete_time (:obj:`int` | :class:`datetime.timedelta`): New auto-delete time + for messages in the chat. + + .. versionchanged:: v22.2 + |time-period-input| Attributes: - message_auto_delete_time (:obj:`int`): New auto-delete time for messages in the - chat. + message_auto_delete_time (:obj:`int` | :class:`datetime.timedelta`): New auto-delete time + for messages in the chat. + + .. deprecated:: v22.2 + |time-period-int-deprecated| """ - __slots__ = ("message_auto_delete_time",) + __slots__ = ("_message_auto_delete_time",) def __init__( self, - message_auto_delete_time: int, + message_auto_delete_time: TimePeriod, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) - self.message_auto_delete_time: int = message_auto_delete_time + self._message_auto_delete_time: dtm.timedelta = to_timedelta(message_auto_delete_time) self._id_attrs = (self.message_auto_delete_time,) self._freeze() + + @property + def message_auto_delete_time(self) -> int | dtm.timedelta: + return get_timedelta_value( # type: ignore[return-value] + self._message_auto_delete_time, attribute="message_auto_delete_time" + ) diff --git a/src/telegram/_messageentity.py b/src/telegram/_messageentity.py new file mode 100644 index 00000000000..37fb597a4cc --- /dev/null +++ b/src/telegram/_messageentity.py @@ -0,0 +1,410 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 MessageEntity.""" + +import copy +import itertools +from collections.abc import Sequence +from typing import TYPE_CHECKING, Final + +from telegram import constants +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional +from telegram._utils.strings import TextEncoding +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + +_SEM = Sequence["MessageEntity"] + + +class MessageEntity(TelegramObject): + """ + This object represents one special entity in a text message. For example, hashtags, + usernames, URLs, etc. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`type`, :attr:`offset` and :attr:`length` are equal. + + Args: + type (:obj:`str`): Type of the entity. Can be :attr:`MENTION` (``@username``), + :attr:`HASHTAG` (``#hashtag`` or ``#hashtag@chatusername``), :attr:`CASHTAG` (``$USD`` + or ``USD@chatusername``), :attr:`BOT_COMMAND` (``/start@jobs_bot``), :attr:`URL` + (``https://telegram.org``), :attr:`EMAIL` (``do-not-reply@telegram.org``), + :attr:`PHONE_NUMBER` (``+1-212-555-0123``), + :attr:`BOLD` (**bold text**), :attr:`ITALIC` (*italic text*), :attr:`UNDERLINE` + (underlined text), :attr:`STRIKETHROUGH`, :attr:`SPOILER` (spoiler message), + :attr:`BLOCKQUOTE` (block quotation), :attr:`CODE` (monowidth string), :attr:`PRE` + (monowidth block), :attr:`TEXT_LINK` (for clickable text URLs), :attr:`TEXT_MENTION` + (for users without usernames), :attr:`CUSTOM_EMOJI` (for inline custom emoji stickers). + + .. versionadded:: 20.0 + Added inline custom emoji + + .. versionadded:: 20.8 + Added block quotation + offset (:obj:`int`): Offset in UTF-16 code units to the start of the entity. + length (:obj:`int`): Length of the entity in UTF-16 code units. + url (:obj:`str`, optional): For :attr:`TEXT_LINK` only, url that will be opened after + user taps on the text. + user (:class:`telegram.User`, optional): For :attr:`TEXT_MENTION` only, the mentioned + user. + language (:obj:`str`, optional): For :attr:`PRE` only, the programming language of + the entity text. + custom_emoji_id (:obj:`str`, optional): For :attr:`CUSTOM_EMOJI` only, unique identifier + of the custom emoji. Use :meth:`telegram.Bot.get_custom_emoji_stickers` to get full + information about the sticker. + + .. versionadded:: 20.0 + Attributes: + type (:obj:`str`): Type of the entity. Can be :attr:`MENTION` (``@username``), + :attr:`HASHTAG` (``#hashtag`` or ``#hashtag@chatusername``), :attr:`CASHTAG` (``$USD`` + or ``USD@chatusername``), :attr:`BOT_COMMAND` (``/start@jobs_bot``), :attr:`URL` + (``https://telegram.org``), :attr:`EMAIL` (``do-not-reply@telegram.org``), + :attr:`PHONE_NUMBER` (``+1-212-555-0123``), + :attr:`BOLD` (**bold text**), :attr:`ITALIC` (*italic text*), :attr:`UNDERLINE` + (underlined text), :attr:`STRIKETHROUGH`, :attr:`SPOILER` (spoiler message), + :attr:`BLOCKQUOTE` (block quotation), :attr:`CODE` (monowidth string), :attr:`PRE` + (monowidth block), :attr:`TEXT_LINK` (for clickable text URLs), :attr:`TEXT_MENTION` + (for users without usernames), :attr:`CUSTOM_EMOJI` (for inline custom emoji stickers). + + .. versionadded:: 20.0 + Added inline custom emoji + + .. versionadded:: 20.8 + Added block quotation + offset (:obj:`int`): Offset in UTF-16 code units to the start of the entity. + length (:obj:`int`): Length of the entity in UTF-16 code units. + url (:obj:`str`): Optional. For :attr:`TEXT_LINK` only, url that will be opened after + user taps on the text. + user (:class:`telegram.User`): Optional. For :attr:`TEXT_MENTION` only, the mentioned + user. + language (:obj:`str`): Optional. For :attr:`PRE` only, the programming language of + the entity text. + custom_emoji_id (:obj:`str`): Optional. For :attr:`CUSTOM_EMOJI` only, unique identifier + of the custom emoji. Use :meth:`telegram.Bot.get_custom_emoji_stickers` to get full + information about the sticker. + + .. versionadded:: 20.0 + + """ + + __slots__ = ("custom_emoji_id", "language", "length", "offset", "type", "url", "user") + + def __init__( + self, + type: str, # pylint: disable=redefined-builtin + offset: int, + length: int, + url: str | None = None, + user: User | None = None, + language: str | None = None, + custom_emoji_id: str | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.type: str = enum.get_member(constants.MessageEntityType, type, type) + self.offset: int = offset + self.length: int = length + # Optionals + self.url: str | None = url + self.user: User | None = user + self.language: str | None = language + self.custom_emoji_id: str | None = custom_emoji_id + + self._id_attrs = (self.type, self.offset, self.length) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "MessageEntity": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["user"] = de_json_optional(data.get("user"), User, bot) + + return super().de_json(data=data, bot=bot) + + @staticmethod + def adjust_message_entities_to_utf_16(text: str, entities: _SEM) -> _SEM: + """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(TextEncoding.UTF_16_LE)) // 2 + position_translation[position] = accumulated_length + # get the final output entities + 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 + + @staticmethod + def shift_entities(by: str | int, entities: _SEM) -> _SEM: + """Utility functionality for shifting the offset of entities by a given amount. + + Examples: + Shifting by an integer amount: + + .. code-block:: python + + text = "Hello, world!" + entities = [ + MessageEntity(offset=0, length=5, type=MessageEntity.BOLD), + MessageEntity(offset=7, length=5, type=MessageEntity.ITALIC), + ] + shifted_entities = MessageEntity.shift_entities(1, entities) + await bot.send_message( + chat_id=123, + text="!" + text, + entities=shifted_entities, + ) + + Shifting using a string: + + .. code-block:: python + + text = "Hello, world!" + prefix = "𝄢" + entities = [ + MessageEntity(offset=0, length=5, type=MessageEntity.BOLD), + MessageEntity(offset=7, length=5, type=MessageEntity.ITALIC), + ] + shifted_entities = MessageEntity.shift_entities(prefix, entities) + await bot.send_message( + chat_id=123, + text=prefix + text, + entities=shifted_entities, + ) + + Tip: + The :paramref:`entities` are *not* modified in place. The function returns a sequence + of new objects. + + .. versionadded:: 21.5 + + Args: + by (:obj:`str` | :obj:`int`): Either the amount to shift the offset by or + a string whose length will be used as the amount to shift the offset by. In this + case, UTF-16 encoding will be used to calculate the length. + entities (Sequence[:class:`telegram.MessageEntity`]): Sequence of entities + + Returns: + Sequence[:class:`telegram.MessageEntity`]: Sequence of entities with the offset shifted + """ + effective_shift = by if isinstance(by, int) else len(by.encode("utf-16-le")) // 2 + + out = [] + for entity in entities: + new_entity = copy.copy(entity) + with new_entity._unfrozen(): + new_entity.offset += effective_shift + out.append(new_entity) + return out + + @classmethod + def concatenate( + cls, + *args: tuple[str, _SEM] | tuple[str, _SEM, bool], + ) -> tuple[str, _SEM]: + """Utility functionality for concatenating two text along with their formatting entities. + + Tip: + This function is useful for prefixing an already formatted text with a new text and its + formatting entities. In particular, it automatically correctly handles UTF-16 encoding. + + Examples: + This example shows a callback function that can be used to add a prefix and suffix to + the message in a :class:`~telegram.ext.CallbackQueryHandler`: + + .. code-block:: python + + async def prefix_message(update: Update, context: ContextTypes.DEFAULT_TYPE): + prefix = "𠌕 bold 𝄢 italic underlined: 𝛙𝌢𑁍 | " + prefix_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), + ] + suffix = " | 𠌕 bold 𝄢 italic underlined: 𝛙𝌢𑁍" + suffix_entities = [ + MessageEntity(offset=5, length=4, type=MessageEntity.BOLD), + MessageEntity(offset=12, length=6, type=MessageEntity.ITALIC), + MessageEntity(offset=31, length=3, type=MessageEntity.UNDERLINE), + ] + + message = update.effective_message + first = (prefix, prefix_entities, True) + second = (message.text, message.entities) + third = (suffix, suffix_entities, True) + + new_text, new_entities = MessageEntity.concatenate(first, second, third) + await update.callback_query.edit_message_text( + text=new_text, + entities=new_entities, + ) + + Hint: + The entities are *not* modified in place. The function returns a + new sequence of objects. + + .. versionadded:: 21.5 + + Args: + *args (tuple[:obj:`str`, Sequence[:class:`telegram.MessageEntity`]] | \ + tuple[:obj:`str`, Sequence[:class:`telegram.MessageEntity`], :obj:`bool`]): + Arbitrary number of tuples containing the text and its entities to concatenate. + If the last element of the tuple is a :obj:`bool`, it is used to determine whether + to adjust the entities to UTF-16 via + :meth:`adjust_message_entities_to_utf_16`. UTF-16 adjustment is disabled by + default. + + Returns: + tuple[:obj:`str`, Sequence[:class:`telegram.MessageEntity`]]: The concatenated text + and its entities + """ + output_text = "" + output_entities: list[MessageEntity] = [] + for arg in args: + text, entities = arg[0], arg[1] + + if len(arg) > 2 and arg[2] is True: + entities = cls.adjust_message_entities_to_utf_16(text, entities) + + output_entities.extend(cls.shift_entities(output_text, entities)) + output_text += text + + return output_text, output_entities + + 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 + """:const:`telegram.constants.MessageEntityType.BLOCKQUOTE` + + .. versionadded:: 20.8 + """ + BOLD: Final[str] = constants.MessageEntityType.BOLD + """:const:`telegram.constants.MessageEntityType.BOLD`""" + BOT_COMMAND: Final[str] = constants.MessageEntityType.BOT_COMMAND + """:const:`telegram.constants.MessageEntityType.BOT_COMMAND`""" + CASHTAG: Final[str] = constants.MessageEntityType.CASHTAG + """:const:`telegram.constants.MessageEntityType.CASHTAG`""" + CODE: Final[str] = constants.MessageEntityType.CODE + """:const:`telegram.constants.MessageEntityType.CODE`""" + CUSTOM_EMOJI: Final[str] = constants.MessageEntityType.CUSTOM_EMOJI + """:const:`telegram.constants.MessageEntityType.CUSTOM_EMOJI` + + .. versionadded:: 20.0 + """ + EMAIL: Final[str] = constants.MessageEntityType.EMAIL + """:const:`telegram.constants.MessageEntityType.EMAIL`""" + EXPANDABLE_BLOCKQUOTE: Final[str] = constants.MessageEntityType.EXPANDABLE_BLOCKQUOTE + """:const:`telegram.constants.MessageEntityType.EXPANDABLE_BLOCKQUOTE` + + .. versionadded:: 21.3 + """ + HASHTAG: Final[str] = constants.MessageEntityType.HASHTAG + """:const:`telegram.constants.MessageEntityType.HASHTAG`""" + ITALIC: Final[str] = constants.MessageEntityType.ITALIC + """:const:`telegram.constants.MessageEntityType.ITALIC`""" + MENTION: Final[str] = constants.MessageEntityType.MENTION + """:const:`telegram.constants.MessageEntityType.MENTION`""" + PHONE_NUMBER: Final[str] = constants.MessageEntityType.PHONE_NUMBER + """:const:`telegram.constants.MessageEntityType.PHONE_NUMBER`""" + PRE: Final[str] = constants.MessageEntityType.PRE + """:const:`telegram.constants.MessageEntityType.PRE`""" + SPOILER: Final[str] = constants.MessageEntityType.SPOILER + """:const:`telegram.constants.MessageEntityType.SPOILER` + + .. versionadded:: 13.10 + """ + STRIKETHROUGH: Final[str] = constants.MessageEntityType.STRIKETHROUGH + """:const:`telegram.constants.MessageEntityType.STRIKETHROUGH`""" + TEXT_LINK: Final[str] = constants.MessageEntityType.TEXT_LINK + """:const:`telegram.constants.MessageEntityType.TEXT_LINK`""" + TEXT_MENTION: Final[str] = constants.MessageEntityType.TEXT_MENTION + """:const:`telegram.constants.MessageEntityType.TEXT_MENTION`""" + UNDERLINE: Final[str] = constants.MessageEntityType.UNDERLINE + """:const:`telegram.constants.MessageEntityType.UNDERLINE`""" + URL: Final[str] = constants.MessageEntityType.URL + """:const:`telegram.constants.MessageEntityType.URL`""" diff --git a/telegram/_messageid.py b/src/telegram/_messageid.py similarity index 63% rename from telegram/_messageid.py rename to src/telegram/_messageid.py index 2ad575f18cf..70420936b8b 100644 --- a/telegram/_messageid.py +++ b/src/telegram/_messageid.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents an instance of a Telegram MessageId.""" -from typing import Optional - from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -31,15 +29,21 @@ class MessageId(TelegramObject): considered equal, if their :attr:`message_id` is equal. Args: - message_id (:obj:`int`): Unique message identifier. + message_id (:obj:`int`): Unique message identifier. In specific instances + (e.g., message containing a video sent to a big chat), the server might automatically + schedule a message instead of sending it immediately. In such cases, this field will be + ``0`` and the relevant message will be unusable until it is actually sent. Attributes: - message_id (:obj:`int`): Unique message identifier. + message_id (:obj:`int`): Unique message identifier. In specific instances + (e.g., message containing a video sent to a big chat), the server might automatically + schedule a message instead of sending it immediately. In such cases, this field will be + ``0`` and the relevant message will be unusable until it is actually sent. """ __slots__ = ("message_id",) - def __init__(self, message_id: int, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, message_id: int, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self.message_id: int = message_id diff --git a/src/telegram/_messageorigin.py b/src/telegram/_messageorigin.py new file mode 100644 index 00000000000..767f5a4a0af --- /dev/null +++ b/src/telegram/_messageorigin.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 the classes that represent Telegram MessageOigin.""" + +import datetime as dtm +from typing import TYPE_CHECKING, Final + +from telegram import constants +from telegram._chat import Chat +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional +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 MessageOrigin(TelegramObject): + """ + Base class for telegram MessageOrigin object, it can be one of: + + * :class:`MessageOriginUser` + * :class:`MessageOriginHiddenUser` + * :class:`MessageOriginChat` + * :class:`MessageOriginChannel` + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`type` and :attr:`date` are equal. + + .. versionadded:: 20.8 + + Args: + type (:obj:`str`): Type of the message origin, can be on of: + :attr:`~telegram.MessageOrigin.USER`, :attr:`~telegram.MessageOrigin.HIDDEN_USER`, + :attr:`~telegram.MessageOrigin.CHAT`, or :attr:`~telegram.MessageOrigin.CHANNEL`. + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + + Attributes: + type (:obj:`str`): Type of the message origin, can be on of: + :attr:`~telegram.MessageOrigin.USER`, :attr:`~telegram.MessageOrigin.HIDDEN_USER`, + :attr:`~telegram.MessageOrigin.CHAT`, or :attr:`~telegram.MessageOrigin.CHANNEL`. + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + """ + + __slots__ = ( + "date", + "type", + ) + + USER: Final[str] = constants.MessageOriginType.USER + """:const:`telegram.constants.MessageOriginType.USER`""" + HIDDEN_USER: Final[str] = constants.MessageOriginType.HIDDEN_USER + """:const:`telegram.constants.MessageOriginType.HIDDEN_USER`""" + CHAT: Final[str] = constants.MessageOriginType.CHAT + """:const:`telegram.constants.MessageOriginType.CHAT`""" + CHANNEL: Final[str] = constants.MessageOriginType.CHANNEL + """:const:`telegram.constants.MessageOriginType.CHANNEL`""" + + def __init__( + self, + type: str, # pylint: disable=W0622 + date: dtm.datetime, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required by all subclasses + self.type: str = enum.get_member(constants.MessageOriginType, type, type) + self.date: dtm.datetime = date + + self._id_attrs = ( + self.type, + self.date, + ) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "MessageOrigin": + """Converts JSON data to the appropriate :class:`MessageOrigin` object, i.e. takes + care of selecting the correct subclass. + """ + data = cls._parse_data(data) + + _class_mapping: dict[str, type[MessageOrigin]] = { + cls.USER: MessageOriginUser, + cls.HIDDEN_USER: MessageOriginHiddenUser, + cls.CHAT: MessageOriginChat, + cls.CHANNEL: MessageOriginChannel, + } + if cls is MessageOrigin and data.get("type") in _class_mapping: + return _class_mapping[data.pop("type")].de_json(data=data, bot=bot) + + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["date"] = from_timestamp(data.get("date"), tzinfo=loc_tzinfo) + + if "sender_user" in data: + data["sender_user"] = de_json_optional(data.get("sender_user"), User, bot) + + if "sender_chat" in data: + data["sender_chat"] = de_json_optional(data.get("sender_chat"), Chat, bot) + + if "chat" in data: + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + + return super().de_json(data=data, bot=bot) + + +class MessageOriginUser(MessageOrigin): + """ + The message was originally sent by a known user. + + .. versionadded:: 20.8 + + Args: + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + sender_user (:class:`telegram.User`): User that sent the message originally. + + Attributes: + type (:obj:`str`): Type of the message origin. Always + :tg-const:`~telegram.MessageOrigin.USER`. + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + sender_user (:class:`telegram.User`): User that sent the message originally. + """ + + __slots__ = ("sender_user",) + + def __init__( + self, + date: dtm.datetime, + sender_user: User, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=self.USER, date=date, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.sender_user: User = sender_user + + +class MessageOriginHiddenUser(MessageOrigin): + """ + The message was originally sent by an unknown user. + + .. versionadded:: 20.8 + + Args: + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + sender_user_name (:obj:`str`): Name of the user that sent the message originally. + + Attributes: + type (:obj:`str`): Type of the message origin. Always + :tg-const:`~telegram.MessageOrigin.HIDDEN_USER`. + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + sender_user_name (:obj:`str`): Name of the user that sent the message originally. + """ + + __slots__ = ("sender_user_name",) + + def __init__( + self, + date: dtm.datetime, + sender_user_name: str, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=self.HIDDEN_USER, date=date, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.sender_user_name: str = sender_user_name + + +class MessageOriginChat(MessageOrigin): + """ + The message was originally sent on behalf of a chat to a group chat. + + .. versionadded:: 20.8 + + Args: + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + sender_chat (:class:`telegram.Chat`): Chat that sent the message originally. + author_signature (:obj:`str`, optional): For messages originally sent by an anonymous chat + administrator, original message author signature + + Attributes: + type (:obj:`str`): Type of the message origin. Always + :tg-const:`~telegram.MessageOrigin.CHAT`. + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + sender_chat (:class:`telegram.Chat`): Chat that sent the message originally. + author_signature (:obj:`str`): Optional. For messages originally sent by an anonymous chat + administrator, original message author signature + """ + + __slots__ = ( + "author_signature", + "sender_chat", + ) + + def __init__( + self, + date: dtm.datetime, + sender_chat: Chat, + author_signature: str | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=self.CHAT, date=date, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.sender_chat: Chat = sender_chat + self.author_signature: str | None = author_signature + + +class MessageOriginChannel(MessageOrigin): + """ + The message was originally sent to a channel chat. + + .. versionadded:: 20.8 + + Args: + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + chat (:class:`telegram.Chat`): Channel chat to which the message was originally sent. + message_id (:obj:`int`): Unique message identifier inside the chat. + author_signature (:obj:`str`, optional): Signature of the original post author. + + Attributes: + type (:obj:`str`): Type of the message origin. Always + :tg-const:`~telegram.MessageOrigin.CHANNEL`. + date (:obj:`datetime.datetime`): Date the message was sent originally. + |datetime_localization| + chat (:class:`telegram.Chat`): Channel chat to which the message was originally sent. + message_id (:obj:`int`): Unique message identifier inside the chat. + author_signature (:obj:`str`): Optional. Signature of the original post author. + """ + + __slots__ = ( + "author_signature", + "chat", + "message_id", + ) + + def __init__( + self, + date: dtm.datetime, + chat: Chat, + message_id: int, + author_signature: str | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=self.CHANNEL, date=date, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.chat: Chat = chat + self.message_id: int = message_id + self.author_signature: str | None = author_signature diff --git a/src/telegram/_messagereactionupdated.py b/src/telegram/_messagereactionupdated.py new file mode 100644 index 00000000000..d5e5471954f --- /dev/null +++ b/src/telegram/_messagereactionupdated.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 MessageReaction Update.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from telegram._chat import Chat +from telegram._reaction import ReactionCount, ReactionType +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, 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 MessageReactionCountUpdated(TelegramObject): + """This class represents reaction changes on a message with anonymous reactions. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if the :attr:`chat`, :attr:`message_id`, :attr:`date` and :attr:`reactions` + is equal. + + .. versionadded:: 20.8 + + Args: + chat (:class:`telegram.Chat`): The chat containing the message. + message_id (:obj:`int`): Unique message identifier inside the chat. + date (:class:`datetime.datetime`): Date of the change in Unix time + |datetime_localization| + reactions (Sequence[:class:`telegram.ReactionCount`]): List of reactions that are present + on the message + + Attributes: + chat (:class:`telegram.Chat`): The chat containing the message. + message_id (:obj:`int`): Unique message identifier inside the chat. + date (:class:`datetime.datetime`): Date of the change in Unix time + |datetime_localization| + reactions (tuple[:class:`telegram.ReactionCount`]): List of reactions that are present on + the message + """ + + __slots__ = ( + "chat", + "date", + "message_id", + "reactions", + ) + + def __init__( + self, + chat: Chat, + message_id: int, + date: dtm.datetime, + reactions: Sequence[ReactionCount], + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.chat: Chat = chat + self.message_id: int = message_id + self.date: dtm.datetime = date + self.reactions: tuple[ReactionCount, ...] = parse_sequence_arg(reactions) + + self._id_attrs = (self.chat, self.message_id, self.date, self.reactions) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "MessageReactionCountUpdated": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # 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"), tzinfo=loc_tzinfo) + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["reactions"] = de_list_optional(data.get("reactions"), ReactionCount, bot) + + return super().de_json(data=data, bot=bot) + + +class MessageReactionUpdated(TelegramObject): + """This class represents a change of a reaction on a message performed by a user. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if the :attr:`chat`, :attr:`message_id`, :attr:`date`, :attr:`old_reaction` + and :attr:`new_reaction` is equal. + + .. versionadded:: 20.8 + + Args: + chat (:class:`telegram.Chat`): The chat containing the message. + message_id (:obj:`int`): Unique message identifier inside the chat. + date (:class:`datetime.datetime`): Date of the change in Unix time. + |datetime_localization| + old_reaction (Sequence[:class:`telegram.ReactionType`]): Previous list of reaction types + that were set by the user. + new_reaction (Sequence[:class:`telegram.ReactionType`]): New list of reaction types that + were set by the user. + user (:class:`telegram.User`, optional): The user that changed the reaction, if the user + isn't anonymous. + actor_chat (:class:`telegram.Chat`, optional): The chat on behalf of which the reaction was + changed, if the user is anonymous. + + Attributes: + chat (:class:`telegram.Chat`): The chat containing the message. + message_id (:obj:`int`): Unique message identifier inside the chat. + date (:class:`datetime.datetime`): Date of the change in Unix time. + |datetime_localization| + old_reaction (tuple[:class:`telegram.ReactionType`]): Previous list of reaction types + that were set by the user. + new_reaction (tuple[:class:`telegram.ReactionType`]): New list of reaction types that + were set by the user. + user (:class:`telegram.User`): Optional. The user that changed the reaction, if the user + isn't anonymous. + actor_chat (:class:`telegram.Chat`): Optional. The chat on behalf of which the reaction was + changed, if the user is anonymous. + """ + + __slots__ = ( + "actor_chat", + "chat", + "date", + "message_id", + "new_reaction", + "old_reaction", + "user", + ) + + def __init__( + self, + chat: Chat, + message_id: int, + date: dtm.datetime, + old_reaction: Sequence[ReactionType], + new_reaction: Sequence[ReactionType], + user: User | None = None, + actor_chat: Chat | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.chat: Chat = chat + self.message_id: int = message_id + self.date: dtm.datetime = date + self.old_reaction: tuple[ReactionType, ...] = parse_sequence_arg(old_reaction) + self.new_reaction: tuple[ReactionType, ...] = parse_sequence_arg(new_reaction) + + # Optional + self.user: User | None = user + self.actor_chat: Chat | None = actor_chat + + self._id_attrs = ( + self.chat, + self.message_id, + self.date, + self.old_reaction, + self.new_reaction, + ) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "MessageReactionUpdated": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # 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"), tzinfo=loc_tzinfo) + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["old_reaction"] = de_list_optional(data.get("old_reaction"), ReactionType, bot) + data["new_reaction"] = de_list_optional(data.get("new_reaction"), ReactionType, bot) + data["user"] = de_json_optional(data.get("user"), User, bot) + data["actor_chat"] = de_json_optional(data.get("actor_chat"), Chat, bot) + + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_ownedgift.py b/src/telegram/_ownedgift.py new file mode 100644 index 00000000000..37ae8f693a6 --- /dev/null +++ b/src/telegram/_ownedgift.py @@ -0,0 +1,455 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 owned gifts.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING, Final + +from telegram import constants +from telegram._gifts import Gift +from telegram._messageentity import MessageEntity +from telegram._telegramobject import TelegramObject +from telegram._uniquegift import UniqueGift +from telegram._user import User +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.entities import parse_message_entities, parse_message_entity +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class OwnedGift(TelegramObject): + """This object describes a gift received and owned by a user or a chat. Currently, it + can be one of: + + * :class:`telegram.OwnedGiftRegular` + * :class:`telegram.OwnedGiftUnique` + + 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:: 22.1 + + Args: + type (:obj:`str`): Type of the owned gift. + + Attributes: + type (:obj:`str`): Type of the owned gift. + """ + + __slots__ = ("type",) + + REGULAR: Final[str] = constants.OwnedGiftType.REGULAR + """:const:`telegram.constants.OwnedGiftType.REGULAR`""" + UNIQUE: Final[str] = constants.OwnedGiftType.UNIQUE + """:const:`telegram.constants.OwnedGiftType.UNIQUE`""" + + def __init__( + self, + type: str, # pylint: disable=redefined-builtin + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.OwnedGiftType, type, type) + + self._id_attrs = (self.type,) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "OwnedGift": + """Converts JSON data to the appropriate :class:`OwnedGift` 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) + + _class_mapping: dict[str, type[OwnedGift]] = { + cls.REGULAR: OwnedGiftRegular, + cls.UNIQUE: OwnedGiftUnique, + } + + if cls is OwnedGift 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 OwnedGifts(TelegramObject): + """Contains the list of gifts received and owned by a user or a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`total_count` and :attr:`gifts` are equal. + + .. versionadded:: 22.1 + + Args: + total_count (:obj:`int`): The total number of gifts owned by the user or the chat. + gifts (Sequence[:class:`telegram.OwnedGift`]): The list of gifts. + next_offset (:obj:`str`, optional): Offset for the next request. If empty, + then there are no more results. + + Attributes: + total_count (:obj:`int`): The total number of gifts owned by the user or the chat. + gifts (Sequence[:class:`telegram.OwnedGift`]): The list of gifts. + next_offset (:obj:`str`): Optional. Offset for the next request. If empty, + then there are no more results. + """ + + __slots__ = ( + "gifts", + "next_offset", + "total_count", + ) + + def __init__( + self, + total_count: int, + gifts: Sequence[OwnedGift], + next_offset: str | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.total_count: int = total_count + self.gifts: tuple[OwnedGift, ...] = parse_sequence_arg(gifts) + self.next_offset: str | None = next_offset + + self._id_attrs = (self.total_count, self.gifts) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "OwnedGifts": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["gifts"] = de_list_optional(data.get("gifts"), OwnedGift, bot) + return super().de_json(data=data, bot=bot) + + +class OwnedGiftRegular(OwnedGift): + """Describes a regular gift owned by a user or a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`gift` and :attr:`send_date` are equal. + + .. versionadded:: 22.1 + + Args: + gift (:class:`telegram.Gift`): Information about the regular gift. + owned_gift_id (:obj:`str`, optional): Unique identifier of the gift for the bot; for + gifts received on behalf of business accounts only. + sender_user (:class:`telegram.User`, optional): Sender of the gift if it is a known user. + send_date (:obj:`datetime.datetime`): Date the gift was sent as :class:`datetime.datetime`. + |datetime_localization|. + text (:obj:`str`, optional): Text of the message that was added to the gift. + entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities that + appear in the text. + is_private (:obj:`bool`, optional): :obj:`True`, if the sender and gift text are shown + only to the gift receiver; otherwise, everyone will be able to see them. + is_saved (:obj:`bool`, optional): :obj:`True`, if the gift is displayed on the account's + profile page; for gifts received on behalf of business accounts only. + can_be_upgraded (:obj:`bool`, optional): :obj:`True`, if the gift can be upgraded to a + unique gift; for gifts received on behalf of business accounts only. + was_refunded (:obj:`bool`, optional): :obj:`True`, if the gift was refunded and isn't + available anymore. + convert_star_count (:obj:`int`, optional): Number of Telegram Stars that can be + claimed by the receiver instead of the gift; omitted if the gift cannot be converted + to Telegram Stars; for gifts received on behalf of business accounts only. + prepaid_upgrade_star_count (:obj:`int`, optional): Number of Telegram Stars that were + paid for the ability to upgrade the gift. + is_upgrade_separate (:obj:`bool`, optional): :obj:`True`, if the gift's upgrade was + purchased after the gift was sent; for gifts received on behalf of business accounts + + .. versionadded:: 22.6 + unique_gift_number (:obj:`int`, optional): Unique number reserved for this gift when + upgraded. See the number field in :class:`~telegram.UniqueGift` + + ... versionadded:: 22.6 + + Attributes: + type (:obj:`str`): Type of the gift, always :attr:`~telegram.OwnedGift.REGULAR`. + gift (:class:`telegram.Gift`): Information about the regular gift. + owned_gift_id (:obj:`str`): Optional. Unique identifier of the gift for the bot; for + gifts received on behalf of business accounts only. + sender_user (:class:`telegram.User`): Optional. Sender of the gift if it is a known user. + send_date (:obj:`datetime.datetime`): Date the gift was sent as :class:`datetime.datetime`. + |datetime_localization|. + text (:obj:`str`): Optional. Text of the message that was added to the gift. + entities (Sequence[:class:`telegram.MessageEntity`]): Optional. Special entities that + appear in the text. + is_private (:obj:`bool`): Optional. :obj:`True`, if the sender and gift text are shown + only to the gift receiver; otherwise, everyone will be able to see them. + is_saved (:obj:`bool`): Optional. :obj:`True`, if the gift is displayed on the account's + profile page; for gifts received on behalf of business accounts only. + can_be_upgraded (:obj:`bool`): Optional. :obj:`True`, if the gift can be upgraded to a + unique gift; for gifts received on behalf of business accounts only. + was_refunded (:obj:`bool`): Optional. :obj:`True`, if the gift was refunded and isn't + available anymore. + convert_star_count (:obj:`int`): Optional. Number of Telegram Stars that can be + claimed by the receiver instead of the gift; omitted if the gift cannot be converted + to Telegram Stars; for gifts received on behalf of business accounts only. + prepaid_upgrade_star_count (:obj:`int`): Optional. Number of Telegram Stars that were + paid for the ability to upgrade the gift. + is_upgrade_separate (:obj:`bool`): Optional. :obj:`True`, if the gift's upgrade was + purchased after the gift was sent; for gifts received on behalf of business accounts + + .. versionadded:: 22.6 + unique_gift_number (:obj:`int`): Optional. Unique number reserved for this gift when + upgraded. See the number field in :class:`~telegram.UniqueGift` + + ... versionadded:: 22.6 + + """ + + __slots__ = ( + "can_be_upgraded", + "convert_star_count", + "entities", + "gift", + "is_private", + "is_saved", + "is_upgrade_separate", + "owned_gift_id", + "prepaid_upgrade_star_count", + "send_date", + "sender_user", + "text", + "unique_gift_number", + "was_refunded", + ) + + def __init__( + self, + gift: Gift, + send_date: dtm.datetime, + owned_gift_id: str | None = None, + sender_user: User | None = None, + text: str | None = None, + entities: Sequence[MessageEntity] | None = None, + is_private: bool | None = None, + is_saved: bool | None = None, + can_be_upgraded: bool | None = None, + was_refunded: bool | None = None, + convert_star_count: int | None = None, + prepaid_upgrade_star_count: int | None = None, + is_upgrade_separate: bool | None = None, + unique_gift_number: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=OwnedGift.REGULAR, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.gift: Gift = gift + self.send_date: dtm.datetime = send_date + self.owned_gift_id: str | None = owned_gift_id + self.sender_user: User | None = sender_user + self.text: str | None = text + self.entities: tuple[MessageEntity, ...] = parse_sequence_arg(entities) + self.is_private: bool | None = is_private + self.is_saved: bool | None = is_saved + self.can_be_upgraded: bool | None = can_be_upgraded + self.was_refunded: bool | None = was_refunded + self.convert_star_count: int | None = convert_star_count + self.prepaid_upgrade_star_count: int | None = prepaid_upgrade_star_count + self.is_upgrade_separate: bool | None = is_upgrade_separate + self.unique_gift_number: int | None = unique_gift_number + + self._id_attrs = (self.type, self.gift, self.send_date) + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "OwnedGiftRegular": + """See :meth:`telegram.OwnedGift.de_json`.""" + data = cls._parse_data(data) + + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["send_date"] = from_timestamp(data.get("send_date"), tzinfo=loc_tzinfo) + data["sender_user"] = de_json_optional(data.get("sender_user"), User, bot) + data["gift"] = de_json_optional(data.get("gift"), Gift, bot) + data["entities"] = de_list_optional(data.get("entities"), MessageEntity, bot) + + return super().de_json(data=data, bot=bot) # type: ignore[return-value] + + def parse_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`text` + from a given :class:`telegram.MessageEntity` of :attr:`entities`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``OwnedGiftRegular.text`` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`entities`. + + Returns: + :obj:`str`: The text of the given entity. + + Raises: + RuntimeError: If the owned gift has no text. + + """ + if not self.text: + raise RuntimeError("This OwnedGiftRegular has no 'text'.") + + return parse_message_entity(self.text, entity) + + def parse_entities(self, types: list[str] | None = None) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this owned gift's text filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + Note: + This method should always be used instead of the :attr:`entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + + Raises: + RuntimeError: If the owned gift has no text. + + """ + if not self.text: + raise RuntimeError("This OwnedGiftRegular has no 'text'.") + + return parse_message_entities(self.text, self.entities, types) + + +class OwnedGiftUnique(OwnedGift): + """ + Describes a unique gift received and owned by a user or a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`gift` and :attr:`send_date` are equal. + + .. versionadded:: 22.1 + + Args: + gift (:class:`telegram.UniqueGift`): Information about the unique gift. + owned_gift_id (:obj:`str`, optional): Unique identifier of the received gift for the + bot; for gifts received on behalf of business accounts only. + sender_user (:class:`telegram.User`, optional): Sender of the gift if it is a known user. + send_date (:obj:`datetime.datetime`): Date the gift was sent as :class:`datetime.datetime`. + |datetime_localization| + is_saved (:obj:`bool`, optional): :obj:`True`, if the gift is displayed on the account's + profile page; for gifts received on behalf of business accounts only. + can_be_transferred (:obj:`bool`, optional): :obj:`True`, if the gift can be transferred to + another owner; for gifts received on behalf of business accounts only. + transfer_star_count (:obj:`int`, optional): Number of Telegram Stars that must be paid + to transfer the gift; omitted if the bot cannot transfer the gift. + next_transfer_date (:obj:`datetime.datetime`, optional): Date when the gift can be + transferred. If it's in the past, then the gift can be transferred now. + |datetime_localization| + .. versionadded:: 22.3 + + Attributes: + type (:obj:`str`): Type of the owned gift, always :tg-const:`~telegram.OwnedGift.UNIQUE`. + gift (:class:`telegram.UniqueGift`): Information about the unique gift. + owned_gift_id (:obj:`str`): Optional. Unique identifier of the received gift for the + bot; for gifts received on behalf of business accounts only. + sender_user (:class:`telegram.User`): Optional. Sender of the gift if it is a known user. + send_date (:obj:`datetime.datetime`): Date the gift was sent as :class:`datetime.datetime`. + |datetime_localization| + is_saved (:obj:`bool`): Optional. :obj:`True`, if the gift is displayed on the account's + profile page; for gifts received on behalf of business accounts only. + can_be_transferred (:obj:`bool`): Optional. :obj:`True`, if the gift can be transferred to + another owner; for gifts received on behalf of business accounts only. + transfer_star_count (:obj:`int`): Optional. Number of Telegram Stars that must be paid + to transfer the gift; omitted if the bot cannot transfer the gift. + next_transfer_date (:obj:`datetime.datetime`): Optional. Date when the gift can be + transferred. If it's in the past, then the gift can be transferred now. + |datetime_localization| + .. versionadded:: 22.3 + """ + + __slots__ = ( + "can_be_transferred", + "gift", + "is_saved", + "next_transfer_date", + "owned_gift_id", + "send_date", + "sender_user", + "transfer_star_count", + ) + + def __init__( + self, + gift: UniqueGift, + send_date: dtm.datetime, + owned_gift_id: str | None = None, + sender_user: User | None = None, + is_saved: bool | None = None, + can_be_transferred: bool | None = None, + transfer_star_count: int | None = None, + next_transfer_date: dtm.datetime | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=OwnedGift.UNIQUE, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.gift: UniqueGift = gift + self.send_date: dtm.datetime = send_date + self.owned_gift_id: str | None = owned_gift_id + self.sender_user: User | None = sender_user + self.is_saved: bool | None = is_saved + self.can_be_transferred: bool | None = can_be_transferred + self.transfer_star_count: int | None = transfer_star_count + self.next_transfer_date: dtm.datetime | None = next_transfer_date + + self._id_attrs = (self.type, self.gift, self.send_date) + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "OwnedGiftUnique": + """See :meth:`telegram.OwnedGift.de_json`.""" + data = cls._parse_data(data) + + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["send_date"] = from_timestamp(data.get("send_date"), tzinfo=loc_tzinfo) + data["sender_user"] = de_json_optional(data.get("sender_user"), User, bot) + data["gift"] = de_json_optional(data.get("gift"), UniqueGift, bot) + data["next_transfer_date"] = from_timestamp( + data.get("next_transfer_date"), tzinfo=loc_tzinfo + ) + + return super().de_json(data=data, bot=bot) # type: ignore[return-value] diff --git a/src/telegram/_paidmedia.py b/src/telegram/_paidmedia.py new file mode 100644 index 00000000000..67af46710a5 --- /dev/null +++ b/src/telegram/_paidmedia.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING, Final + +from telegram import constants +from telegram._files.photosize import PhotoSize +from telegram._files.video import Video +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils import enum +from telegram._utils.argumentparsing import ( + de_json_optional, + de_list_optional, + parse_sequence_arg, + to_timedelta, +) +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import JSONDict, TimePeriod + +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: JSONDict | None = 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: JSONDict, bot: "Bot | None" = None) -> "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) + + _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) + + if "duration" in data: + data["duration"] = dtm.timedelta(seconds=s) if (s := data.get("duration")) else None + + 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 + + .. versionchanged:: v22.2 + As part of the migration to representing time periods using ``datetime.timedelta``, + equality comparison now considers integer durations and equivalent timedeltas as equal. + + 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` | :class:`datetime.timedelta`, optional): Duration of the media in + seconds as defined by the sender. + + .. versionchanged:: v22.2 + |time-period-input| + + 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` | :class:`datetime.timedelta`): Optional. Duration of the media in + seconds as defined by the sender. + + .. deprecated:: v22.2 + |time-period-int-deprecated| + """ + + __slots__ = ("_duration", "height", "width") + + def __init__( + self, + width: int | None = None, + height: int | None = None, + duration: TimePeriod | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=PaidMedia.PREVIEW, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.width: int | None = width + self.height: int | None = height + self._duration: dtm.timedelta | None = to_timedelta(duration) + + self._id_attrs = (self.type, self.width, self.height, self._duration) + + @property + def duration(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._duration, attribute="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: JSONDict | None = 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: JSONDict, bot: "Bot | None" = None) -> "PaidMediaPhoto": + data = cls._parse_data(data) + + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, 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: JSONDict | None = 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: JSONDict, bot: "Bot | None" = None) -> "PaidMediaVideo": + data = cls._parse_data(data) + + data["video"] = de_json_optional(data.get("video"), Video, 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: JSONDict | None = 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: JSONDict, bot: "Bot | None" = None) -> "PaidMediaInfo": + data = cls._parse_data(data) + + data["paid_media"] = de_list_optional(data.get("paid_media"), PaidMedia, bot) + return super().de_json(data=data, bot=bot) + + +class PaidMediaPurchased(TelegramObject): + """This object contains information about a paid media purchase. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`from_user` and :attr:`paid_media_payload` are equal. + + Note: + In Python :keyword:`from` is a reserved word. Use :paramref:`from_user` instead. + + .. versionadded:: 21.6 + + Args: + from_user (:class:`telegram.User`): User who purchased the media. + paid_media_payload (:obj:`str`): Bot-specified paid media payload. + + Attributes: + from_user (:class:`telegram.User`): User who purchased the media. + paid_media_payload (:obj:`str`): Bot-specified paid media payload. + """ + + __slots__ = ("from_user", "paid_media_payload") + + def __init__( + self, + from_user: "User", + paid_media_payload: str, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.from_user: User = from_user + self.paid_media_payload: str = paid_media_payload + + self._id_attrs = (self.from_user, self.paid_media_payload) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PaidMediaPurchased": + data = cls._parse_data(data) + + data["from_user"] = User.de_json(data=data.pop("from"), bot=bot) + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_paidmessagepricechanged.py b/src/telegram/_paidmessagepricechanged.py new file mode 100644 index 00000000000..d5fa78692ee --- /dev/null +++ b/src/telegram/_paidmessagepricechanged.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 describes a price change of a paid message.""" + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class PaidMessagePriceChanged(TelegramObject): + """Describes a service message about a change in the price of paid messages within a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`paid_message_star_count` is equal. + + .. versionadded:: 22.1 + + Args: + paid_message_star_count (:obj:`int`): The new number of Telegram Stars that must be paid by + non-administrator users of the supergroup chat for each sent message + + Attributes: + paid_message_star_count (:obj:`int`): The new number of Telegram Stars that must be paid by + non-administrator users of the supergroup chat for each sent message + """ + + __slots__ = ("paid_message_star_count",) + + def __init__( + self, + paid_message_star_count: int, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.paid_message_star_count: int = paid_message_star_count + + self._id_attrs = (self.paid_message_star_count,) + self._freeze() diff --git a/telegram/py.typed b/src/telegram/_passport/__init__.py similarity index 100% rename from telegram/py.typed rename to src/telegram/_passport/__init__.py diff --git a/telegram/_passport/credentials.py b/src/telegram/_passport/credentials.py similarity index 74% rename from telegram/_passport/credentials.py rename to src/telegram/_passport/credentials.py index 8fbe7589c0f..d5a16443980 100644 --- a/telegram/_passport/credentials.py +++ b/src/telegram/_passport/credentials.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,7 +19,8 @@ # pylint: disable=missing-module-docstring, redefined-builtin import json from base64 import b64decode -from typing import TYPE_CHECKING, Optional, Sequence, Tuple, no_type_check +from collections.abc import Sequence +from typing import TYPE_CHECKING, no_type_check try: from cryptography.hazmat.backends import default_backend @@ -38,7 +39,8 @@ CRYPTO_INSTALLED = False from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg +from telegram._utils.strings import TextEncoding from telegram._utils.types import JSONDict from telegram.error import PassportDecryptionError @@ -47,7 +49,7 @@ @no_type_check -def decrypt(secret, hash, data): # skipcq: PYL-W0622 +def decrypt(secret, hash, data): """ Decrypt per telegram docs at https://core.telegram.org/passport. @@ -71,7 +73,7 @@ def decrypt(secret, hash, data): # skipcq: PYL-W0622 if not CRYPTO_INSTALLED: raise RuntimeError( "To use Telegram Passports, PTB must be installed via `pip install " - "python-telegram-bot[passport]`." + '"python-telegram-bot[passport]"`.' ) # Make a SHA512 hash of secret + update digest = Hash(SHA512(), backend=default_backend()) @@ -96,9 +98,9 @@ def decrypt(secret, hash, data): # skipcq: PYL-W0622 @no_type_check -def decrypt_json(secret, hash, data): # skipcq: PYL-W0622 +def decrypt_json(secret, hash, data): """Decrypts data using secret and hash and then decodes utf-8 string and loads json""" - return json.loads(decrypt(secret, hash, data).decode("utf-8")) + return json.loads(decrypt(secret, hash, data).decode(TextEncoding.UTF_8)) class EncryptedCredentials(TelegramObject): @@ -111,7 +113,7 @@ class EncryptedCredentials(TelegramObject): Note: This object is decrypted only when originating from - :obj:`telegram.PassportData.decrypted_credentials`. + :attr:`telegram.PassportData.decrypted_credentials`. Args: data (:class:`telegram.Credentials` | :obj:`str`): Decrypted data with unique user's @@ -130,20 +132,20 @@ class EncryptedCredentials(TelegramObject): """ __slots__ = ( + "_decrypted_data", + "_decrypted_secret", + "data", "hash", "secret", - "data", - "_decrypted_secret", - "_decrypted_data", ) def __init__( self, data: str, - hash: str, # skipcq: PYL-W0622 + hash: str, secret: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -153,15 +155,15 @@ def __init__( self._id_attrs = (self.data, self.hash, self.secret) - self._decrypted_secret: Optional[str] = None - self._decrypted_data: Optional["Credentials"] = None + self._decrypted_secret: bytes | None = None + self._decrypted_data: Credentials | None = None self._freeze() @property - def decrypted_secret(self) -> str: + def decrypted_secret(self) -> bytes: """ - :obj:`str`: Lazily decrypt and return secret. + :obj:`bytes`: Lazily decrypt and return secret. Raises: telegram.error.PassportDecryptionError: Decryption failed. Usually due to bad @@ -171,7 +173,7 @@ def decrypted_secret(self) -> str: if not CRYPTO_INSTALLED: raise RuntimeError( "To use Telegram Passports, PTB must be installed via `pip install " - "python-telegram-bot[passport]`." + '"python-telegram-bot[passport]"`.' ) # Try decrypting according to step 1 at # https://core.telegram.org/passport#decrypting-data @@ -205,7 +207,7 @@ def decrypted_data(self) -> "Credentials": decrypt_json(self.decrypted_secret, b64decode(self.hash), b64decode(self.data)), self.get_bot(), ) - return self._decrypted_data # type: ignore[return-value] + return self._decrypted_data class Credentials(TelegramObject): @@ -222,7 +224,7 @@ def __init__( secure_data: "SecureData", nonce: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -232,14 +234,11 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Credentials"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Credentials": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["secure_data"] = SecureData.de_json(data.get("secure_data"), bot=bot) + data["secure_data"] = de_json_optional(data.get("secure_data"), SecureData, bot) return super().de_json(data=data, bot=bot) @@ -295,75 +294,74 @@ class SecureData(TelegramObject): """ __slots__ = ( - "utility_bill", - "personal_details", - "temporary_registration", "address", + "bank_statement", "driver_license", - "rental_agreement", - "internal_passport", "identity_card", - "bank_statement", + "internal_passport", "passport", "passport_registration", + "personal_details", + "rental_agreement", + "temporary_registration", + "utility_bill", ) def __init__( self, - personal_details: Optional["SecureValue"] = None, - passport: Optional["SecureValue"] = None, - internal_passport: Optional["SecureValue"] = None, - driver_license: Optional["SecureValue"] = None, - identity_card: Optional["SecureValue"] = None, - address: Optional["SecureValue"] = None, - utility_bill: Optional["SecureValue"] = None, - bank_statement: Optional["SecureValue"] = None, - rental_agreement: Optional["SecureValue"] = None, - passport_registration: Optional["SecureValue"] = None, - temporary_registration: Optional["SecureValue"] = None, + personal_details: "SecureValue | None" = None, + passport: "SecureValue | None" = None, + internal_passport: "SecureValue | None" = None, + driver_license: "SecureValue | None" = None, + identity_card: "SecureValue | None" = None, + address: "SecureValue | None" = None, + utility_bill: "SecureValue | None" = None, + bank_statement: "SecureValue | None" = None, + rental_agreement: "SecureValue | None" = None, + passport_registration: "SecureValue | None" = None, + temporary_registration: "SecureValue | None" = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Optionals - self.temporary_registration: Optional[SecureValue] = temporary_registration - self.passport_registration: Optional[SecureValue] = passport_registration - self.rental_agreement: Optional[SecureValue] = rental_agreement - self.bank_statement: Optional[SecureValue] = bank_statement - self.utility_bill: Optional[SecureValue] = utility_bill - self.address: Optional[SecureValue] = address - self.identity_card: Optional[SecureValue] = identity_card - self.driver_license: Optional[SecureValue] = driver_license - self.internal_passport: Optional[SecureValue] = internal_passport - self.passport: Optional[SecureValue] = passport - self.personal_details: Optional[SecureValue] = personal_details + self.temporary_registration: SecureValue | None = temporary_registration + self.passport_registration: SecureValue | None = passport_registration + self.rental_agreement: SecureValue | None = rental_agreement + self.bank_statement: SecureValue | None = bank_statement + self.utility_bill: SecureValue | None = utility_bill + self.address: SecureValue | None = address + self.identity_card: SecureValue | None = identity_card + self.driver_license: SecureValue | None = driver_license + self.internal_passport: SecureValue | None = internal_passport + self.passport: SecureValue | None = passport + self.personal_details: SecureValue | None = personal_details self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["SecureData"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "SecureData": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["temporary_registration"] = SecureValue.de_json( - data.get("temporary_registration"), bot=bot + data["temporary_registration"] = de_json_optional( + data.get("temporary_registration"), SecureValue, bot + ) + data["passport_registration"] = de_json_optional( + data.get("passport_registration"), SecureValue, bot ) - data["passport_registration"] = SecureValue.de_json( - data.get("passport_registration"), bot=bot + data["rental_agreement"] = de_json_optional(data.get("rental_agreement"), SecureValue, bot) + data["bank_statement"] = de_json_optional(data.get("bank_statement"), SecureValue, bot) + data["utility_bill"] = de_json_optional(data.get("utility_bill"), SecureValue, bot) + data["address"] = de_json_optional(data.get("address"), SecureValue, bot) + data["identity_card"] = de_json_optional(data.get("identity_card"), SecureValue, bot) + data["driver_license"] = de_json_optional(data.get("driver_license"), SecureValue, bot) + data["internal_passport"] = de_json_optional( + data.get("internal_passport"), SecureValue, bot ) - data["rental_agreement"] = SecureValue.de_json(data.get("rental_agreement"), bot=bot) - data["bank_statement"] = SecureValue.de_json(data.get("bank_statement"), bot=bot) - data["utility_bill"] = SecureValue.de_json(data.get("utility_bill"), bot=bot) - data["address"] = SecureValue.de_json(data.get("address"), bot=bot) - data["identity_card"] = SecureValue.de_json(data.get("identity_card"), bot=bot) - data["driver_license"] = SecureValue.de_json(data.get("driver_license"), bot=bot) - data["internal_passport"] = SecureValue.de_json(data.get("internal_passport"), bot=bot) - data["passport"] = SecureValue.de_json(data.get("passport"), bot=bot) - data["personal_details"] = SecureValue.de_json(data.get("personal_details"), bot=bot) + data["passport"] = de_json_optional(data.get("passport"), SecureValue, bot) + data["personal_details"] = de_json_optional(data.get("personal_details"), SecureValue, bot) return super().de_json(data=data, bot=bot) @@ -385,11 +383,11 @@ class SecureValue(TelegramObject): selfie (:class:`telegram.FileCredentials`, optional): Credentials for encrypted selfie of the user with a document. Can be available for "passport", "driver_license", "identity_card" and "internal_passport". - translation (List[:class:`telegram.FileCredentials`], optional): Credentials for an + translation (list[:class:`telegram.FileCredentials`], optional): Credentials for an encrypted translation of the document. Available for "passport", "driver_license", "identity_card", "internal_passport", "utility_bill", "bank_statement", "rental_agreement", "passport_registration" and "temporary_registration". - files (List[:class:`telegram.FileCredentials`], optional): Credentials for encrypted + files (list[:class:`telegram.FileCredentials`], optional): Credentials for encrypted files. Available for "utility_bill", "bank_statement", "rental_agreement", "passport_registration" and "temporary_registration" types. @@ -405,7 +403,7 @@ class SecureValue(TelegramObject): selfie (:class:`telegram.FileCredentials`): Optional. Credentials for encrypted selfie of the user with a document. Can be available for "passport", "driver_license", "identity_card" and "internal_passport". - translation (Tuple[:class:`telegram.FileCredentials`]): Optional. Credentials for an + translation (tuple[:class:`telegram.FileCredentials`]): Optional. Credentials for an encrypted translation of the document. Available for "passport", "driver_license", "identity_card", "internal_passport", "utility_bill", "bank_statement", "rental_agreement", "passport_registration" and "temporary_registration". @@ -413,7 +411,7 @@ class SecureValue(TelegramObject): .. versionchanged:: 20.0 |tupleclassattrs| - files (Tuple[:class:`telegram.FileCredentials`]): Optional. Credentials for encrypted + files (tuple[:class:`telegram.FileCredentials`]): Optional. Credentials for encrypted files. Available for "utility_bill", "bank_statement", "rental_agreement", "passport_registration" and "temporary_registration" types. @@ -424,43 +422,40 @@ class SecureValue(TelegramObject): """ - __slots__ = ("data", "front_side", "reverse_side", "selfie", "files", "translation") + __slots__ = ("data", "files", "front_side", "reverse_side", "selfie", "translation") def __init__( self, - data: Optional["DataCredentials"] = None, - front_side: Optional["FileCredentials"] = None, - reverse_side: Optional["FileCredentials"] = None, - selfie: Optional["FileCredentials"] = None, - files: Optional[Sequence["FileCredentials"]] = None, - translation: Optional[Sequence["FileCredentials"]] = None, + data: "DataCredentials | None" = None, + front_side: "FileCredentials | None" = None, + reverse_side: "FileCredentials | None" = None, + selfie: "FileCredentials | None" = None, + files: Sequence["FileCredentials"] | None = None, + translation: Sequence["FileCredentials"] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) - self.data: Optional[DataCredentials] = data - self.front_side: Optional[FileCredentials] = front_side - self.reverse_side: Optional[FileCredentials] = reverse_side - self.selfie: Optional[FileCredentials] = selfie - self.files: Tuple["FileCredentials", ...] = parse_sequence_arg(files) - self.translation: Tuple["FileCredentials", ...] = parse_sequence_arg(translation) + self.data: DataCredentials | None = data + self.front_side: FileCredentials | None = front_side + self.reverse_side: FileCredentials | None = reverse_side + self.selfie: FileCredentials | None = selfie + self.files: tuple[FileCredentials, ...] = parse_sequence_arg(files) + self.translation: tuple[FileCredentials, ...] = parse_sequence_arg(translation) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["SecureValue"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "SecureValue": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["data"] = DataCredentials.de_json(data.get("data"), bot=bot) - data["front_side"] = FileCredentials.de_json(data.get("front_side"), bot=bot) - data["reverse_side"] = FileCredentials.de_json(data.get("reverse_side"), bot=bot) - data["selfie"] = FileCredentials.de_json(data.get("selfie"), bot=bot) - data["files"] = FileCredentials.de_list(data.get("files"), bot=bot) - data["translation"] = FileCredentials.de_list(data.get("translation"), bot=bot) + data["data"] = de_json_optional(data.get("data"), DataCredentials, bot) + data["front_side"] = de_json_optional(data.get("front_side"), FileCredentials, bot) + data["reverse_side"] = de_json_optional(data.get("reverse_side"), FileCredentials, bot) + data["selfie"] = de_json_optional(data.get("selfie"), FileCredentials, bot) + data["files"] = de_list_optional(data.get("files"), FileCredentials, bot) + data["translation"] = de_list_optional(data.get("translation"), FileCredentials, bot) return super().de_json(data=data, bot=bot) @@ -468,10 +463,14 @@ def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["SecureValue" class _CredentialsBase(TelegramObject): """Base class for DataCredentials and FileCredentials.""" - __slots__ = ("hash", "secret", "file_hash", "data_hash") + __slots__ = ("data_hash", "file_hash", "hash", "secret") def __init__( - self, hash: str, secret: str, *, api_kwargs: Optional[JSONDict] = None # skipcq: PYL-W0622 + self, + hash: str, + secret: str, + *, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) with self._unfrozen(): @@ -499,7 +498,7 @@ class DataCredentials(_CredentialsBase): __slots__ = () - def __init__(self, data_hash: str, secret: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, data_hash: str, secret: str, *, api_kwargs: JSONDict | None = None): super().__init__(hash=data_hash, secret=secret, api_kwargs=api_kwargs) self._freeze() @@ -520,6 +519,6 @@ class FileCredentials(_CredentialsBase): __slots__ = () - def __init__(self, file_hash: str, secret: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, file_hash: str, secret: str, *, api_kwargs: JSONDict | None = None): super().__init__(hash=file_hash, secret=secret, api_kwargs=api_kwargs) self._freeze() diff --git a/telegram/_passport/data.py b/src/telegram/_passport/data.py similarity index 90% rename from telegram/_passport/data.py rename to src/telegram/_passport/data.py index 8e3827059e8..57261496a8e 100644 --- a/telegram/_passport/data.py +++ b/src/telegram/_passport/data.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # 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 -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -61,16 +60,16 @@ class PersonalDetails(TelegramObject): """ __slots__ = ( - "middle_name", - "first_name_native", - "last_name_native", - "residence_country_code", - "first_name", - "last_name", + "birth_date", "country_code", + "first_name", + "first_name_native", "gender", + "last_name", + "last_name_native", + "middle_name", "middle_name_native", - "birth_date", + "residence_country_code", ) def __init__( @@ -81,25 +80,25 @@ def __init__( gender: str, country_code: str, residence_country_code: str, - first_name_native: Optional[str] = None, - last_name_native: Optional[str] = None, - middle_name: Optional[str] = None, - middle_name_native: Optional[str] = None, + first_name_native: str | None = None, + last_name_native: str | None = None, + middle_name: str | None = None, + middle_name_native: str | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.first_name: str = first_name self.last_name: str = last_name - self.middle_name: Optional[str] = middle_name + self.middle_name: str | None = middle_name self.birth_date: str = birth_date self.gender: str = gender self.country_code: str = country_code self.residence_country_code: str = residence_country_code - self.first_name_native: Optional[str] = first_name_native - self.last_name_native: Optional[str] = last_name_native - self.middle_name_native: Optional[str] = middle_name_native + self.first_name_native: str | None = first_name_native + self.last_name_native: str | None = last_name_native + self.middle_name_native: str | None = middle_name_native self._freeze() @@ -126,12 +125,12 @@ class ResidentialAddress(TelegramObject): """ __slots__ = ( - "post_code", "city", "country_code", - "street_line2", - "street_line1", + "post_code", "state", + "street_line1", + "street_line2", ) def __init__( @@ -143,7 +142,7 @@ def __init__( country_code: str, post_code: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -177,7 +176,7 @@ def __init__( document_no: str, expiry_date: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.document_no: str = document_no diff --git a/telegram/_passport/encryptedpassportelement.py b/src/telegram/_passport/encryptedpassportelement.py similarity index 58% rename from telegram/_passport/encryptedpassportelement.py rename to src/telegram/_passport/encryptedpassportelement.py index d680e3686da..8b4bdfbd77e 100644 --- a/telegram/_passport/encryptedpassportelement.py +++ b/src/telegram/_passport/encryptedpassportelement.py @@ -1,7 +1,6 @@ #!/usr/bin/env python -# flake8: noqa: E501 # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,14 +16,22 @@ # 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 EncryptedPassportElement.""" + from base64 import b64decode -from typing import TYPE_CHECKING, Optional, Sequence, Tuple +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._passport.credentials import decrypt_json from telegram._passport.data import IdDocumentData, PersonalDetails, ResidentialAddress from telegram._passport.passportfile import PassportFile from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import ( + de_json_decrypted_optional, + de_json_optional, + de_list_decrypted_optional, + de_list_optional, + parse_sequence_arg, +) from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -42,7 +49,7 @@ class EncryptedPassportElement(TelegramObject): Note: This object is decrypted only when originating from - :obj:`telegram.PassportData.decrypted_data`. + :attr:`telegram.PassportData.decrypted_data`. Args: type (:obj:`str`): Element type. One of "personal_details", "passport", "driver_license", @@ -53,35 +60,34 @@ class EncryptedPassportElement(TelegramObject): :class:`telegram.PassportElementErrorUnspecified`. data (:class:`telegram.PersonalDetails` | :class:`telegram.IdDocumentData` | \ :class:`telegram.ResidentialAddress` | :obj:`str`, optional): - Decrypted or encrypted data, available for "personal_details", "passport", - "driver_license", "identity_card", "identity_passport" and "address" types. - phone_number (:obj:`str`, optional): User's verified phone number, available only for + Decrypted or encrypted data; available only for "personal_details", "passport", + "driver_license", "identity_card", "internal_passport" and "address" types. + phone_number (:obj:`str`, optional): User's verified phone number; available only for "phone_number" type. - email (:obj:`str`, optional): User's verified email address, available only for "email" + email (:obj:`str`, optional): User's verified email address; available only for "email" type. files (Sequence[:class:`telegram.PassportFile`], optional): Array of encrypted/decrypted - files - with documents provided by the user, available for "utility_bill", "bank_statement", - "rental_agreement", "passport_registration" and "temporary_registration" types. + files with documents provided by the user; available only for "utility_bill", + "bank_statement", "rental_agreement", "passport_registration" and + "temporary_registration" types. .. versionchanged:: 20.0 |sequenceclassargs| front_side (:class:`telegram.PassportFile`, optional): Encrypted/decrypted file with the - front side of the document, provided by the user. Available for "passport", + front side of the document, provided by the user; Available only for "passport", "driver_license", "identity_card" and "internal_passport". reverse_side (:class:`telegram.PassportFile`, optional): Encrypted/decrypted file with the - reverse side of the document, provided by the user. Available for "driver_license" and - "identity_card". + reverse side of the document, provided by the user; Available only for + "driver_license" and "identity_card". selfie (:class:`telegram.PassportFile`, optional): Encrypted/decrypted file with the - selfie of the user holding a document, provided by the user; available for "passport", - "driver_license", "identity_card" and "internal_passport". + selfie of the user holding a document, provided by the user; available if requested for + "passport", "driver_license", "identity_card" and "internal_passport". translation (Sequence[:class:`telegram.PassportFile`], optional): Array of - encrypted/decrypted - files with translated versions of documents provided by the user. Available if - requested for "passport", "driver_license", "identity_card", "internal_passport", - "utility_bill", "bank_statement", "rental_agreement", "passport_registration" and - "temporary_registration" types. + encrypted/decrypted files with translated versions of documents provided by the user; + available if requested requested for "passport", "driver_license", "identity_card", + "internal_passport", "utility_bill", "bank_statement", "rental_agreement", + "passport_registration" and "temporary_registration" types. .. versionchanged:: 20.0 |sequenceclassargs| @@ -95,16 +101,16 @@ class EncryptedPassportElement(TelegramObject): :class:`telegram.PassportElementErrorUnspecified`. data (:class:`telegram.PersonalDetails` | :class:`telegram.IdDocumentData` | \ :class:`telegram.ResidentialAddress` | :obj:`str`): - Optional. Decrypted or encrypted data, available for "personal_details", "passport", - "driver_license", "identity_card", "identity_passport" and "address" types. - phone_number (:obj:`str`): Optional. User's verified phone number, available only for + Optional. Decrypted or encrypted data; available only for "personal_details", + "passport", "driver_license", "identity_card", "internal_passport" and "address" types. + phone_number (:obj:`str`): Optional. User's verified phone number; available only for "phone_number" type. - email (:obj:`str`): Optional. User's verified email address, available only for "email" + email (:obj:`str`): Optional. User's verified email address; available only for "email" type. - files (Tuple[:class:`telegram.PassportFile`]): Optional. Array of encrypted/decrypted - files - with documents provided by the user, available for "utility_bill", "bank_statement", - "rental_agreement", "passport_registration" and "temporary_registration" types. + files (tuple[:class:`telegram.PassportFile`]): Optional. Array of encrypted/decrypted + files with documents provided by the user; available only for "utility_bill", + "bank_statement", "rental_agreement", "passport_registration" and + "temporary_registration" types. .. versionchanged:: 20.0 @@ -112,20 +118,19 @@ class EncryptedPassportElement(TelegramObject): * |alwaystuple| front_side (:class:`telegram.PassportFile`): Optional. Encrypted/decrypted file with the - front side of the document, provided by the user. Available for "passport", + front side of the document, provided by the user; available only for "passport", "driver_license", "identity_card" and "internal_passport". reverse_side (:class:`telegram.PassportFile`): Optional. Encrypted/decrypted file with the - reverse side of the document, provided by the user. Available for "driver_license" and - "identity_card". + reverse side of the document, provided by the user; available only for "driver_license" + and "identity_card". selfie (:class:`telegram.PassportFile`): Optional. Encrypted/decrypted file with the - selfie of the user holding a document, provided by the user; available for "passport", - "driver_license", "identity_card" and "internal_passport". - translation (Tuple[:class:`telegram.PassportFile`]): Optional. Array of - encrypted/decrypted - files with translated versions of documents provided by the user. Available if - requested for "passport", "driver_license", "identity_card", "internal_passport", - "utility_bill", "bank_statement", "rental_agreement", "passport_registration" and - "temporary_registration" types. + selfie of the user holding a document, provided by the user; available if requested for + "passport", "driver_license", "identity_card" and "internal_passport". + translation (tuple[:class:`telegram.PassportFile`]): Optional. Array of + encrypted/decrypted files with translated versions of documents provided by the user; + available if requested for "passport", "driver_license", "identity_card", + "internal_passport", "utility_bill", "bank_statement", "rental_agreement", + "passport_registration" and "temporary_registration" types. .. versionchanged:: 20.0 @@ -135,47 +140,46 @@ class EncryptedPassportElement(TelegramObject): """ __slots__ = ( - "selfie", - "files", - "type", - "translation", + "data", "email", + "files", + "front_side", "hash", "phone_number", "reverse_side", - "front_side", - "data", + "selfie", + "translation", + "type", ) def __init__( self, type: str, # pylint: disable=redefined-builtin hash: str, # pylint: disable=redefined-builtin - data: Optional[PersonalDetails] = None, - phone_number: Optional[str] = None, - email: Optional[str] = None, - files: Optional[Sequence[PassportFile]] = None, - front_side: Optional[PassportFile] = None, - reverse_side: Optional[PassportFile] = None, - selfie: Optional[PassportFile] = None, - translation: Optional[Sequence[PassportFile]] = None, - credentials: Optional["Credentials"] = None, # pylint: disable=unused-argument + data: PersonalDetails | IdDocumentData | ResidentialAddress | None = None, + phone_number: str | None = None, + email: str | None = None, + files: Sequence[PassportFile] | None = None, + front_side: PassportFile | None = None, + reverse_side: PassportFile | None = None, + selfie: PassportFile | None = None, + translation: Sequence[PassportFile] | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.type: str = type # Optionals - self.data: Optional[PersonalDetails] = data - self.phone_number: Optional[str] = phone_number - self.email: Optional[str] = email - self.files: Tuple[PassportFile, ...] = parse_sequence_arg(files) - self.front_side: Optional[PassportFile] = front_side - self.reverse_side: Optional[PassportFile] = reverse_side - self.selfie: Optional[PassportFile] = selfie - self.translation: Tuple[PassportFile, ...] = parse_sequence_arg(translation) + self.data: PersonalDetails | IdDocumentData | ResidentialAddress | None = data + self.phone_number: str | None = phone_number + self.email: str | None = email + self.files: tuple[PassportFile, ...] = parse_sequence_arg(files) + self.front_side: PassportFile | None = front_side + self.reverse_side: PassportFile | None = reverse_side + self.selfie: PassportFile | None = selfie + self.translation: tuple[PassportFile, ...] = parse_sequence_arg(translation) self.hash: str = hash self._id_attrs = ( @@ -192,39 +196,41 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["EncryptedPassportElement"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "EncryptedPassportElement": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["files"] = PassportFile.de_list(data.get("files"), bot) or None - data["front_side"] = PassportFile.de_json(data.get("front_side"), bot) - data["reverse_side"] = PassportFile.de_json(data.get("reverse_side"), bot) - data["selfie"] = PassportFile.de_json(data.get("selfie"), bot) - data["translation"] = PassportFile.de_list(data.get("translation"), bot) or None + data["files"] = de_list_optional(data.get("files"), PassportFile, bot) or None + data["front_side"] = de_json_optional(data.get("front_side"), PassportFile, bot) + data["reverse_side"] = de_json_optional(data.get("reverse_side"), PassportFile, bot) + data["selfie"] = de_json_optional(data.get("selfie"), PassportFile, bot) + data["translation"] = de_list_optional(data.get("translation"), PassportFile, bot) or None return super().de_json(data=data, bot=bot) @classmethod def de_json_decrypted( - cls, data: Optional[JSONDict], bot: "Bot", credentials: "Credentials" - ) -> Optional["EncryptedPassportElement"]: + cls, data: JSONDict, bot: "Bot | None", credentials: "Credentials" + ) -> "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. + data (dict[:obj:`str`, ...]): The JSON data. + 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: :class:`telegram.EncryptedPassportElement`: """ - if not data: - return None if data["type"] not in ("phone_number", "email"): secure_data = getattr(credentials.secure_data, data["type"]) @@ -250,20 +256,21 @@ def de_json_decrypted( data["data"] = ResidentialAddress.de_json(data["data"], bot=bot) data["files"] = ( - PassportFile.de_list_decrypted(data.get("files"), bot, secure_data.files) or None + de_list_decrypted_optional(data.get("files"), PassportFile, bot, secure_data.files) + or None ) - data["front_side"] = PassportFile.de_json_decrypted( - data.get("front_side"), bot, secure_data.front_side + data["front_side"] = de_json_decrypted_optional( + data.get("front_side"), PassportFile, bot, secure_data.front_side ) - data["reverse_side"] = PassportFile.de_json_decrypted( - data.get("reverse_side"), bot, secure_data.reverse_side + data["reverse_side"] = de_json_decrypted_optional( + data.get("reverse_side"), PassportFile, bot, secure_data.reverse_side ) - data["selfie"] = PassportFile.de_json_decrypted( - data.get("selfie"), bot, secure_data.selfie + data["selfie"] = de_json_decrypted_optional( + data.get("selfie"), PassportFile, bot, secure_data.selfie ) data["translation"] = ( - PassportFile.de_list_decrypted( - data.get("translation"), bot, secure_data.translation + de_list_decrypted_optional( + data.get("translation"), PassportFile, bot, secure_data.translation ) or None ) diff --git a/telegram/_passport/passportdata.py b/src/telegram/_passport/passportdata.py similarity index 82% rename from telegram/_passport/passportdata.py rename to src/telegram/_passport/passportdata.py index 805203b058f..deae7196f3a 100644 --- a/telegram/_passport/passportdata.py +++ b/src/telegram/_passport/passportdata.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,12 +17,14 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Contains information about Telegram Passport data shared with the bot by the user.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._passport.credentials import EncryptedCredentials from telegram._passport.encryptedpassportelement import EncryptedPassportElement from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import parse_sequence_arg +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -49,7 +51,7 @@ class PassportData(TelegramObject): credentials (:class:`telegram.EncryptedCredentials`)): Encrypted credentials. Attributes: - data (Tuple[:class:`telegram.EncryptedPassportElement`]): Array with encrypted + data (tuple[:class:`telegram.EncryptedPassportElement`]): Array with encrypted information about documents and other Telegram Passport elements that was shared with the bot. @@ -61,42 +63,39 @@ class PassportData(TelegramObject): """ - __slots__ = ("credentials", "data", "_decrypted_data") + __slots__ = ("_decrypted_data", "credentials", "data") def __init__( self, data: Sequence[EncryptedPassportElement], credentials: EncryptedCredentials, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) - self.data: Tuple[EncryptedPassportElement, ...] = parse_sequence_arg(data) + self.data: tuple[EncryptedPassportElement, ...] = parse_sequence_arg(data) self.credentials: EncryptedCredentials = credentials - self._decrypted_data: Optional[Tuple[EncryptedPassportElement]] = None + self._decrypted_data: tuple[EncryptedPassportElement] | None = None self._id_attrs = tuple([x.type for x in data] + [credentials.hash]) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["PassportData"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PassportData": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["data"] = EncryptedPassportElement.de_list(data.get("data"), bot) - data["credentials"] = EncryptedCredentials.de_json(data.get("credentials"), bot) + data["data"] = de_list_optional(data.get("data"), EncryptedPassportElement, bot) + data["credentials"] = de_json_optional(data.get("credentials"), EncryptedCredentials, bot) return super().de_json(data=data, bot=bot) @property - def decrypted_data(self) -> Tuple[EncryptedPassportElement, ...]: + def decrypted_data(self) -> tuple[EncryptedPassportElement, ...]: """ - Tuple[:class:`telegram.EncryptedPassportElement`]: Lazily decrypt and return information + tuple[:class:`telegram.EncryptedPassportElement`]: Lazily decrypt and return information about documents and other Telegram Passport elements which were shared with the bot. .. versionchanged:: 20.0 diff --git a/telegram/_passport/passportelementerrors.py b/src/telegram/_passport/passportelementerrors.py similarity index 91% rename from telegram/_passport/passportelementerrors.py rename to src/telegram/_passport/passportelementerrors.py index 96fd9322795..bba589ee2cb 100644 --- a/telegram/_passport/passportelementerrors.py +++ b/src/telegram/_passport/passportelementerrors.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,9 +19,10 @@ # pylint: disable=redefined-builtin """This module contains the classes that represent Telegram PassportElementError.""" -from typing import Optional +from collections.abc import Sequence from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import parse_sequence_arg from telegram._utils.types import JSONDict @@ -49,7 +50,7 @@ class PassportElementError(TelegramObject): __slots__ = ("message", "source", "type") def __init__( - self, source: str, type: str, message: str, *, api_kwargs: Optional[JSONDict] = None + self, source: str, type: str, message: str, *, api_kwargs: JSONDict | None = None ): super().__init__(api_kwargs=api_kwargs) # Required @@ -98,7 +99,7 @@ def __init__( data_hash: str, message: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): # Required super().__init__("data", type, message, api_kwargs=api_kwargs) @@ -143,7 +144,7 @@ class PassportElementErrorFile(PassportElementError): __slots__ = ("file_hash",) def __init__( - self, type: str, file_hash: str, message: str, *, api_kwargs: Optional[JSONDict] = None + self, type: str, file_hash: str, message: str, *, api_kwargs: JSONDict | None = None ): # Required super().__init__("file", type, message, api_kwargs=api_kwargs) @@ -166,14 +167,20 @@ class PassportElementErrorFiles(PassportElementError): type (:obj:`str`): The section of the user's Telegram Passport which has the issue, one of ``"utility_bill"``, ``"bank_statement"``, ``"rental_agreement"``, ``"passport_registration"``, ``"temporary_registration"``. - file_hashes (List[:obj:`str`]): List of base64-encoded file hashes. + file_hashes (Sequence[:obj:`str`]): List of base64-encoded file hashes. + + .. versionchanged:: 22.0 + |sequenceargs| message (:obj:`str`): Error message. Attributes: type (:obj:`str`): The section of the user's Telegram Passport which has the issue, one of ``"utility_bill"``, ``"bank_statement"``, ``"rental_agreement"``, ``"passport_registration"``, ``"temporary_registration"``. - file_hashes (List[:obj:`str`]): List of base64-encoded file hashes. + file_hashes (tuple[:obj:`str`]): List of base64-encoded file hashes. + + .. versionchanged:: 22.0 + |tupleclassattrs| message (:obj:`str`): Error message. """ @@ -181,14 +188,19 @@ class PassportElementErrorFiles(PassportElementError): __slots__ = ("file_hashes",) def __init__( - self, type: str, file_hashes: str, message: str, *, api_kwargs: Optional[JSONDict] = None + self, + type: str, + file_hashes: Sequence[str], + message: str, + *, + api_kwargs: JSONDict | None = None, ): # Required super().__init__("files", type, message, api_kwargs=api_kwargs) with self._unfrozen(): - self.file_hashes: str = file_hashes + self.file_hashes: tuple[str, ...] = parse_sequence_arg(file_hashes) - self._id_attrs = (self.source, self.type, self.message, *tuple(file_hashes)) + self._id_attrs = (self.source, self.type, self.message, self.file_hashes) class PassportElementErrorFrontSide(PassportElementError): @@ -219,7 +231,7 @@ class PassportElementErrorFrontSide(PassportElementError): __slots__ = ("file_hash",) def __init__( - self, type: str, file_hash: str, message: str, *, api_kwargs: Optional[JSONDict] = None + self, type: str, file_hash: str, message: str, *, api_kwargs: JSONDict | None = None ): # Required super().__init__("front_side", type, message, api_kwargs=api_kwargs) @@ -257,7 +269,7 @@ class PassportElementErrorReverseSide(PassportElementError): __slots__ = ("file_hash",) def __init__( - self, type: str, file_hash: str, message: str, *, api_kwargs: Optional[JSONDict] = None + self, type: str, file_hash: str, message: str, *, api_kwargs: JSONDict | None = None ): # Required super().__init__("reverse_side", type, message, api_kwargs=api_kwargs) @@ -293,7 +305,7 @@ class PassportElementErrorSelfie(PassportElementError): __slots__ = ("file_hash",) def __init__( - self, type: str, file_hash: str, message: str, *, api_kwargs: Optional[JSONDict] = None + self, type: str, file_hash: str, message: str, *, api_kwargs: JSONDict | None = None ): # Required super().__init__("selfie", type, message, api_kwargs=api_kwargs) @@ -333,7 +345,7 @@ class PassportElementErrorTranslationFile(PassportElementError): __slots__ = ("file_hash",) def __init__( - self, type: str, file_hash: str, message: str, *, api_kwargs: Optional[JSONDict] = None + self, type: str, file_hash: str, message: str, *, api_kwargs: JSONDict | None = None ): # Required super().__init__("translation_file", type, message, api_kwargs=api_kwargs) @@ -357,7 +369,10 @@ class PassportElementErrorTranslationFiles(PassportElementError): one of ``"passport"``, ``"driver_license"``, ``"identity_card"``, ``"internal_passport"``, ``"utility_bill"``, ``"bank_statement"``, ``"rental_agreement"``, ``"passport_registration"``, ``"temporary_registration"``. - file_hashes (List[:obj:`str`]): List of base64-encoded file hashes. + file_hashes (Sequence[:obj:`str`]): List of base64-encoded file hashes. + + .. versionchanged:: 22.0 + |sequenceargs| message (:obj:`str`): Error message. Attributes: @@ -365,7 +380,10 @@ class PassportElementErrorTranslationFiles(PassportElementError): one of ``"passport"``, ``"driver_license"``, ``"identity_card"``, ``"internal_passport"``, ``"utility_bill"``, ``"bank_statement"``, ``"rental_agreement"``, ``"passport_registration"``, ``"temporary_registration"``. - file_hashes (List[:obj:`str`]): List of base64-encoded file hashes. + file_hashes (tuple[:obj:`str`]): List of base64-encoded file hashes. + + .. versionchanged:: 22.0 + |tupleclassattrs| message (:obj:`str`): Error message. """ @@ -373,14 +391,19 @@ class PassportElementErrorTranslationFiles(PassportElementError): __slots__ = ("file_hashes",) def __init__( - self, type: str, file_hashes: str, message: str, *, api_kwargs: Optional[JSONDict] = None + self, + type: str, + file_hashes: Sequence[str], + message: str, + *, + api_kwargs: JSONDict | None = None, ): # Required super().__init__("translation_files", type, message, api_kwargs=api_kwargs) with self._unfrozen(): - self.file_hashes: str = file_hashes + self.file_hashes: tuple[str, ...] = parse_sequence_arg(file_hashes) - self._id_attrs = (self.source, self.type, self.message, *tuple(file_hashes)) + self._id_attrs = (self.source, self.type, self.message, self.file_hashes) class PassportElementErrorUnspecified(PassportElementError): @@ -407,7 +430,7 @@ class PassportElementErrorUnspecified(PassportElementError): __slots__ = ("element_hash",) def __init__( - self, type: str, element_hash: str, message: str, *, api_kwargs: Optional[JSONDict] = None + self, type: str, element_hash: str, message: str, *, api_kwargs: JSONDict | None = None ): # Required super().__init__("unspecified", type, message, api_kwargs=api_kwargs) diff --git a/telegram/_passport/passportfile.py b/src/telegram/_passport/passportfile.py similarity index 63% rename from telegram/_passport/passportfile.py rename to src/telegram/_passport/passportfile.py index 5d12838e0f6..724fb51ed1c 100644 --- a/telegram/_passport/passportfile.py +++ b/src/telegram/_passport/passportfile.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,9 +18,11 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Encrypted PassportFile.""" -from typing import TYPE_CHECKING, List, Optional, Tuple +import datetime as dtm +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput @@ -43,7 +45,11 @@ class PassportFile(TelegramObject): 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`): File size in bytes. - file_date (:obj:`int`): Unix time when the file was uploaded. + file_date (:class:`datetime.datetime`): Time when the file was uploaded. + + .. versionchanged:: 22.0 + Accepts only :class:`datetime.datetime` instead of :obj:`int`. + |datetime_localization| Attributes: file_id (:obj:`str`): Identifier for this file, which can be used to download @@ -52,16 +58,18 @@ class PassportFile(TelegramObject): 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`): File size in bytes. - file_date (:obj:`int`): Unix time when the file was uploaded. - + file_date (:class:`datetime.datetime`): Time when the file was uploaded. + .. versionchanged:: 22.0 + Returns :class:`datetime.datetime` instead of :obj:`int`. + |datetime_localization| """ __slots__ = ( + "_credentials", "file_date", "file_id", "file_size", - "_credentials", "file_unique_id", ) @@ -69,11 +77,11 @@ def __init__( self, file_id: str, file_unique_id: str, - file_date: int, + file_date: dtm.datetime, file_size: int, - credentials: Optional["FileCredentials"] = None, + credentials: "FileCredentials | None" = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) @@ -81,25 +89,43 @@ def __init__( self.file_id: str = file_id self.file_unique_id: str = file_unique_id self.file_size: int = file_size - self.file_date: int = file_date + self.file_date: dtm.datetime = file_date # Optionals - self._credentials: Optional[FileCredentials] = credentials + self._credentials: FileCredentials | None = credentials self._id_attrs = (self.file_unique_id,) self._freeze() + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PassportFile": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["file_date"] = from_timestamp(data.get("file_date"), tzinfo=loc_tzinfo) + + return super().de_json(data=data, bot=bot) + @classmethod def de_json_decrypted( - cls, data: Optional[JSONDict], bot: "Bot", credentials: "FileCredentials" - ) -> Optional["PassportFile"]: + cls, data: JSONDict, bot: "Bot | None", credentials: "FileCredentials" + ) -> "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. + data (dict[:obj:`str`, ...]): The JSON data. + 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: @@ -108,17 +134,17 @@ def de_json_decrypted( """ data = cls._parse_data(data) - if not data: - return None - data["credentials"] = credentials return super().de_json(data=data, bot=bot) @classmethod def de_list_decrypted( - cls, data: Optional[List[JSONDict]], bot: "Bot", credentials: List["FileCredentials"] - ) -> Tuple[Optional["PassportFile"], ...]: + cls, + data: list[JSONDict], + bot: "Bot | None", + credentials: list["FileCredentials"], + ) -> tuple["PassportFile", ...]: """Variant of :meth:`telegram.TelegramObject.de_list` that also takes into account passport credentials. @@ -128,24 +154,27 @@ def de_list_decrypted( * Filters out any :obj:`None` values Args: - data (List[Dict[:obj:`str`, ...]]): The JSON data. - bot (:class:`telegram.Bot`): The bot associated with these objects. + data (list[dict[:obj:`str`, ...]]): The JSON data. + 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: - Tuple[:class:`telegram.PassportFile`]: + tuple[:class:`telegram.PassportFile`]: """ - if not data: - return () - return tuple( obj for obj in ( cls.de_json_decrypted(passport_file, bot, credentials[i]) for i, passport_file in enumerate(data) ) - if obj is not None ) async def get_file( @@ -155,12 +184,12 @@ async def get_file( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> "File": """ Wrapper over :meth:`telegram.Bot.get_file`. Will automatically assign the correct credentials to the returned :class:`telegram.File` if originating from - :obj:`telegram.PassportData.decrypted_data`. + :attr:`telegram.PassportData.decrypted_data`. For the documentation of the arguments, please see :meth:`telegram.Bot.get_file`. @@ -179,5 +208,6 @@ async def get_file( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) - file.set_credentials(self._credentials) + if self._credentials: + file.set_credentials(self._credentials) return file diff --git a/src/telegram/_payment/__init__.py b/src/telegram/_payment/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/telegram/_payment/invoice.py b/src/telegram/_payment/invoice.py similarity index 78% rename from telegram/_payment/invoice.py rename to src/telegram/_payment/invoice.py index 1f72830c470..be7d7df173a 100644 --- a/telegram/_payment/invoice.py +++ b/src/telegram/_payment/invoice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Invoice.""" -from typing import ClassVar, Optional +from typing import Final from telegram import constants from telegram._telegramobject import TelegramObject @@ -37,10 +37,11 @@ class Invoice(TelegramObject): description (:obj:`str`): Product description. start_parameter (:obj:`str`): Unique bot deep-linking parameter that can be used to generate this invoice. - currency (:obj:`str`): Three-letter ISO 4217 currency code. - 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 + 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 `currencies.json `_, it shows the number of digits past the decimal point for each currency (2 for the majority of currencies). @@ -50,10 +51,11 @@ class Invoice(TelegramObject): description (:obj:`str`): Product description. start_parameter (:obj:`str`): Unique bot deep-linking parameter that can be used to generate this invoice. - currency (:obj:`str`): Three-letter ISO 4217 currency code. - 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 + 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 `currencies.json `_, it shows the number of digits past the decimal point for each currency (2 for the majority of currencies). @@ -62,9 +64,9 @@ class Invoice(TelegramObject): __slots__ = ( "currency", + "description", "start_parameter", "title", - "description", "total_amount", ) @@ -76,7 +78,7 @@ def __init__( currency: str, total_amount: int, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.title: str = title @@ -95,37 +97,37 @@ def __init__( self._freeze() - MIN_TITLE_LENGTH: ClassVar[int] = constants.InvoiceLimit.MIN_TITLE_LENGTH + MIN_TITLE_LENGTH: Final[int] = constants.InvoiceLimit.MIN_TITLE_LENGTH """:const:`telegram.constants.InvoiceLimit.MIN_TITLE_LENGTH` .. versionadded:: 20.0 """ - MAX_TITLE_LENGTH: ClassVar[int] = constants.InvoiceLimit.MAX_TITLE_LENGTH + MAX_TITLE_LENGTH: Final[int] = constants.InvoiceLimit.MAX_TITLE_LENGTH """:const:`telegram.constants.InvoiceLimit.MAX_TITLE_LENGTH` .. versionadded:: 20.0 """ - MIN_DESCRIPTION_LENGTH: ClassVar[int] = constants.InvoiceLimit.MIN_DESCRIPTION_LENGTH + MIN_DESCRIPTION_LENGTH: Final[int] = constants.InvoiceLimit.MIN_DESCRIPTION_LENGTH """:const:`telegram.constants.InvoiceLimit.MIN_DESCRIPTION_LENGTH` .. versionadded:: 20.0 """ - MAX_DESCRIPTION_LENGTH: ClassVar[int] = constants.InvoiceLimit.MAX_DESCRIPTION_LENGTH + MAX_DESCRIPTION_LENGTH: Final[int] = constants.InvoiceLimit.MAX_DESCRIPTION_LENGTH """:const:`telegram.constants.InvoiceLimit.MAX_DESCRIPTION_LENGTH` .. versionadded:: 20.0 """ - MIN_PAYLOAD_LENGTH: ClassVar[int] = constants.InvoiceLimit.MIN_PAYLOAD_LENGTH + MIN_PAYLOAD_LENGTH: Final[int] = constants.InvoiceLimit.MIN_PAYLOAD_LENGTH """:const:`telegram.constants.InvoiceLimit.MIN_PAYLOAD_LENGTH` .. versionadded:: 20.0 """ - MAX_PAYLOAD_LENGTH: ClassVar[int] = constants.InvoiceLimit.MAX_PAYLOAD_LENGTH + MAX_PAYLOAD_LENGTH: Final[int] = constants.InvoiceLimit.MAX_PAYLOAD_LENGTH """:const:`telegram.constants.InvoiceLimit.MAX_PAYLOAD_LENGTH` .. versionadded:: 20.0 """ - MAX_TIP_AMOUNTS: ClassVar[int] = constants.InvoiceLimit.MAX_TIP_AMOUNTS + MAX_TIP_AMOUNTS: Final[int] = constants.InvoiceLimit.MAX_TIP_AMOUNTS """:const:`telegram.constants.InvoiceLimit.MAX_TIP_AMOUNTS` .. versionadded:: 20.0 diff --git a/telegram/_payment/labeledprice.py b/src/telegram/_payment/labeledprice.py similarity index 86% rename from telegram/_payment/labeledprice.py rename to src/telegram/_payment/labeledprice.py index 6abfacbe612..a9526d4f069 100644 --- a/telegram/_payment/labeledprice.py +++ b/src/telegram/_payment/labeledprice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram LabeledPrice.""" -from typing import Optional - from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -36,7 +34,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 +43,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 @@ -53,9 +51,9 @@ class LabeledPrice(TelegramObject): """ - __slots__ = ("label", "amount") + __slots__ = ("amount", "label") - def __init__(self, label: str, amount: int, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, label: str, amount: int, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) self.label: str = label self.amount: int = amount diff --git a/telegram/_payment/orderinfo.py b/src/telegram/_payment/orderinfo.py similarity index 74% rename from telegram/_payment/orderinfo.py rename to src/telegram/_payment/orderinfo.py index f7d7ac73402..c3f8a798d80 100644 --- a/telegram/_payment/orderinfo.py +++ b/src/telegram/_payment/orderinfo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,10 +18,11 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram OrderInfo.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._payment.shippingaddress import ShippingAddress from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -49,35 +50,34 @@ class OrderInfo(TelegramObject): """ - __slots__ = ("email", "shipping_address", "phone_number", "name") + __slots__ = ("email", "name", "phone_number", "shipping_address") def __init__( self, - name: Optional[str] = None, - phone_number: Optional[str] = None, - email: Optional[str] = None, - shipping_address: Optional[str] = None, + name: str | None = None, + phone_number: str | None = None, + email: str | None = None, + shipping_address: ShippingAddress | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) - self.name: Optional[str] = name - self.phone_number: Optional[str] = phone_number - self.email: Optional[str] = email - self.shipping_address: Optional[str] = shipping_address + self.name: str | None = name + self.phone_number: str | None = phone_number + self.email: str | None = email + self.shipping_address: ShippingAddress | None = shipping_address self._id_attrs = (self.name, self.phone_number, self.email, self.shipping_address) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["OrderInfo"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "OrderInfo": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return cls() - - data["shipping_address"] = ShippingAddress.de_json(data.get("shipping_address"), bot) + data["shipping_address"] = de_json_optional( + data.get("shipping_address"), ShippingAddress, bot + ) return super().de_json(data=data, bot=bot) diff --git a/telegram/_payment/precheckoutquery.py b/src/telegram/_payment/precheckoutquery.py similarity index 79% rename from telegram/_payment/precheckoutquery.py rename to src/telegram/_payment/precheckoutquery.py index a6b1528124a..94acae703cb 100644 --- a/telegram/_payment/precheckoutquery.py +++ b/src/telegram/_payment/precheckoutquery.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,11 +18,12 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram PreCheckoutQuery.""" -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from telegram._payment.orderinfo import OrderInfo from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput @@ -42,14 +43,15 @@ class PreCheckoutQuery(TelegramObject): Args: id (:obj:`str`): Unique query identifier. from_user (:class:`telegram.User`): User who sent the query. - currency (:obj:`str`): Three-letter ISO 4217 currency code. - 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``. + 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 `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. @@ -57,14 +59,15 @@ class PreCheckoutQuery(TelegramObject): Attributes: id (:obj:`str`): Unique query identifier. from_user (:class:`telegram.User`): User who sent the query. - currency (:obj:`str`): Three-letter ISO 4217 currency code. - 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``. + 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 `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. @@ -73,13 +76,13 @@ class PreCheckoutQuery(TelegramObject): """ __slots__ = ( - "invoice_payload", - "shipping_option_id", "currency", + "from_user", + "id", + "invoice_payload", "order_info", + "shipping_option_id", "total_amount", - "id", - "from_user", ) def __init__( @@ -89,47 +92,44 @@ def __init__( currency: str, total_amount: int, invoice_payload: str, - shipping_option_id: Optional[str] = None, - order_info: Optional[OrderInfo] = None, + shipping_option_id: str | None = None, + order_info: OrderInfo | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) - self.id: str = id # pylint: disable=invalid-name + self.id: str = id self.from_user: User = from_user self.currency: str = currency self.total_amount: int = total_amount self.invoice_payload: str = invoice_payload - self.shipping_option_id: Optional[str] = shipping_option_id - self.order_info: Optional[OrderInfo] = order_info + self.shipping_option_id: str | None = shipping_option_id + self.order_info: OrderInfo | None = order_info self._id_attrs = (self.id,) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["PreCheckoutQuery"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PreCheckoutQuery": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["from_user"] = User.de_json(data.pop("from", None), bot) - data["order_info"] = OrderInfo.de_json(data.get("order_info"), bot) + data["from_user"] = de_json_optional(data.pop("from", None), User, bot) + data["order_info"] = de_json_optional(data.get("order_info"), OrderInfo, bot) return super().de_json(data=data, bot=bot) - async def answer( # pylint: disable=invalid-name + async def answer( self, ok: bool, - error_message: Optional[str] = None, + error_message: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: diff --git a/src/telegram/_payment/refundedpayment.py b/src/telegram/_payment/refundedpayment.py new file mode 100644 index 00000000000..f29fe0face6 --- /dev/null +++ b/src/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-2026 +# 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 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. + + .. versionadded:: 21.4 + + 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: str | None = None, + *, + api_kwargs: JSONDict | None = 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: str | None = provider_payment_charge_id + + self._id_attrs = (self.telegram_payment_charge_id,) + + self._freeze() diff --git a/telegram/_payment/shippingaddress.py b/src/telegram/_payment/shippingaddress.py similarity index 96% rename from telegram/_payment/shippingaddress.py rename to src/telegram/_payment/shippingaddress.py index 9c55e6dcba2..22e2f0c2860 100644 --- a/telegram/_payment/shippingaddress.py +++ b/src/telegram/_payment/shippingaddress.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram ShippingAddress.""" -from typing import Optional - from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -50,12 +48,12 @@ class ShippingAddress(TelegramObject): """ __slots__ = ( - "post_code", "city", "country_code", - "street_line2", - "street_line1", + "post_code", "state", + "street_line1", + "street_line2", ) def __init__( @@ -67,7 +65,7 @@ def __init__( street_line2: str, post_code: str, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.country_code: str = country_code diff --git a/telegram/_payment/shippingoption.py b/src/telegram/_payment/shippingoption.py similarity index 85% rename from telegram/_payment/shippingoption.py rename to src/telegram/_payment/shippingoption.py index 4526f68c230..0b0a987a013 100644 --- a/telegram/_payment/shippingoption.py +++ b/src/telegram/_payment/shippingoption.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,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/]. """This module contains an object that represents a Telegram ShippingOption.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._utils.argumentparsing import parse_sequence_arg @@ -47,14 +49,14 @@ class ShippingOption(TelegramObject): Attributes: id (:obj:`str`): Shipping option identifier. title (:obj:`str`): Option title. - prices (Tuple[:class:`telegram.LabeledPrice`]): List of price portions. + prices (tuple[:class:`telegram.LabeledPrice`]): List of price portions. .. versionchanged:: 20.0 |tupleclassattrs| """ - __slots__ = ("prices", "title", "id") + __slots__ = ("id", "prices", "title") def __init__( self, @@ -62,13 +64,13 @@ def __init__( title: str, prices: Sequence["LabeledPrice"], *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) - self.id: str = id # pylint: disable=invalid-name + self.id: str = id self.title: str = title - self.prices: Tuple["LabeledPrice", ...] = parse_sequence_arg(prices) + self.prices: tuple[LabeledPrice, ...] = parse_sequence_arg(prices) self._id_attrs = (self.id,) diff --git a/telegram/_payment/shippingquery.py b/src/telegram/_payment/shippingquery.py similarity index 78% rename from telegram/_payment/shippingquery.py rename to src/telegram/_payment/shippingquery.py index 85e94170d9e..2b3f92b3a16 100644 --- a/telegram/_payment/shippingquery.py +++ b/src/telegram/_payment/shippingquery.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,17 +18,19 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram ShippingQuery.""" -from typing import TYPE_CHECKING, Optional, Sequence +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._payment.shippingaddress import ShippingAddress -from telegram._payment.shippingoption import ShippingOption from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import JSONDict, ODVInput if TYPE_CHECKING: from telegram import Bot + from telegram._payment.shippingoption import ShippingOption class ShippingQuery(TelegramObject): @@ -43,19 +45,19 @@ 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. """ - __slots__ = ("invoice_payload", "shipping_address", "id", "from_user") + __slots__ = ("from_user", "id", "invoice_payload", "shipping_address") def __init__( self, @@ -64,10 +66,10 @@ def __init__( invoice_payload: str, shipping_address: ShippingAddress, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) - self.id: str = id # pylint: disable=invalid-name + self.id: str = id self.from_user: User = from_user self.invoice_payload: str = invoice_payload self.shipping_address: ShippingAddress = shipping_address @@ -77,29 +79,28 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ShippingQuery"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ShippingQuery": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["from_user"] = User.de_json(data.pop("from", None), bot) - data["shipping_address"] = ShippingAddress.de_json(data.get("shipping_address"), bot) + data["from_user"] = de_json_optional(data.pop("from", None), User, bot) + data["shipping_address"] = de_json_optional( + data.get("shipping_address"), ShippingAddress, bot + ) return super().de_json(data=data, bot=bot) - async def answer( # pylint: disable=invalid-name + async def answer( self, ok: bool, - shipping_options: Optional[Sequence[ShippingOption]] = None, - error_message: Optional[str] = None, + shipping_options: Sequence["ShippingOption"] | None = None, + error_message: str | None = 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, + api_kwargs: JSONDict | None = None, ) -> bool: """Shortcut for:: diff --git a/src/telegram/_payment/stars/__init__.py b/src/telegram/_payment/stars/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/telegram/_payment/stars/affiliateinfo.py b/src/telegram/_payment/stars/affiliateinfo.py new file mode 100644 index 00000000000..5b178f74483 --- /dev/null +++ b/src/telegram/_payment/stars/affiliateinfo.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 the classes for Telegram Stars affiliates.""" + +from typing import TYPE_CHECKING + +from telegram._chat import Chat +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class AffiliateInfo(TelegramObject): + """Contains information about the affiliate that received a commission via this transaction. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`affiliate_user`, :attr:`affiliate_chat`, + :attr:`commission_per_mille`, :attr:`amount`, and :attr:`nanostar_amount` are equal. + + .. versionadded:: 21.9 + + Args: + affiliate_user (:class:`telegram.User`, optional): The bot or the user that received an + affiliate commission if it was received by a bot or a user + affiliate_chat (:class:`telegram.Chat`, optional): The chat that received an affiliate + commission if it was received by a chat + commission_per_mille (:obj:`int`): The number of Telegram Stars received by the affiliate + for each 1000 Telegram Stars received by the bot from referred users + amount (:obj:`int`): Integer amount of Telegram Stars received by the affiliate from the + transaction, rounded to 0; can be negative for refunds + nanostar_amount (:obj:`int`, optional): The number of + :tg-const:`~telegram.constants.Nanostar.VALUE` shares of Telegram + Stars received by the affiliate; from + :tg-const:`~telegram.constants.NanostarLimit.MIN_AMOUNT` to + :tg-const:`~telegram.constants.NanostarLimit.MAX_AMOUNT`; + can be negative for refunds + + Attributes: + affiliate_user (:class:`telegram.User`): Optional. The bot or the user that received an + affiliate commission if it was received by a bot or a user + affiliate_chat (:class:`telegram.Chat`): Optional. The chat that received an affiliate + commission if it was received by a chat + commission_per_mille (:obj:`int`): The number of Telegram Stars received by the affiliate + for each 1000 Telegram Stars received by the bot from referred users + amount (:obj:`int`): Integer amount of Telegram Stars received by the affiliate from the + transaction, rounded to 0; can be negative for refunds + nanostar_amount (:obj:`int`): Optional. The number of + :tg-const:`~telegram.constants.Nanostar.VALUE` shares of Telegram + Stars received by the affiliate; from + :tg-const:`~telegram.constants.NanostarLimit.MIN_AMOUNT` to + :tg-const:`~telegram.constants.NanostarLimit.MAX_AMOUNT`; + can be negative for refunds + """ + + __slots__ = ( + "affiliate_chat", + "affiliate_user", + "amount", + "commission_per_mille", + "nanostar_amount", + ) + + def __init__( + self, + commission_per_mille: int, + amount: int, + affiliate_user: "User | None" = None, + affiliate_chat: "Chat | None" = None, + nanostar_amount: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.affiliate_user: User | None = affiliate_user + self.affiliate_chat: Chat | None = affiliate_chat + self.commission_per_mille: int = commission_per_mille + self.amount: int = amount + self.nanostar_amount: int | None = nanostar_amount + + self._id_attrs = ( + self.affiliate_user, + self.affiliate_chat, + self.commission_per_mille, + self.amount, + self.nanostar_amount, + ) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "AffiliateInfo": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["affiliate_user"] = de_json_optional(data.get("affiliate_user"), User, bot) + data["affiliate_chat"] = de_json_optional(data.get("affiliate_chat"), Chat, bot) + + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_payment/stars/revenuewithdrawalstate.py b/src/telegram/_payment/stars/revenuewithdrawalstate.py new file mode 100644 index 00000000000..beb0e7de74f --- /dev/null +++ b/src/telegram/_payment/stars/revenuewithdrawalstate.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 Revenue Withdrawals.""" + +import datetime as dtm +from typing import TYPE_CHECKING, Final + +from telegram import constants +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +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 describes 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: JSONDict | None = 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: JSONDict, bot: "Bot | None" = None) -> "RevenueWithdrawalState": + """Converts JSON data to the appropriate :class:`RevenueWithdrawalState` 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) + + _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: JSONDict | None = 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: dtm.datetime, + url: str, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=RevenueWithdrawalState.SUCCEEDED, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.date: dtm.datetime = date + self.url: str = url + self._id_attrs = ( + self.type, + self.date, + ) + + @classmethod + def de_json( + cls, data: JSONDict, bot: "Bot | None" = None + ) -> "RevenueWithdrawalStateSucceeded": + """See :meth:`telegram.RevenueWithdrawalState.de_json`.""" + data = cls._parse_data(data) + + # 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: JSONDict | None = None) -> None: + super().__init__(type=RevenueWithdrawalState.FAILED, api_kwargs=api_kwargs) + self._freeze() diff --git a/src/telegram/_payment/stars/staramount.py b/src/telegram/_payment/stars/staramount.py new file mode 100644 index 00000000000..38765d8c8a9 --- /dev/null +++ b/src/telegram/_payment/stars/staramount.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 StarAmount.""" + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class StarAmount(TelegramObject): + """Describes an amount of Telegram Stars. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`amount` and :attr:`nanostar_amount` are equal. + + Args: + amount (:obj:`int`): Integer amount of Telegram Stars, rounded to ``0``; can be negative. + nanostar_amount (:obj:`int`, optional): The number of + :tg-const:`telegram.constants.Nanostar.VALUE` shares of Telegram + Stars; from :tg-const:`telegram.constants.NanostarLimit.MIN_AMOUNT` + to :tg-const:`telegram.constants.NanostarLimit.MAX_AMOUNT`; can be + negative if and only if :attr:`amount` is non-positive. + + Attributes: + amount (:obj:`int`): Integer amount of Telegram Stars, rounded to ``0``; can be negative. + nanostar_amount (:obj:`int`): Optional. The number of + :tg-const:`telegram.constants.Nanostar.VALUE` shares of Telegram + Stars; from :tg-const:`telegram.constants.NanostarLimit.MIN_AMOUNT` + to :tg-const:`telegram.constants.NanostarLimit.MAX_AMOUNT`; can be + negative if and only if :attr:`amount` is non-positive. + + """ + + __slots__ = ("amount", "nanostar_amount") + + def __init__( + self, + amount: int, + nanostar_amount: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.amount: int = amount + self.nanostar_amount: int | None = nanostar_amount + + self._id_attrs = (self.amount, self.nanostar_amount) + + self._freeze() diff --git a/src/telegram/_payment/stars/startransactions.py b/src/telegram/_payment/stars/startransactions.py new file mode 100644 index 00000000000..b8502fd8fd7 --- /dev/null +++ b/src/telegram/_payment/stars/startransactions.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.types import JSONDict + +from .transactionpartner import TransactionPartner + +if TYPE_CHECKING: + from telegram import Bot + + +class StarTransaction(TelegramObject): + """Describes a Telegram Star transaction. + Note that if the buyer initiates a chargeback with the payment provider from whom they + acquired Stars (e.g., Apple, Google) following this transaction, the refunded Stars will be + deducted from the bot's balance. This is outside of Telegram's control. + + 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`): Integer amount of Telegram Stars transferred by the transaction. + nanostar_amount (:obj:`int`, optional): The number of + :tg-const:`~telegram.constants.Nanostar.VALUE` shares of Telegram + Stars transferred by the transaction; from 0 to + :tg-const:`~telegram.constants.NanostarLimit.MAX_AMOUNT` + + .. versionadded:: 21.9 + 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`): Integer amount of Telegram Stars transferred by the transaction. + nanostar_amount (:obj:`int`): Optional. The number of + :tg-const:`~telegram.constants.Nanostar.VALUE` shares of Telegram + Stars transferred by the transaction; from 0 to + :tg-const:`~telegram.constants.NanostarLimit.MAX_AMOUNT` + + .. versionadded:: 21.9 + 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", "nanostar_amount", "receiver", "source") + + def __init__( + self, + id: str, + amount: int, + date: dtm.datetime, + source: TransactionPartner | None = None, + receiver: TransactionPartner | None = None, + nanostar_amount: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.id: str = id + self.amount: int = amount + self.date: dtm.datetime = date + self.source: TransactionPartner | None = source + self.receiver: TransactionPartner | None = receiver + self.nanostar_amount: int | None = nanostar_amount + + self._id_attrs = ( + self.id, + self.source, + self.receiver, + ) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "StarTransaction": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # 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"] = de_json_optional(data.get("source"), TransactionPartner, bot) + data["receiver"] = de_json_optional(data.get("receiver"), TransactionPartner, 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: JSONDict | None = 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: JSONDict, bot: "Bot | None" = None) -> "StarTransactions": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["transactions"] = de_list_optional(data.get("transactions"), StarTransaction, bot) + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_payment/stars/transactionpartner.py b/src/telegram/_payment/stars/transactionpartner.py new file mode 100644 index 00000000000..09d96c159da --- /dev/null +++ b/src/telegram/_payment/stars/transactionpartner.py @@ -0,0 +1,524 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 transaction partners.""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Final + +from telegram import constants +from telegram._chat import Chat +from telegram._gifts import Gift +from telegram._paidmedia import PaidMedia +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils import enum +from telegram._utils.argumentparsing import ( + de_json_optional, + de_list_optional, + parse_sequence_arg, + to_timedelta, +) +from telegram._utils.types import JSONDict, TimePeriod + +from .affiliateinfo import AffiliateInfo +from .revenuewithdrawalstate import RevenueWithdrawalState + +if TYPE_CHECKING: + import datetime as dtm + + from telegram import Bot + + +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:`TransactionPartnerChat` + * :class:`TransactionPartnerAffiliateProgram` + * :class:`TransactionPartnerFragment` + * :class:`TransactionPartnerTelegramAds` + * :class:`TransactionPartnerTelegramApi` + * :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 + + .. versionchanged:: 21.11 + Added :class:`TransactionPartnerChat` + + Args: + type (:obj:`str`): The type of the transaction partner. + + Attributes: + type (:obj:`str`): The type of the transaction partner. + """ + + __slots__ = ("type",) + + AFFILIATE_PROGRAM: Final[str] = constants.TransactionPartnerType.AFFILIATE_PROGRAM + """:const:`telegram.constants.TransactionPartnerType.AFFILIATE_PROGRAM` + + .. versionadded:: 21.9 + """ + CHAT: Final[str] = constants.TransactionPartnerType.CHAT + """:const:`telegram.constants.TransactionPartnerType.CHAT` + + .. versionadded:: 21.11 + """ + FRAGMENT: Final[str] = constants.TransactionPartnerType.FRAGMENT + """:const:`telegram.constants.TransactionPartnerType.FRAGMENT`""" + 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`""" + TELEGRAM_API: Final[str] = constants.TransactionPartnerType.TELEGRAM_API + """:const:`telegram.constants.TransactionPartnerType.TELEGRAM_API`""" + USER: Final[str] = constants.TransactionPartnerType.USER + """:const:`telegram.constants.TransactionPartnerType.USER`""" + + def __init__(self, type: str, *, api_kwargs: JSONDict | None = 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: JSONDict, bot: "Bot | None" = None) -> "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) + + _class_mapping: dict[str, type[TransactionPartner]] = { + cls.AFFILIATE_PROGRAM: TransactionPartnerAffiliateProgram, + cls.CHAT: TransactionPartnerChat, + cls.FRAGMENT: TransactionPartnerFragment, + cls.USER: TransactionPartnerUser, + cls.TELEGRAM_ADS: TransactionPartnerTelegramAds, + cls.TELEGRAM_API: TransactionPartnerTelegramApi, + cls.OTHER: TransactionPartnerOther, + } + + 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 TransactionPartnerAffiliateProgram(TransactionPartner): + """Describes the affiliate program that issued the affiliate commission received via this + transaction. + + This object is comparable in terms of equality. Two objects of this class are considered equal, + if their :attr:`commission_per_mille` are equal. + + .. versionadded:: 21.9 + + Args: + sponsor_user (:class:`telegram.User`, optional): Information about the bot that sponsored + the affiliate program + commission_per_mille (:obj:`int`): The number of Telegram Stars received by the bot for + each 1000 Telegram Stars received by the affiliate program sponsor from referred users. + + Attributes: + type (:obj:`str`): The type of the transaction partner, + always :tg-const:`telegram.TransactionPartner.AFFILIATE_PROGRAM`. + sponsor_user (:class:`telegram.User`): Optional. Information about the bot that sponsored + the affiliate program + commission_per_mille (:obj:`int`): The number of Telegram Stars received by the bot for + each 1000 Telegram Stars received by the affiliate program sponsor from referred users. + """ + + __slots__ = ("commission_per_mille", "sponsor_user") + + def __init__( + self, + commission_per_mille: int, + sponsor_user: "User | None" = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=TransactionPartner.AFFILIATE_PROGRAM, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.sponsor_user: User | None = sponsor_user + self.commission_per_mille: int = commission_per_mille + self._id_attrs = ( + self.type, + self.commission_per_mille, + ) + + @classmethod + def de_json( + cls, data: JSONDict, bot: "Bot | None" = None + ) -> "TransactionPartnerAffiliateProgram": + """See :meth:`telegram.TransactionPartner.de_json`.""" + data = cls._parse_data(data) + + data["sponsor_user"] = de_json_optional(data.get("sponsor_user"), User, bot) + + return super().de_json(data=data, bot=bot) # type: ignore[return-value] + + +class TransactionPartnerChat(TransactionPartner): + """Describes a transaction with a chat. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`chat` are equal. + + .. versionadded:: 21.11 + + Args: + chat (:class:`telegram.Chat`): Information about the chat. + gift (:class:`telegram.Gift`, optional): The gift sent to the chat by the bot. + + Attributes: + type (:obj:`str`): The type of the transaction partner, + always :tg-const:`telegram.TransactionPartner.CHAT`. + chat (:class:`telegram.Chat`): Information about the chat. + gift (:class:`telegram.Gift`): Optional. The gift sent to the user by the bot. + + """ + + __slots__ = ( + "chat", + "gift", + ) + + def __init__( + self, + chat: Chat, + gift: Gift | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=TransactionPartner.CHAT, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.chat: Chat = chat + self.gift: Gift | None = gift + + self._id_attrs = ( + self.type, + self.chat, + ) + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "TransactionPartnerChat": + """See :meth:`telegram.TransactionPartner.de_json`.""" + data = cls._parse_data(data) + + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["gift"] = de_json_optional(data.get("gift"), Gift, bot) + + return super().de_json(data=data, bot=bot) # type: ignore[return-value] + + +class TransactionPartnerFragment(TransactionPartner): + """Describes a withdrawal transaction with Fragment. + + .. versionadded:: 21.4 + + Args: + withdrawal_state (:class:`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 (:class:`telegram.RevenueWithdrawalState`): Optional. State of the + transaction if the transaction is outgoing. + """ + + __slots__ = ("withdrawal_state",) + + def __init__( + self, + withdrawal_state: "RevenueWithdrawalState | None" = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=TransactionPartner.FRAGMENT, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.withdrawal_state: RevenueWithdrawalState | None = withdrawal_state + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "TransactionPartnerFragment": + """See :meth:`telegram.TransactionPartner.de_json`.""" + data = cls._parse_data(data) + + data["withdrawal_state"] = de_json_optional( + data.get("withdrawal_state"), RevenueWithdrawalState, 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` and :attr:`transaction_type` are equal. + + .. versionadded:: 21.4 + + .. versionchanged:: 22.1 + Equality comparison now includes the new required argument :paramref:`transaction_type`, + introduced in Bot API 9.0. + + Args: + transaction_type (:obj:`str`): Type of the transaction, currently one of + :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` for payments via + invoices, :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` + for payments for paid media, + :tg-const:`telegram.constants.TransactionPartnerUser.GIFT_PURCHASE` for gifts sent by + the bot, :tg-const:`telegram.constants.TransactionPartnerUser.PREMIUM_PURCHASE` + for Telegram Premium subscriptions gifted by the bot, + :tg-const:`telegram.constants.TransactionPartnerUser.BUSINESS_ACCOUNT_TRANSFER` for + direct transfers from managed business accounts. + + .. versionadded:: 22.1 + user (:class:`telegram.User`): Information about the user. + affiliate (:class:`telegram.AffiliateInfo`, optional): Information about the affiliate that + received a commission via this transaction. Can be available only for + :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` + and :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` + transactions. + + .. versionadded:: 21.9 + invoice_payload (:obj:`str`, optional): Bot-specified invoice payload. Can be available + only for :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` + transactions. + subscription_period (:obj:`int` | :class:`datetime.timedelta`, optional): The duration of + the paid subscription. Can be available only for + :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` transactions. + + .. versionadded:: 21.8 + + .. versionchanged:: v22.2 + Accepts :obj:`int` objects as well as :class:`datetime.timedelta`. + paid_media (Sequence[:class:`telegram.PaidMedia`], optional): Information about the paid + media bought by the user. for + :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` + transactions only. + + .. versionadded:: 21.5 + paid_media_payload (:obj:`str`, optional): Bot-specified paid media payload. Can be + available only for + :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` transactions. + + .. versionadded:: 21.6 + gift (:class:`telegram.Gift`, optional): The gift sent to the user by the bot; for + :tg-const:`telegram.constants.TransactionPartnerUser.GIFT_PURCHASE` transactions only. + + .. versionadded:: 21.8 + premium_subscription_duration (:obj:`int`, optional): Number of months the gifted Telegram + Premium subscription will be active for; for + :tg-const:`telegram.constants.TransactionPartnerUser.PREMIUM_PURCHASE` + transactions only. + + .. versionadded:: 22.1 + + Attributes: + type (:obj:`str`): The type of the transaction partner, + always :tg-const:`telegram.TransactionPartner.USER`. + transaction_type (:obj:`str`): Type of the transaction, currently one of + :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` for payments via + invoices, :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` + for payments for paid media, + :tg-const:`telegram.constants.TransactionPartnerUser.GIFT_PURCHASE` for gifts sent by + the bot, :tg-const:`telegram.constants.TransactionPartnerUser.PREMIUM_PURCHASE` + for Telegram Premium subscriptions gifted by the bot, + :tg-const:`telegram.constants.TransactionPartnerUser.BUSINESS_ACCOUNT_TRANSFER` for + direct transfers from managed business accounts. + + .. versionadded:: 22.1 + user (:class:`telegram.User`): Information about the user. + affiliate (:class:`telegram.AffiliateInfo`): Optional. Information about the affiliate that + received a commission via this transaction. Can be available only for + :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` + and :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` + transactions. + + .. versionadded:: 21.9 + invoice_payload (:obj:`str`): Optional. Bot-specified invoice payload. Can be available + only for :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` + transactions. + subscription_period (:class:`datetime.timedelta`): Optional. The duration of the paid + subscription. Can be available only for + :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` transactions. + + .. versionadded:: 21.8 + paid_media (tuple[:class:`telegram.PaidMedia`]): Optional. Information about the paid + media bought by the user. for + :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` + transactions only. + + .. versionadded:: 21.5 + paid_media_payload (:obj:`str`): Optional. Bot-specified paid media payload. Can be + available only for + :tg-const:`telegram.constants.TransactionPartnerUser.PAID_MEDIA_PAYMENT` transactions. + + .. versionadded:: 21.6 + gift (:class:`telegram.Gift`): Optional. The gift sent to the user by the bot; for + :tg-const:`telegram.constants.TransactionPartnerUser.GIFT_PURCHASE` transactions only. + + .. versionadded:: 21.8 + premium_subscription_duration (:obj:`int`): Optional. Number of months the gifted Telegram + Premium subscription will be active for; for + :tg-const:`telegram.constants.TransactionPartnerUser.PREMIUM_PURCHASE` + transactions only. + + .. versionadded:: 22.1 + + """ + + __slots__ = ( + "affiliate", + "gift", + "invoice_payload", + "paid_media", + "paid_media_payload", + "premium_subscription_duration", + "subscription_period", + "transaction_type", + "user", + ) + + def __init__( + self, + transaction_type: str, + user: "User", + invoice_payload: str | None = None, + paid_media: Sequence[PaidMedia] | None = None, + paid_media_payload: str | None = None, + subscription_period: TimePeriod | None = None, + gift: Gift | None = None, + affiliate: AffiliateInfo | None = None, + premium_subscription_duration: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=TransactionPartner.USER, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.user: User = user + self.affiliate: AffiliateInfo | None = affiliate + self.invoice_payload: str | None = invoice_payload + self.paid_media: tuple[PaidMedia, ...] | None = parse_sequence_arg(paid_media) + self.paid_media_payload: str | None = paid_media_payload + self.subscription_period: dtm.timedelta | None = to_timedelta(subscription_period) + self.gift: Gift | None = gift + self.premium_subscription_duration: int | None = premium_subscription_duration + self.transaction_type: str = transaction_type + + self._id_attrs = ( + self.type, + self.user, + self.transaction_type, + ) + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "TransactionPartnerUser": + """See :meth:`telegram.TransactionPartner.de_json`.""" + data = cls._parse_data(data) + + data["user"] = de_json_optional(data.get("user"), User, bot) + data["affiliate"] = de_json_optional(data.get("affiliate"), AffiliateInfo, bot) + data["paid_media"] = de_list_optional(data.get("paid_media"), PaidMedia, bot) + data["gift"] = de_json_optional(data.get("gift"), Gift, 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: JSONDict | None = 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: JSONDict | None = None) -> None: + super().__init__(type=TransactionPartner.TELEGRAM_ADS, api_kwargs=api_kwargs) + self._freeze() + + +class TransactionPartnerTelegramApi(TransactionPartner): + """Describes a transaction with payment for + `paid broadcasting `_. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`request_count` is equal. + + .. versionadded:: 21.7 + + Args: + request_count (:obj:`int`): The number of successful requests that exceeded regular limits + and were therefore billed. + + Attributes: + type (:obj:`str`): The type of the transaction partner, + always :tg-const:`telegram.TransactionPartner.TELEGRAM_API`. + request_count (:obj:`int`): The number of successful requests that exceeded regular limits + and were therefore billed. + """ + + __slots__ = ("request_count",) + + def __init__(self, request_count: int, *, api_kwargs: JSONDict | None = None) -> None: + super().__init__(type=TransactionPartner.TELEGRAM_API, api_kwargs=api_kwargs) + with self._unfrozen(): + self.request_count: int = request_count + self._id_attrs = (self.request_count,) diff --git a/telegram/_payment/successfulpayment.py b/src/telegram/_payment/successfulpayment.py similarity index 56% rename from telegram/_payment/successfulpayment.py rename to src/telegram/_payment/successfulpayment.py index 1fc863b3f56..7fb284814c6 100644 --- a/telegram/_payment/successfulpayment.py +++ b/src/telegram/_payment/successfulpayment.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,10 +18,13 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram SuccessfulPayment.""" -from typing import TYPE_CHECKING, Optional +import datetime as dtm +from typing import TYPE_CHECKING from telegram._payment.orderinfo import OrderInfo from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -30,20 +33,35 @@ class SuccessfulPayment(TelegramObject): """This object contains basic information about a successful payment. + Note that if the buyer initiates a chargeback with the relevant payment provider following + this transaction, the funds may be debited from your balance. This is outside of + Telegram's control. 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` and :attr:`provider_payment_charge_id` are equal. Args: - currency (:obj:`str`): Three-letter ISO 4217 currency code. - 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``. + 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 `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. + subscription_expiration_date (:class:`datetime.datetime`, optional): Expiration date of the + subscription; for recurring payments only. + + .. versionadded:: 21.8 + is_recurring (:obj:`bool`, optional): True, if the payment is for a subscription. + + .. versionadded:: 21.8 + is_first_recurring (:obj:`bool`, optional): True, if the payment is the first payment of a + subscription. + + .. versionadded:: 21.8 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. @@ -51,14 +69,26 @@ class SuccessfulPayment(TelegramObject): provider_payment_charge_id (:obj:`str`): Provider payment identifier. Attributes: - currency (:obj:`str`): Three-letter ISO 4217 currency code. - 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``. + 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 `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. + subscription_expiration_date (:class:`datetime.datetime`): Optional. Expiration + date of the subscription; for recurring payments only. + + .. versionadded:: 21.8 + is_recurring (:obj:`bool`): Optional. True, if the payment is for a subscription. + + .. versionadded:: 21.8 + is_first_recurring (:obj:`bool`): Optional. True, if the payment is the first payment of a + subscription. + + .. versionadded:: 21.8 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. @@ -68,12 +98,15 @@ class SuccessfulPayment(TelegramObject): """ __slots__ = ( - "invoice_payload", - "shipping_option_id", "currency", + "invoice_payload", + "is_first_recurring", + "is_recurring", "order_info", - "telegram_payment_charge_id", "provider_payment_charge_id", + "shipping_option_id", + "subscription_expiration_date", + "telegram_payment_charge_id", "total_amount", ) @@ -84,32 +117,42 @@ def __init__( invoice_payload: str, telegram_payment_charge_id: str, provider_payment_charge_id: str, - shipping_option_id: Optional[str] = None, - order_info: Optional[OrderInfo] = None, + shipping_option_id: str | None = None, + order_info: OrderInfo | None = None, + subscription_expiration_date: dtm.datetime | None = None, + is_recurring: bool | None = None, + is_first_recurring: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.currency: str = currency self.total_amount: int = total_amount self.invoice_payload: str = invoice_payload - self.shipping_option_id: Optional[str] = shipping_option_id - self.order_info: Optional[OrderInfo] = order_info + self.shipping_option_id: str | None = shipping_option_id + self.order_info: OrderInfo | None = order_info self.telegram_payment_charge_id: str = telegram_payment_charge_id self.provider_payment_charge_id: str = provider_payment_charge_id + self.subscription_expiration_date: dtm.datetime | None = subscription_expiration_date + self.is_recurring: bool | None = is_recurring + self.is_first_recurring: bool | None = is_first_recurring self._id_attrs = (self.telegram_payment_charge_id, self.provider_payment_charge_id) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["SuccessfulPayment"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "SuccessfulPayment": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None + data["order_info"] = de_json_optional(data.get("order_info"), OrderInfo, bot) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) - data["order_info"] = OrderInfo.de_json(data.get("order_info"), bot) + data["subscription_expiration_date"] = from_timestamp( + data.get("subscription_expiration_date"), tzinfo=loc_tzinfo + ) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_poll.py b/src/telegram/_poll.py new file mode 100644 index 00000000000..744edd22eff --- /dev/null +++ b/src/telegram/_poll.py @@ -0,0 +1,654 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 Poll.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING, Final + +from telegram import constants +from telegram._chat import Chat +from telegram._messageentity import MessageEntity +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils import enum +from telegram._utils.argumentparsing import ( + de_json_optional, + de_list_optional, + parse_sequence_arg, + to_timedelta, +) +from telegram._utils.datetime import ( + extract_tzinfo_from_defaults, + from_timestamp, + get_timedelta_value, +) +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.entities import parse_message_entities, parse_message_entity +from telegram._utils.types import JSONDict, ODVInput, TimePeriod + +if TYPE_CHECKING: + from telegram import Bot + + +class InputPollOption(TelegramObject): + """ + 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. + + .. versionadded:: 21.2 + + Args: + text (:obj:`str`): Option text, + :tg-const:`telegram.PollOption.MIN_LENGTH`-:tg-const:`telegram.PollOption.MAX_LENGTH` + characters. + text_parse_mode (:obj:`str`, optional): |parse_mode| + Currently, only custom emoji entities are allowed. + text_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities + that appear in the option :paramref:`text`. It can be specified instead of + :paramref:`text_parse_mode`. + Currently, only custom emoji entities are allowed. + This list is empty if the text does not contain entities. + + Attributes: + text (:obj:`str`): Option text, + :tg-const:`telegram.PollOption.MIN_LENGTH`-:tg-const:`telegram.PollOption.MAX_LENGTH` + characters. + text_parse_mode (:obj:`str`): Optional. |parse_mode| + Currently, only custom emoji entities are allowed. + text_entities (Sequence[:class:`telegram.MessageEntity`]): Special entities + that appear in the option :paramref:`text`. It can be specified instead of + :paramref:`text_parse_mode`. + Currently, only custom emoji entities are allowed. + This list is empty if the text does not contain entities. + """ + + __slots__ = ("text", "text_entities", "text_parse_mode") + + def __init__( + self, + text: str, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Sequence[MessageEntity] | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.text: str = text + self.text_parse_mode: ODVInput[str] = text_parse_mode + self.text_entities: tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) + + self._id_attrs = (self.text,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "InputPollOption": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["text_entities"] = de_list_optional(data.get("text_entities"), MessageEntity, bot) + + return super().de_json(data=data, bot=bot) + + +class PollOption(TelegramObject): + """ + This object contains information about one answer option in a poll. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`text` and :attr:`voter_count` are equal. + + Args: + text (:obj:`str`): Option text, + :tg-const:`telegram.PollOption.MIN_LENGTH`-:tg-const:`telegram.PollOption.MAX_LENGTH` + characters. + voter_count (:obj:`int`): Number of users that voted for this option. + text_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities + that appear in the option text. Currently, only custom emoji entities are allowed in + poll option texts. + + .. versionadded:: 21.2 + + Attributes: + text (:obj:`str`): Option text, + :tg-const:`telegram.PollOption.MIN_LENGTH`-:tg-const:`telegram.PollOption.MAX_LENGTH` + characters. + voter_count (:obj:`int`): Number of users that voted for this option. + text_entities (tuple[:class:`telegram.MessageEntity`]): Special entities + that appear in the option text. Currently, only custom emoji entities are allowed in + poll option texts. + This list is empty if the question does not contain entities. + + .. versionadded:: 21.2 + + """ + + __slots__ = ("text", "text_entities", "voter_count") + + def __init__( + self, + text: str, + voter_count: int, + text_entities: Sequence[MessageEntity] | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.text: str = text + self.voter_count: int = voter_count + self.text_entities: tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) + + self._id_attrs = (self.text, self.voter_count) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PollOption": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["text_entities"] = de_list_optional(data.get("text_entities"), MessageEntity, bot) + + return super().de_json(data=data, bot=bot) + + def parse_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`text` + from a given :class:`telegram.MessageEntity` of :attr:`text_entities`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``Message.text`` with the offset and length.) + + .. versionadded:: 21.2 + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`text_entities`. + + Returns: + :obj:`str`: The text of the given entity. + """ + return parse_message_entity(self.text, entity) + + def parse_entities(self, types: list[str] | None = None) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this polls question filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + Note: + This method should always be used instead of the :attr:`text_entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_entity` for more info. + + .. versionadded:: 21.2 + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + """ + return parse_message_entities(self.text, self.text_entities, types) + + MIN_LENGTH: Final[int] = constants.PollLimit.MIN_OPTION_LENGTH + """:const:`telegram.constants.PollLimit.MIN_OPTION_LENGTH` + + .. versionadded:: 20.0 + """ + MAX_LENGTH: Final[int] = constants.PollLimit.MAX_OPTION_LENGTH + """:const:`telegram.constants.PollLimit.MAX_OPTION_LENGTH` + + .. versionadded:: 20.0 + """ + + +class PollAnswer(TelegramObject): + """ + This object represents an answer of a user in a non-anonymous poll. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`poll_id`, :attr:`user` and :attr:`option_ids` are equal. + + .. versionchanged:: 20.5 + The order of :paramref:`option_ids` and :paramref:`user` is changed in + 20.5 as the latter one became optional. + + .. versionchanged:: 20.6 + Backward compatiblity for changed order of :paramref:`option_ids` and :paramref:`user` + was removed. + + Args: + poll_id (:obj:`str`): Unique poll identifier. + option_ids (Sequence[:obj:`int`]): Identifiers of answer options, chosen by the user. May + be empty if the user retracted their vote. + + .. versionchanged:: 20.0 + |sequenceclassargs| + user (:class:`telegram.User`, optional): The user that changed the answer to the poll, + if the voter isn't anonymous. If the voter is anonymous, this field will contain the + user :tg-const:`telegram.constants.ChatID.FAKE_CHANNEL` for backwards compatibility. + + .. versionchanged:: 20.5 + :paramref:`user` became optional. + voter_chat (:class:`telegram.Chat`, optional): The chat that changed the answer to the + poll, if the voter is anonymous. + + .. versionadded:: 20.5 + + Attributes: + poll_id (:obj:`str`): Unique poll identifier. + option_ids (tuple[:obj:`int`]): Identifiers of answer options, chosen by the user. May + be empty if the user retracted their vote. + + .. versionchanged:: 20.0 + |tupleclassattrs| + user (:class:`telegram.User`): Optional. The user, who changed the answer to the + poll, if the voter isn't anonymous. If the voter is anonymous, this field will contain + the user :tg-const:`telegram.constants.ChatID.FAKE_CHANNEL` for backwards compatibility + + .. versionchanged:: 20.5 + :paramref:`user` became optional. + voter_chat (:class:`telegram.Chat`): Optional. The chat that changed the answer to the + poll, if the voter is anonymous. + + .. versionadded:: 20.5 + + """ + + __slots__ = ("option_ids", "poll_id", "user", "voter_chat") + + def __init__( + self, + poll_id: str, + option_ids: Sequence[int], + user: User | None = None, + voter_chat: Chat | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.poll_id: str = poll_id + self.voter_chat: Chat | None = voter_chat + self.option_ids: tuple[int, ...] = parse_sequence_arg(option_ids) + self.user: User | None = user + + self._id_attrs = ( + self.poll_id, + self.option_ids, + self.user, + self.voter_chat, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PollAnswer": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["user"] = de_json_optional(data.get("user"), User, bot) + data["voter_chat"] = de_json_optional(data.get("voter_chat"), Chat, bot) + + return super().de_json(data=data, bot=bot) + + +class Poll(TelegramObject): + """ + This object contains information about a poll. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`id` is equal. + + Examples: + :any:`Poll Bot ` + + Args: + id (:obj:`str`): Unique poll identifier. + question (:obj:`str`): Poll question, :tg-const:`telegram.Poll.MIN_QUESTION_LENGTH`- + :tg-const:`telegram.Poll.MAX_QUESTION_LENGTH` characters. + options (Sequence[:class:`~telegram.PollOption`]): List of poll options. + + .. versionchanged:: 20.0 + |sequenceclassargs| + is_closed (:obj:`bool`): :obj:`True`, if the poll is closed. + is_anonymous (:obj:`bool`): :obj:`True`, if the poll is anonymous. + type (:obj:`str`): Poll type, currently can be :attr:`REGULAR` or :attr:`QUIZ`. + allows_multiple_answers (:obj:`bool`): :obj:`True`, if the poll allows multiple answers. + correct_option_id (:obj:`int`, optional): A zero based identifier of the correct answer + option. Available only for closed polls in the quiz mode, which were sent + (not forwarded), by the bot or to a private chat with the bot. + explanation (:obj:`str`, optional): Text that is shown when a user chooses an incorrect + answer or taps on the lamp icon in a quiz-style poll, + 0-:tg-const:`telegram.Poll.MAX_EXPLANATION_LENGTH` characters. + explanation_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special + entities like usernames, URLs, bot commands, etc. that appear in the + :attr:`explanation`. This list is empty if the message does not contain explanation + entities. + + .. versionchanged:: 20.0 + + * This attribute is now always a (possibly empty) list and never :obj:`None`. + * |sequenceclassargs| + open_period (:obj:`int` | :class:`datetime.timedelta`, optional): Amount of time in seconds + the poll will be active after creation. + + .. versionchanged:: v22.2 + |time-period-input| + close_date (:obj:`datetime.datetime`, optional): Point in time (Unix timestamp) when the + poll will be automatically closed. Converted to :obj:`datetime.datetime`. + + .. versionchanged:: 20.3 + |datetime_localization| + question_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities + that appear in the :attr:`question`. Currently, only custom emoji entities are allowed + in poll questions. + + .. versionadded:: 21.2 + + Attributes: + id (:obj:`str`): Unique poll identifier. + question (:obj:`str`): Poll question, :tg-const:`telegram.Poll.MIN_QUESTION_LENGTH`- + :tg-const:`telegram.Poll.MAX_QUESTION_LENGTH` characters. + options (tuple[:class:`~telegram.PollOption`]): List of poll options. + + .. versionchanged:: 20.0 + |tupleclassattrs| + total_voter_count (:obj:`int`): Total number of users that voted in the poll. + is_closed (:obj:`bool`): :obj:`True`, if the poll is closed. + is_anonymous (:obj:`bool`): :obj:`True`, if the poll is anonymous. + type (:obj:`str`): Poll type, currently can be :attr:`REGULAR` or :attr:`QUIZ`. + allows_multiple_answers (:obj:`bool`): :obj:`True`, if the poll allows multiple answers. + correct_option_id (:obj:`int`): Optional. A zero based identifier of the correct answer + option. Available only for closed polls in the quiz mode, which were sent + (not forwarded), by the bot or to a private chat with the bot. + explanation (:obj:`str`): Optional. Text that is shown when a user chooses an incorrect + answer or taps on the lamp icon in a quiz-style poll, + 0-:tg-const:`telegram.Poll.MAX_EXPLANATION_LENGTH` characters. + explanation_entities (tuple[:class:`telegram.MessageEntity`]): Special entities + like usernames, URLs, bot commands, etc. that appear in the :attr:`explanation`. + This list is empty if the message does not contain explanation entities. + + .. versionchanged:: 20.0 + |tupleclassattrs| + + .. versionchanged:: 20.0 + This attribute is now always a (possibly empty) list and never :obj:`None`. + open_period (:obj:`int` | :class:`datetime.timedelta`): Optional. Amount of time in seconds + the poll will be active after creation. + + .. deprecated:: v22.2 + |time-period-int-deprecated| + close_date (:obj:`datetime.datetime`): Optional. Point in time when the poll will be + automatically closed. + + .. versionchanged:: 20.3 + |datetime_localization| + question_entities (tuple[:class:`telegram.MessageEntity`]): Special entities + that appear in the :attr:`question`. Currently, only custom emoji entities are allowed + in poll questions. + This list is empty if the question does not contain entities. + + .. versionadded:: 21.2 + + """ + + __slots__ = ( + "_open_period", + "allows_multiple_answers", + "close_date", + "correct_option_id", + "explanation", + "explanation_entities", + "id", + "is_anonymous", + "is_closed", + "options", + "question", + "question_entities", + "total_voter_count", + "type", + ) + + def __init__( + self, + id: str, # pylint: disable=redefined-builtin + question: str, + options: Sequence[PollOption], + total_voter_count: int, + is_closed: bool, + is_anonymous: bool, + type: str, # pylint: disable=redefined-builtin + allows_multiple_answers: bool, + correct_option_id: int | None = None, + explanation: str | None = None, + explanation_entities: Sequence[MessageEntity] | None = None, + open_period: TimePeriod | None = None, + close_date: dtm.datetime | None = None, + question_entities: Sequence[MessageEntity] | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.id: str = id + self.question: str = question + self.options: tuple[PollOption, ...] = parse_sequence_arg(options) + self.total_voter_count: int = total_voter_count + self.is_closed: bool = is_closed + self.is_anonymous: bool = is_anonymous + self.type: str = enum.get_member(constants.PollType, type, type) + self.allows_multiple_answers: bool = allows_multiple_answers + self.correct_option_id: int | None = correct_option_id + self.explanation: str | None = explanation + self.explanation_entities: tuple[MessageEntity, ...] = parse_sequence_arg( + explanation_entities + ) + self._open_period: dtm.timedelta | None = to_timedelta(open_period) + self.close_date: dtm.datetime | None = close_date + self.question_entities: tuple[MessageEntity, ...] = parse_sequence_arg(question_entities) + + self._id_attrs = (self.id,) + + self._freeze() + + @property + def open_period(self) -> int | dtm.timedelta | None: + return get_timedelta_value(self._open_period, attribute="open_period") + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Poll": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["options"] = de_list_optional(data.get("options"), PollOption, bot) + data["explanation_entities"] = de_list_optional( + data.get("explanation_entities"), MessageEntity, bot + ) + data["close_date"] = from_timestamp(data.get("close_date"), tzinfo=loc_tzinfo) + data["question_entities"] = de_list_optional( + data.get("question_entities"), MessageEntity, bot + ) + + return super().de_json(data=data, bot=bot) + + def parse_explanation_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`explanation` from a given :class:`telegram.MessageEntity` of + :attr:`explanation_entities`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``Message.text`` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`explanation_entities`. + + Returns: + :obj:`str`: The text of the given entity. + + Raises: + RuntimeError: If the poll has no explanation. + + """ + if not self.explanation: + raise RuntimeError("This Poll has no 'explanation'.") + + return parse_message_entity(self.explanation, entity) + + def parse_explanation_entities( + self, types: list[str] | None = None + ) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this polls explanation filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + Note: + This method should always be used instead of the :attr:`explanation_entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_explanation_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + + Raises: + RuntimeError: If the poll has no explanation. + + """ + if not self.explanation: + raise RuntimeError("This Poll has no 'explanation'.") + + return parse_message_entities(self.explanation, self.explanation_entities, types) + + def parse_question_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`question` from a given :class:`telegram.MessageEntity` of + :attr:`question_entities`. + + .. versionadded:: 21.2 + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``Message.text`` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`question_entities`. + + Returns: + :obj:`str`: The text of the given entity. + """ + return parse_message_entity(self.question, entity) + + def parse_question_entities(self, types: list[str] | None = None) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this polls question filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + .. versionadded:: 21.2 + + Note: + This method should always be used instead of the :attr:`question_entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_question_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + + """ + return parse_message_entities(self.question, self.question_entities, types) + + REGULAR: Final[str] = constants.PollType.REGULAR + """:const:`telegram.constants.PollType.REGULAR`""" + QUIZ: Final[str] = constants.PollType.QUIZ + """:const:`telegram.constants.PollType.QUIZ`""" + MAX_EXPLANATION_LENGTH: Final[int] = constants.PollLimit.MAX_EXPLANATION_LENGTH + """:const:`telegram.constants.PollLimit.MAX_EXPLANATION_LENGTH` + + .. versionadded:: 20.0 + """ + MAX_EXPLANATION_LINE_FEEDS: Final[int] = constants.PollLimit.MAX_EXPLANATION_LINE_FEEDS + """:const:`telegram.constants.PollLimit.MAX_EXPLANATION_LINE_FEEDS` + + .. versionadded:: 20.0 + """ + MIN_OPEN_PERIOD: Final[int] = constants.PollLimit.MIN_OPEN_PERIOD + """:const:`telegram.constants.PollLimit.MIN_OPEN_PERIOD` + + .. versionadded:: 20.0 + """ + MAX_OPEN_PERIOD: Final[int] = constants.PollLimit.MAX_OPEN_PERIOD + """:const:`telegram.constants.PollLimit.MAX_OPEN_PERIOD` + + .. versionadded:: 20.0 + """ + MIN_QUESTION_LENGTH: Final[int] = constants.PollLimit.MIN_QUESTION_LENGTH + """:const:`telegram.constants.PollLimit.MIN_QUESTION_LENGTH` + + .. versionadded:: 20.0 + """ + MAX_QUESTION_LENGTH: Final[int] = constants.PollLimit.MAX_QUESTION_LENGTH + """:const:`telegram.constants.PollLimit.MAX_QUESTION_LENGTH` + + .. versionadded:: 20.0 + """ + MIN_OPTION_LENGTH: Final[int] = constants.PollLimit.MIN_OPTION_LENGTH + """:const:`telegram.constants.PollLimit.MIN_OPTION_LENGTH` + + .. versionadded:: 20.0 + """ + MAX_OPTION_LENGTH: Final[int] = constants.PollLimit.MAX_OPTION_LENGTH + """:const:`telegram.constants.PollLimit.MAX_OPTION_LENGTH` + + .. versionadded:: 20.0 + """ + MIN_OPTION_NUMBER: Final[int] = constants.PollLimit.MIN_OPTION_NUMBER + """:const:`telegram.constants.PollLimit.MIN_OPTION_NUMBER` + + .. versionadded:: 20.0 + """ + MAX_OPTION_NUMBER: Final[int] = constants.PollLimit.MAX_OPTION_NUMBER + """:const:`telegram.constants.PollLimit.MAX_OPTION_NUMBER` + + .. versionadded:: 20.0 + """ diff --git a/telegram/_proximityalerttriggered.py b/src/telegram/_proximityalerttriggered.py similarity index 83% rename from telegram/_proximityalerttriggered.py rename to src/telegram/_proximityalerttriggered.py index c394131ed6c..8f07d9aede4 100644 --- a/telegram/_proximityalerttriggered.py +++ b/src/telegram/_proximityalerttriggered.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,10 +17,12 @@ # 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 Proximity Alert.""" -from typing import TYPE_CHECKING, Optional + +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._user import User +from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict if TYPE_CHECKING: @@ -47,7 +49,7 @@ class ProximityAlertTriggered(TelegramObject): """ - __slots__ = ("traveler", "distance", "watcher") + __slots__ = ("distance", "traveler", "watcher") def __init__( self, @@ -55,7 +57,7 @@ def __init__( watcher: User, distance: int, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) self.traveler: User = traveler @@ -67,14 +69,11 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["ProximityAlertTriggered"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ProximityAlertTriggered": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - - data["traveler"] = User.de_json(data.get("traveler"), bot) - data["watcher"] = User.de_json(data.get("watcher"), bot) + data["traveler"] = de_json_optional(data.get("traveler"), User, bot) + data["watcher"] = de_json_optional(data.get("watcher"), User, bot) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_reaction.py b/src/telegram/_reaction.py new file mode 100644 index 00000000000..2dbcd462e72 --- /dev/null +++ b/src/telegram/_reaction.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 objects that represents a Telegram ReactionType.""" + +from typing import TYPE_CHECKING, Final, Literal + +from telegram import constants +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class ReactionType(TelegramObject): + """Base class for Telegram ReactionType Objects. + There exist :class:`telegram.ReactionTypeEmoji`, :class:`telegram.ReactionTypeCustomEmoji` + and :class:`telegram.ReactionTypePaid`. + + .. versionadded:: 20.8 + .. versionchanged:: 21.5 + + Added paid reaction. + + Args: + type (:obj:`str`): Type of the reaction. Can be + :attr:`~telegram.ReactionType.EMOJI`, :attr:`~telegram.ReactionType.CUSTOM_EMOJI` or + :attr:`~telegram.ReactionType.PAID`. + Attributes: + type (:obj:`str`): Type of the reaction. Can be + :attr:`~telegram.ReactionType.EMOJI`, :attr:`~telegram.ReactionType.CUSTOM_EMOJI` or + :attr:`~telegram.ReactionType.PAID`. + + """ + + __slots__ = ("type",) + + EMOJI: Final[constants.ReactionType] = constants.ReactionType.EMOJI + """:const:`telegram.constants.ReactionType.EMOJI`""" + CUSTOM_EMOJI: Final[constants.ReactionType] = constants.ReactionType.CUSTOM_EMOJI + """:const:`telegram.constants.ReactionType.CUSTOM_EMOJI`""" + PAID: Final[constants.ReactionType] = constants.ReactionType.PAID + """:const:`telegram.constants.ReactionType.PAID` + + .. versionadded:: 21.5 + """ + + def __init__( + self, + type: Literal["emoji", "custom_emoji", "paid"] | constants.ReactionType, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required by all subclasses + self.type: str = enum.get_member(constants.ReactionType, type, type) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ReactionType": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + _class_mapping: dict[str, type[ReactionType]] = { + cls.EMOJI: ReactionTypeEmoji, + cls.CUSTOM_EMOJI: ReactionTypeCustomEmoji, + cls.PAID: ReactionTypePaid, + } + + if cls is ReactionType and data.get("type") in _class_mapping: + return _class_mapping[data.pop("type")].de_json(data, bot) + + return super().de_json(data=data, bot=bot) + + +class ReactionTypeEmoji(ReactionType): + """ + Represents a reaction with a normal emoji. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if the :attr:`emoji` is equal. + + .. versionadded:: 20.8 + + Args: + emoji (:obj:`str`): Reaction emoji. It can be one of + :const:`telegram.constants.ReactionEmoji`. + + Attributes: + type (:obj:`str`): Type of the reaction, + always :tg-const:`telegram.ReactionType.EMOJI`. + emoji (:obj:`str`): Reaction emoji. It can be one of + :const:`telegram.constants.ReactionEmoji`. + """ + + __slots__ = ("emoji",) + + def __init__( + self, + emoji: str, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=ReactionType.EMOJI, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.emoji: str = emoji + self._id_attrs = (self.emoji,) + + +class ReactionTypeCustomEmoji(ReactionType): + """ + Represents a reaction with a custom emoji. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if the :attr:`custom_emoji_id` is equal. + + .. versionadded:: 20.8 + + Args: + custom_emoji_id (:obj:`str`): Custom emoji identifier. + + Attributes: + type (:obj:`str`): Type of the reaction, + always :tg-const:`telegram.ReactionType.CUSTOM_EMOJI`. + custom_emoji_id (:obj:`str`): Custom emoji identifier. + + """ + + __slots__ = ("custom_emoji_id",) + + def __init__( + self, + custom_emoji_id: str, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(type=ReactionType.CUSTOM_EMOJI, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.custom_emoji_id: str = custom_emoji_id + self._id_attrs = (self.custom_emoji_id,) + + +class ReactionTypePaid(ReactionType): + """ + The reaction is paid. + + .. versionadded:: 21.5 + + Attributes: + type (:obj:`str`): Type of the reaction, + always :tg-const:`telegram.ReactionType.PAID`. + """ + + __slots__ = () + + def __init__(self, *, api_kwargs: JSONDict | None = None): + super().__init__(type=ReactionType.PAID, api_kwargs=api_kwargs) + self._freeze() + + +class ReactionCount(TelegramObject): + """This class represents a reaction added to a message along with the number of times it was + added. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if the :attr:`type` and :attr:`total_count` is equal. + + .. versionadded:: 20.8 + + Args: + type (:class:`telegram.ReactionType`): Type of the reaction. + total_count (:obj:`int`): Number of times the reaction was added. + + Attributes: + type (:class:`telegram.ReactionType`): Type of the reaction. + total_count (:obj:`int`): Number of times the reaction was added. + """ + + __slots__ = ( + "total_count", + "type", + ) + + def __init__( + self, + type: ReactionType, # pylint: disable=redefined-builtin + total_count: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.type: ReactionType = type + self.total_count: int = total_count + + self._id_attrs = ( + self.type, + self.total_count, + ) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ReactionCount": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["type"] = de_json_optional(data.get("type"), ReactionType, bot) + + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_reply.py b/src/telegram/_reply.py new file mode 100644 index 00000000000..367d5aad7a0 --- /dev/null +++ b/src/telegram/_reply.py @@ -0,0 +1,490 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 modules contains objects that represents Telegram Replies""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from telegram._chat import Chat +from telegram._checklists import Checklist +from telegram._dice import Dice +from telegram._files.animation import Animation +from telegram._files.audio import Audio +from telegram._files.contact import Contact +from telegram._files.document import Document +from telegram._files.location import Location +from telegram._files.photosize import PhotoSize +from telegram._files.sticker import Sticker +from telegram._files.venue import Venue +from telegram._files.video import Video +from telegram._files.videonote import VideoNote +from telegram._files.voice import Voice +from telegram._games.game import Game +from telegram._giveaway import Giveaway, GiveawayWinners +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 +from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.types import JSONDict, ODVInput + +if TYPE_CHECKING: + from telegram import Bot + + +class ExternalReplyInfo(TelegramObject): + """ + This object contains information about a message that is being replied to, which may + come from another chat or forum topic. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`origin` is equal. + + .. versionadded:: 20.8 + + Args: + origin (:class:`telegram.MessageOrigin`): Origin of the message replied to by the given + message. + chat (:class:`telegram.Chat`, optional): Chat the original message belongs to. Available + only if the chat is a supergroup or a channel. + message_id (:obj:`int`, optional): Unique message identifier inside the original chat. + Available only if the original chat is a supergroup or a channel. + link_preview_options (:class:`telegram.LinkPreviewOptions`, optional): Options used for + link preview generation for the original message, if it is a text message + animation (:class:`telegram.Animation`, optional): Message is an animation, information + about the animation. + audio (:class:`telegram.Audio`, optional): Message is an audio file, information about the + file. + document (:class:`telegram.Document`, optional): Message is a general file, information + about the file. + photo (Sequence[:class:`telegram.PhotoSize`], optional): Message is a photo, available + sizes of the photo. + sticker (:class:`telegram.Sticker`, optional): Message is a sticker, information about the + 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. + 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 + a spoiler animation. + checklist (:class:`telegram.Checklist`, optional): Message is a checklist. + + .. versionadded:: 22.3 + contact (:class:`telegram.Contact`, optional): Message is a shared contact, information + 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. + :ref:`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. :ref:`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 + message. + chat (:class:`telegram.Chat`): Optional. Chat the original message belongs to. Available + only if the chat is a supergroup or a channel. + message_id (:obj:`int`): Optional. Unique message identifier inside the original chat. + Available only if the original chat is a supergroup or a channel. + link_preview_options (:class:`telegram.LinkPreviewOptions`): Optional. Options used for + link preview generation for the original message, if it is a text message. + animation (:class:`telegram.Animation`): Optional. Message is an animation, information + about the animation. + audio (:class:`telegram.Audio`): Optional. Message is an audio file, information about the + file. + document (:class:`telegram.Document`): Optional. Message is a general file, information + about the file. + photo (tuple[:class:`telegram.PhotoSize`]): Optional. Message is a photo, available sizes + of the photo. + sticker (:class:`telegram.Sticker`): Optional. Message is a sticker, information about the + 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. + 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 + a spoiler animation. + checklist (:class:`telegram.Checklist`): Optional. Message is a checklist. + + .. versionadded:: 22.3 + contact (:class:`telegram.Contact`): Optional. Message is a shared contact, information + 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. + :ref:`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. :ref:`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__ = ( + "animation", + "audio", + "chat", + "checklist", + "contact", + "dice", + "document", + "game", + "giveaway", + "giveaway_winners", + "has_media_spoiler", + "invoice", + "link_preview_options", + "location", + "message_id", + "origin", + "paid_media", + "photo", + "poll", + "sticker", + "story", + "venue", + "video", + "video_note", + "voice", + ) + + def __init__( + self, + origin: MessageOrigin, + chat: Chat | None = None, + message_id: int | None = None, + link_preview_options: LinkPreviewOptions | None = None, + animation: Animation | None = None, + audio: Audio | None = None, + document: Document | None = None, + photo: Sequence[PhotoSize] | None = None, + sticker: Sticker | None = None, + story: Story | None = None, + video: Video | None = None, + video_note: VideoNote | None = None, + voice: Voice | None = None, + has_media_spoiler: bool | None = None, + contact: "Contact | None" = None, + dice: Dice | None = None, + game: Game | None = None, + giveaway: Giveaway | None = None, + giveaway_winners: GiveawayWinners | None = None, + invoice: Invoice | None = None, + location: "Location | None" = None, + poll: Poll | None = None, + venue: Venue | None = None, + paid_media: PaidMediaInfo | None = None, + checklist: Checklist | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.origin: MessageOrigin = origin + self.chat: Chat | None = chat + self.message_id: int | None = message_id + self.link_preview_options: LinkPreviewOptions | None = link_preview_options + self.animation: Animation | None = animation + self.audio: Audio | None = audio + self.document: Document | None = document + self.photo: tuple[PhotoSize, ...] | None = parse_sequence_arg(photo) + self.sticker: Sticker | None = sticker + self.story: Story | None = story + self.video: Video | None = video + self.video_note: VideoNote | None = video_note + self.voice: Voice | None = voice + self.has_media_spoiler: bool | None = has_media_spoiler + self.checklist: Checklist | None = checklist + self.contact: Contact | None = contact + self.dice: Dice | None = dice + self.game: Game | None = game + self.giveaway: Giveaway | None = giveaway + self.giveaway_winners: GiveawayWinners | None = giveaway_winners + self.invoice: Invoice | None = invoice + self.location: Location | None = location + self.poll: Poll | None = poll + self.venue: Venue | None = venue + self.paid_media: PaidMediaInfo | None = paid_media + + self._id_attrs = (self.origin,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ExternalReplyInfo": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["origin"] = de_json_optional(data.get("origin"), MessageOrigin, bot) + data["chat"] = de_json_optional(data.get("chat"), Chat, bot) + data["link_preview_options"] = de_json_optional( + data.get("link_preview_options"), LinkPreviewOptions, bot + ) + data["animation"] = de_json_optional(data.get("animation"), Animation, bot) + data["audio"] = de_json_optional(data.get("audio"), Audio, bot) + data["document"] = de_json_optional(data.get("document"), Document, bot) + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + data["story"] = de_json_optional(data.get("story"), Story, bot) + data["video"] = de_json_optional(data.get("video"), Video, bot) + data["video_note"] = de_json_optional(data.get("video_note"), VideoNote, bot) + data["voice"] = de_json_optional(data.get("voice"), Voice, bot) + data["contact"] = de_json_optional(data.get("contact"), Contact, bot) + data["dice"] = de_json_optional(data.get("dice"), Dice, bot) + data["game"] = de_json_optional(data.get("game"), Game, bot) + data["giveaway"] = de_json_optional(data.get("giveaway"), Giveaway, bot) + data["giveaway_winners"] = de_json_optional( + data.get("giveaway_winners"), GiveawayWinners, bot + ) + data["invoice"] = de_json_optional(data.get("invoice"), Invoice, bot) + data["location"] = de_json_optional(data.get("location"), Location, bot) + data["poll"] = de_json_optional(data.get("poll"), Poll, bot) + data["venue"] = de_json_optional(data.get("venue"), Venue, bot) + data["paid_media"] = de_json_optional(data.get("paid_media"), PaidMediaInfo, bot) + data["checklist"] = de_json_optional(data.get("checklist"), Checklist, bot) + + return super().de_json(data=data, bot=bot) + + +class TextQuote(TelegramObject): + """ + This object contains information about the quoted part of a message that is replied to + by the given message. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`text` and :attr:`position` are equal. + + .. versionadded:: 20.8 + + Args: + text (:obj:`str`): Text of the quoted part of a message that is replied to by the given + message. + position (:obj:`int`): Approximate quote position in the original message in UTF-16 code + units as specified by the sender. + entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities that + appear + in the quote. Currently, only bold, italic, underline, strikethrough, spoiler, and + custom_emoji entities are kept in quotes. + is_manual (:obj:`bool`, optional): :obj:`True`, if the quote was chosen manually by the + message sender. Otherwise, the quote was added automatically by the server. + + Attributes: + text (:obj:`str`): Text of the quoted part of a message that is replied to by the given + message. + position (:obj:`int`): Approximate quote position in the original message in UTF-16 code + units as specified by the sender. + entities (tuple[:class:`telegram.MessageEntity`]): Optional. Special entities that appear + in the quote. Currently, only bold, italic, underline, strikethrough, spoiler, and + custom_emoji entities are kept in quotes. + is_manual (:obj:`bool`): Optional. :obj:`True`, if the quote was chosen manually by the + message sender. Otherwise, the quote was added automatically by the server. + """ + + __slots__ = ( + "entities", + "is_manual", + "position", + "text", + ) + + def __init__( + self, + text: str, + position: int, + entities: Sequence[MessageEntity] | None = None, + is_manual: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.text: str = text + self.position: int = position + self.entities: tuple[MessageEntity, ...] | None = parse_sequence_arg(entities) + self.is_manual: bool | None = is_manual + + self._id_attrs = ( + self.text, + self.position, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "TextQuote": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["entities"] = de_list_optional(data.get("entities"), MessageEntity, bot) + + return super().de_json(data=data, bot=bot) + + +class ReplyParameters(TelegramObject): + """ + Describes reply parameters for the message that is being sent. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`message_id` is equal. + + .. versionadded:: 20.8 + + .. versionchanged:: 22.5 + The :paramref:`checklist_task_id` parameter has been moved to the last position to + maintain backward compatibility with versions prior to 22.4. + This resolves a breaking change accidentally introduced in version 22.4. See the changelog + for version 22.5 for more information. + + Args: + message_id (:obj:`int`): Identifier of the message that will be replied to in the current + chat, or in the chat :paramref:`chat_id` if it is specified. + chat_id (:obj:`int` | :obj:`str`, optional): If the message to be replied to is from a + different chat, |chat_id_channel| + Not supported for messages sent on behalf of a business account and messages from + channel direct messages chats. + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| Can be + used only for replies in the same chat and forum topic. + quote (:obj:`str`, optional): Quoted part of the message to be replied to; 0-1024 + characters after entities parsing. The quote must be an exact substring of the message + to be replied to, including bold, italic, underline, strikethrough, spoiler, and + custom_emoji entities. The message will fail to send if the quote isn't found in the + original message. + quote_parse_mode (:obj:`str`, optional): Mode for parsing entities in the quote. See + :wiki:`formatting options ` for + more details. + quote_entities (Sequence[:class:`telegram.MessageEntity`], optional): A JSON-serialized + list + of special entities that appear in the quote. It can be specified instead of + :paramref:`quote_parse_mode`. + quote_position (:obj:`int`, optional): Position of the quote in the original message in + UTF-16 code units. + checklist_task_id (:obj:`int`, optional): Identifier of the specific checklist task to be + replied to. + + .. versionadded:: 22.4 + + Attributes: + message_id (:obj:`int`): Identifier of the message that will be replied to in the current + chat, or in the chat :paramref:`chat_id` if it is specified. + chat_id (:obj:`int` | :obj:`str`): Optional. If the message to be replied to is from a + different chat, |chat_id_channel| + Not supported for messages sent on behalf of a business account and messages from + channel direct messages chats. + allow_sending_without_reply (:obj:`bool`): Optional. |allow_sending_without_reply| Can be + used only for replies in the same chat and forum topic. + quote (:obj:`str`): Optional. Quoted part of the message to be replied to; 0-1024 + characters after entities parsing. The quote must be an exact substring of the message + to be replied to, including bold, italic, underline, strikethrough, spoiler, and + custom_emoji entities. The message will fail to send if the quote isn't found in the + original message. + quote_parse_mode (:obj:`str`): Optional. Mode for parsing entities in the quote. See + :wiki:`formatting options ` for + more details. + quote_entities (tuple[:class:`telegram.MessageEntity`]): Optional. A JSON-serialized list + of special entities that appear in the quote. It can be specified instead of + :paramref:`quote_parse_mode`. + quote_position (:obj:`int`): Optional. Position of the quote in the original message in + UTF-16 code units. + checklist_task_id (:obj:`int`): Optional. Identifier of the specific checklist task to be + replied to. + + .. versionadded:: 22.4 + """ + + __slots__ = ( + "allow_sending_without_reply", + "chat_id", + "checklist_task_id", + "message_id", + "quote", + "quote_entities", + "quote_parse_mode", + "quote_position", + ) + + def __init__( + self, + message_id: int, + chat_id: int | str | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + quote: str | None = None, + quote_parse_mode: ODVInput[str] = DEFAULT_NONE, + quote_entities: Sequence[MessageEntity] | None = None, + quote_position: int | None = None, + checklist_task_id: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + + self.message_id: int = message_id + self.chat_id: int | str | None = chat_id + self.allow_sending_without_reply: ODVInput[bool] = allow_sending_without_reply + self.quote: str | None = quote + self.quote_parse_mode: ODVInput[str] = quote_parse_mode + self.quote_entities: tuple[MessageEntity, ...] | None = parse_sequence_arg(quote_entities) + self.quote_position: int | None = quote_position + self.checklist_task_id: int | None = checklist_task_id + + self._id_attrs = (self.message_id,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ReplyParameters": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["quote_entities"] = tuple( + de_list_optional(data.get("quote_entities"), MessageEntity, bot) + ) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_replykeyboardmarkup.py b/src/telegram/_replykeyboardmarkup.py similarity index 85% rename from telegram/_replykeyboardmarkup.py rename to src/telegram/_replykeyboardmarkup.py index 685a73bf93d..99ac0de3b00 100644 --- a/telegram/_replykeyboardmarkup.py +++ b/src/telegram/_replykeyboardmarkup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,8 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram ReplyKeyboardMarkup.""" -from typing import ClassVar, Optional, Sequence, Tuple, Union +from collections.abc import Sequence +from typing import Final from telegram import constants from telegram._keyboardbutton import KeyboardButton @@ -28,7 +29,8 @@ class ReplyKeyboardMarkup(TelegramObject): - """This object represents a custom keyboard with reply options. + """This object represents a custom keyboard with reply options. Not supported in channels and + for messages sent on behalf of a Telegram Business account. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their size of :attr:`keyboard` and all the buttons are equal. @@ -40,7 +42,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 @@ -65,8 +67,8 @@ class ReplyKeyboardMarkup(TelegramObject): 1) Users that are @mentioned in the :attr:`~telegram.Message.text` of the :class:`telegram.Message` object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of the - original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. Defaults to :obj:`False`. @@ -84,7 +86,7 @@ class ReplyKeyboardMarkup(TelegramObject): .. versionadded:: 20.0 Attributes: - keyboard (Tuple[Tuple[:class:`telegram.KeyboardButton`]]): Array of button rows, + keyboard (tuple[tuple[:class:`telegram.KeyboardButton`]]): Array of button rows, each represented by an Array of :class:`telegram.KeyboardButton` objects. resize_keyboard (:obj:`bool`): Optional. Requests clients to resize the keyboard vertically for optimal fit (e.g., make the keyboard smaller if there are just two rows of @@ -99,8 +101,8 @@ class ReplyKeyboardMarkup(TelegramObject): 1) Users that are @mentioned in the :attr:`~telegram.Message.text` of the :class:`telegram.Message` object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of the - original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. Defaults to :obj:`False`. @@ -120,24 +122,24 @@ class ReplyKeyboardMarkup(TelegramObject): """ __slots__ = ( - "selective", - "keyboard", - "resize_keyboard", - "one_time_keyboard", "input_field_placeholder", "is_persistent", + "keyboard", + "one_time_keyboard", + "resize_keyboard", + "selective", ) def __init__( self, - keyboard: Sequence[Sequence[Union[str, KeyboardButton]]], - resize_keyboard: Optional[bool] = None, - one_time_keyboard: Optional[bool] = None, - selective: Optional[bool] = None, - input_field_placeholder: Optional[str] = None, - is_persistent: Optional[bool] = None, + keyboard: Sequence[Sequence[str | KeyboardButton]], + resize_keyboard: bool | None = None, + one_time_keyboard: bool | None = None, + selective: bool | None = None, + input_field_placeholder: str | None = None, + is_persistent: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) if not check_keyboard_type(keyboard): @@ -147,17 +149,17 @@ def __init__( ) # Required - self.keyboard: Tuple[Tuple[KeyboardButton, ...], ...] = tuple( + self.keyboard: tuple[tuple[KeyboardButton, ...], ...] = tuple( tuple(KeyboardButton(button) if isinstance(button, str) else button for button in row) for row in keyboard ) # Optionals - self.resize_keyboard: Optional[bool] = resize_keyboard - self.one_time_keyboard: Optional[bool] = one_time_keyboard - self.selective: Optional[bool] = selective - self.input_field_placeholder: Optional[str] = input_field_placeholder - self.is_persistent: Optional[bool] = is_persistent + self.resize_keyboard: bool | None = resize_keyboard + self.one_time_keyboard: bool | None = one_time_keyboard + self.selective: bool | None = selective + self.input_field_placeholder: str | None = input_field_placeholder + self.is_persistent: bool | None = is_persistent self._id_attrs = (self.keyboard,) @@ -166,12 +168,12 @@ def __init__( @classmethod def from_button( cls, - button: Union[KeyboardButton, str], + button: KeyboardButton | str, resize_keyboard: bool = False, one_time_keyboard: bool = False, selective: bool = False, - input_field_placeholder: Optional[str] = None, - is_persistent: Optional[bool] = None, + input_field_placeholder: str | None = None, + is_persistent: bool | None = None, **kwargs: object, ) -> "ReplyKeyboardMarkup": """Shortcut for:: @@ -196,8 +198,8 @@ def from_button( to specific users only. Targets: 1) Users that are @mentioned in the text of the Message object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of the - original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. Defaults to :obj:`False`. @@ -224,12 +226,12 @@ def from_button( @classmethod def from_row( cls, - button_row: Sequence[Union[str, KeyboardButton]], + button_row: Sequence[str | KeyboardButton], resize_keyboard: bool = False, one_time_keyboard: bool = False, selective: bool = False, - input_field_placeholder: Optional[str] = None, - is_persistent: Optional[bool] = None, + input_field_placeholder: str | None = None, + is_persistent: bool | None = None, **kwargs: object, ) -> "ReplyKeyboardMarkup": """Shortcut for:: @@ -257,8 +259,8 @@ def from_row( to specific users only. Targets: 1) Users that are @mentioned in the text of the Message object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of the - original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. Defaults to :obj:`False`. @@ -286,12 +288,12 @@ def from_row( @classmethod def from_column( cls, - button_column: Sequence[Union[str, KeyboardButton]], + button_column: Sequence[str | KeyboardButton], resize_keyboard: bool = False, one_time_keyboard: bool = False, selective: bool = False, - input_field_placeholder: Optional[str] = None, - is_persistent: Optional[bool] = None, + input_field_placeholder: str | None = None, + is_persistent: bool | None = None, **kwargs: object, ) -> "ReplyKeyboardMarkup": """Shortcut for:: @@ -319,8 +321,8 @@ def from_column( to specific users only. Targets: 1) Users that are @mentioned in the text of the Message object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of the - original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. Defaults to :obj:`False`. @@ -346,12 +348,12 @@ def from_column( **kwargs, # type: ignore[arg-type] ) - MIN_INPUT_FIELD_PLACEHOLDER: ClassVar[int] = constants.ReplyLimit.MIN_INPUT_FIELD_PLACEHOLDER + MIN_INPUT_FIELD_PLACEHOLDER: Final[int] = constants.ReplyLimit.MIN_INPUT_FIELD_PLACEHOLDER """:const:`telegram.constants.ReplyLimit.MIN_INPUT_FIELD_PLACEHOLDER` .. versionadded:: 20.0 """ - MAX_INPUT_FIELD_PLACEHOLDER: ClassVar[int] = constants.ReplyLimit.MAX_INPUT_FIELD_PLACEHOLDER + MAX_INPUT_FIELD_PLACEHOLDER: Final[int] = constants.ReplyLimit.MAX_INPUT_FIELD_PLACEHOLDER """:const:`telegram.constants.ReplyLimit.MAX_INPUT_FIELD_PLACEHOLDER` .. versionadded:: 20.0 diff --git a/telegram/_replykeyboardremove.py b/src/telegram/_replykeyboardremove.py similarity index 82% rename from telegram/_replykeyboardremove.py rename to src/telegram/_replykeyboardremove.py index 9832bcc93a8..05a5839595a 100644 --- a/telegram/_replykeyboardremove.py +++ b/src/telegram/_replykeyboardremove.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # 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 ReplyKeyboardRemove.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -29,6 +28,7 @@ class ReplyKeyboardRemove(TelegramObject): keyboard and display the default letter-keyboard. By default, custom keyboards are displayed until a new keyboard is sent by a bot. An exception is made for one-time keyboards that are hidden immediately after the user presses a button (see :class:`telegram.ReplyKeyboardMarkup`). + Not supported in channels and for messages sent on behalf of a Telegram Business account. Note: User will not be able to summon this keyboard; if you want to hide the keyboard from @@ -46,8 +46,8 @@ class ReplyKeyboardRemove(TelegramObject): for specific users only. Targets: 1) Users that are @mentioned in the text of the :class:`telegram.Message` object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of - the original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. Attributes: remove_keyboard (:obj:`True`): Requests clients to remove the custom keyboard. @@ -55,18 +55,18 @@ class ReplyKeyboardRemove(TelegramObject): Targets: 1) Users that are @mentioned in the text of the :class:`telegram.Message` object. - 2) If the bot's message is a reply (has ``reply_to_message_id``), sender of - the original message. + 2) If the bot's message is a reply to a message in the same chat and forum topic, + sender of the original message. """ - __slots__ = ("selective", "remove_keyboard") + __slots__ = ("remove_keyboard", "selective") - def __init__(self, selective: Optional[bool] = None, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, selective: bool | None = None, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) # Required self.remove_keyboard: bool = True # Optionals - self.selective: Optional[bool] = selective + self.selective: bool | None = selective self._freeze() diff --git a/telegram/_sentwebappmessage.py b/src/telegram/_sentwebappmessage.py similarity index 90% rename from telegram/_sentwebappmessage.py rename to src/telegram/_sentwebappmessage.py index 395d12cfe28..1d26e231b9e 100644 --- a/telegram/_sentwebappmessage.py +++ b/src/telegram/_sentwebappmessage.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,6 @@ # 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 Sent Web App Message.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -45,11 +44,11 @@ class SentWebAppMessage(TelegramObject): __slots__ = ("inline_message_id",) def __init__( - self, inline_message_id: Optional[str] = None, *, api_kwargs: Optional[JSONDict] = None + self, inline_message_id: str | None = None, *, api_kwargs: JSONDict | None = None ): super().__init__(api_kwargs=api_kwargs) # Optionals - self.inline_message_id: Optional[str] = inline_message_id + self.inline_message_id: str | None = inline_message_id self._id_attrs = (self.inline_message_id,) diff --git a/src/telegram/_shared.py b/src/telegram/_shared.py new file mode 100644 index 00000000000..2de07529c62 --- /dev/null +++ b/src/telegram/_shared.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 two objects used for request chats/users service messages.""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from telegram._files.photosize import PhotoSize +from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg +from telegram._utils.types import JSONDict +from telegram._utils.usernames import get_full_name, get_link, get_name + +if TYPE_CHECKING: + from telegram._bot import Bot + + +class UsersShared(TelegramObject): + """ + This object contains information about the user whose identifier was shared with the bot + using a :class:`telegram.KeyboardButtonRequestUsers` button. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`request_id` and :attr:`users` are equal. + + .. versionadded:: 20.8 + Bot API 7.0 replaces ``UserShared`` with this class. The only difference is that now + the ``user_ids`` is a sequence instead of a single integer. + + .. versionchanged:: 21.1 + The argument :attr:`users` is now considered for the equality comparison instead of + ``user_ids``. + + .. versionremoved:: 21.2 + Removed the deprecated argument and attribute ``user_ids``. + + Args: + request_id (:obj:`int`): Identifier of the request. + users (Sequence[:class:`telegram.SharedUser`]): Information about users shared with the + bot. + + .. versionadded:: 21.1 + + .. versionchanged:: 21.2 + This argument is now required. + + Attributes: + request_id (:obj:`int`): Identifier of the request. + users (tuple[:class:`telegram.SharedUser`]): Information about users shared with the + bot. + + .. versionadded:: 21.1 + """ + + __slots__ = ("request_id", "users") + + def __init__( + self, + request_id: int, + users: Sequence["SharedUser"], + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.request_id: int = request_id + self.users: tuple[SharedUser, ...] = parse_sequence_arg(users) + + self._id_attrs = (self.request_id, self.users) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "UsersShared": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["users"] = de_list_optional(data.get("users"), SharedUser, bot) + + api_kwargs = {} + # This is a deprecated field that TG still returns for backwards compatibility + # Let's filter it out to speed up the de-json process + if user_ids := data.get("user_ids"): + api_kwargs = {"user_ids": user_ids} + + return super()._de_json(data=data, bot=bot, api_kwargs=api_kwargs) + + +class ChatShared(TelegramObject): + """ + This object contains information about the chat whose identifier was shared with the bot + using a :class:`telegram.KeyboardButtonRequestChat` button. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`request_id` and :attr:`chat_id` are equal. + + .. versionadded:: 20.1 + + Args: + request_id (:obj:`int`): Identifier of the request. + chat_id (:obj:`int`): Identifier of the shared user. This number may be greater than 32 + bits and some programming languages may have difficulty/silent defects in interpreting + it. But it is smaller than 52 bits, so a signed 64-bit integer or double-precision + float type are safe for storing this identifier. + title (:obj:`str`, optional): Title of the chat, if the title was requested by the bot. + + .. versionadded:: 21.1 + username (:obj:`str`, optional): Username of the chat, if the username was requested by + the bot and available. + + .. versionadded:: 21.1 + photo (Sequence[:class:`telegram.PhotoSize`], optional): Available sizes of the chat photo, + if the photo was requested by the bot + + .. versionadded:: 21.1 + + Attributes: + request_id (:obj:`int`): Identifier of the request. + chat_id (:obj:`int`): Identifier of the shared user. This number may be greater than 32 + bits and some programming languages may have difficulty/silent defects in interpreting + it. But it is smaller than 52 bits, so a signed 64-bit integer or double-precision + float type are safe for storing this identifier. + title (:obj:`str`): Optional. Title of the chat, if the title was requested by the bot. + + .. versionadded:: 21.1 + username (:obj:`str`): Optional. Username of the chat, if the username was requested by + the bot and available. + + .. versionadded:: 21.1 + photo (tuple[:class:`telegram.PhotoSize`]): Optional. Available sizes of the chat photo, + if the photo was requested by the bot + + .. versionadded:: 21.1 + """ + + __slots__ = ("chat_id", "photo", "request_id", "title", "username") + + def __init__( + self, + request_id: int, + chat_id: int, + title: str | None = None, + username: str | None = None, + photo: Sequence[PhotoSize] | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.request_id: int = request_id + self.chat_id: int = chat_id + self.title: str | None = title + self.username: str | None = username + self.photo: tuple[PhotoSize, ...] | None = parse_sequence_arg(photo) + + self._id_attrs = (self.request_id, self.chat_id) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ChatShared": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) + return super().de_json(data=data, bot=bot) + + @property + def link(self) -> str | None: + """:obj:`str`: Convenience property. If :attr:`username` is available, returns a t.me link + of the chat. + + .. versionadded:: 22.4 + """ + return get_link(self) + + +class SharedUser(TelegramObject): + """ + This object contains information about a user that was shared with the bot using a + :class:`telegram.KeyboardButtonRequestUsers` button. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`user_id` is equal. + + .. versionadded:: 21.1 + + Args: + user_id (:obj:`int`): Identifier of the shared user. This number may have 32 significant + bits and some programming languages may have difficulty/silent defects in interpreting + it. But it has atmost 52 significant bits, so 64-bit integers or double-precision + float types are safe for storing these identifiers. The bot may not have access to the + user and could be unable to use this identifier, unless the user is already known to + the bot by some other means. + first_name (:obj:`str`, optional): First name of the user, if the name was requested by the + bot. + last_name (:obj:`str`, optional): Last name of the user, if the name was requested by the + bot. + username (:obj:`str`, optional): Username of the user, if the username was requested by the + bot. + photo (Sequence[:class:`telegram.PhotoSize`], optional): Available sizes of the chat photo, + if the photo was requested by the bot. + + Attributes: + user_id (:obj:`int`): Identifier of the shared user. This number may have 32 significant + bits and some programming languages may have difficulty/silent defects in interpreting + it. But it has atmost 52 significant bits, so 64-bit integers or double-precision + float types are safe for storing these identifiers. The bot may not have access to the + user and could be unable to use this identifier, unless the user is already known to + the bot by some other means. + first_name (:obj:`str`): Optional. First name of the user, if the name was requested by the + bot. + last_name (:obj:`str`): Optional. Last name of the user, if the name was requested by the + bot. + username (:obj:`str`): Optional. Username of the user, if the username was requested by the + bot. + photo (tuple[:class:`telegram.PhotoSize`]): Available sizes of the chat photo, if + the photo was requested by the bot. This list is empty if the photo was not requsted. + """ + + __slots__ = ("first_name", "last_name", "photo", "user_id", "username") + + def __init__( + self, + user_id: int, + first_name: str | None = None, + last_name: str | None = None, + username: str | None = None, + photo: Sequence[PhotoSize] | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.user_id: int = user_id + self.first_name: str | None = first_name + self.last_name: str | None = last_name + self.username: str | None = username + self.photo: tuple[PhotoSize, ...] | None = parse_sequence_arg(photo) + + self._id_attrs = (self.user_id,) + + self._freeze() + + @property + def name(self) -> str | None: + """:obj:`str`: Convenience property. If available, returns the user's :attr:`username` + prefixed with "@". If :attr:`username` is not available, returns :attr:`full_name`. + + .. versionadded:: 22.4 + """ + return get_name(self) + + @property + def full_name(self) -> str | None: + """:obj:`str`: Convenience property. If :attr:`first_name` is not :obj:`None`, gives + :attr:`first_name` followed by (if available) :attr:`last_name`. + + .. versionadded:: 22.4 + """ + return get_full_name(self) + + @property + def link(self) -> str | None: + """:obj:`str`: Convenience property. If :attr:`username` is available, returns a t.me link + of the user. + + .. versionadded:: 22.4 + """ + return get_link(self) + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "SharedUser": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_story.py b/src/telegram/_story.py new file mode 100644 index 00000000000..680e7206230 --- /dev/null +++ b/src/telegram/_story.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 related to a Telegram Story.""" + +from typing import TYPE_CHECKING + +from telegram._chat import Chat +from telegram._telegramobject import TelegramObject +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.types import JSONDict, ODVInput, TimePeriod + +if TYPE_CHECKING: + from telegram import Bot + + +class Story(TelegramObject): + """ + This object represents a story. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`chat` and :attr:`id` are equal. + + .. versionadded:: 20.5 + + .. versionchanged:: 21.0 + Added attributes :attr:`chat` and :attr:`id` and equality based on them. + + Args: + chat (:class:`telegram.Chat`): Chat that posted the story. + id (:obj:`int`): Unique identifier for the story in the chat. + + Attributes: + chat (:class:`telegram.Chat`): Chat that posted the story. + id (:obj:`int`): Unique identifier for the story in the chat. + + """ + + __slots__ = ( + "chat", + "id", + ) + + def __init__( + self, + chat: Chat, + id: int, # pylint: disable=redefined-builtin + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.chat: Chat = chat + self.id: int = id + + self._id_attrs = (self.chat, self.id) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Story": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["chat"] = Chat.de_json(data.get("chat", {}), bot) + return super().de_json(data=data, bot=bot) + + async def repost( + self, + business_connection_id: str, + active_period: TimePeriod, + post_to_chat_page: bool | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Story": + """Shortcut for:: + + await bot.repost_story( + from_chat_id=story.chat.id, + from_story_id=story.id, + *args, **kwargs + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.repost_story`. + + .. versionadded:: 22.6 + + Returns: + :class:`Story`: On success, :class:`Story` is returned. + + """ + return await self.get_bot().repost_story( + business_connection_id=business_connection_id, + from_chat_id=self.chat.id, + from_story_id=self.id, + active_period=active_period, + post_to_chat_page=post_to_chat_page, + protect_content=protect_content, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) diff --git a/src/telegram/_storyarea.py b/src/telegram/_storyarea.py new file mode 100644 index 00000000000..27d8f168cf2 --- /dev/null +++ b/src/telegram/_storyarea.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 story areas.""" + +from typing import Final + +from telegram import constants +from telegram._reaction import ReactionType +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.types import JSONDict + + +class StoryAreaPosition(TelegramObject): + """Describes the position of a clickable area within a story. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if all of their attributes are equal. + + .. versionadded:: 22.1 + + Args: + x_percentage (:obj:`float`): The abscissa of the area's center, as a percentage of the + media width. + y_percentage (:obj:`float`): The ordinate of the area's center, as a percentage of the + media height. + width_percentage (:obj:`float`): The width of the area's rectangle, as a percentage of the + media width. + height_percentage (:obj:`float`): The height of the area's rectangle, as a percentage of + the media height. + rotation_angle (:obj:`float`): The clockwise rotation angle of the rectangle, in degrees; + 0-:tg-const:`~telegram.constants.StoryAreaPositionLimit.MAX_ROTATION_ANGLE`. + corner_radius_percentage (:obj:`float`): The radius of the rectangle corner rounding, as a + percentage of the media width. + + Attributes: + x_percentage (:obj:`float`): The abscissa of the area's center, as a percentage of the + media width. + y_percentage (:obj:`float`): The ordinate of the area's center, as a percentage of the + media height. + width_percentage (:obj:`float`): The width of the area's rectangle, as a percentage of the + media width. + height_percentage (:obj:`float`): The height of the area's rectangle, as a percentage of + the media height. + rotation_angle (:obj:`float`): The clockwise rotation angle of the rectangle, in degrees; + 0-:tg-const:`~telegram.constants.StoryAreaPositionLimit.MAX_ROTATION_ANGLE`. + corner_radius_percentage (:obj:`float`): The radius of the rectangle corner rounding, as a + percentage of the media width. + + """ + + __slots__ = ( + "corner_radius_percentage", + "height_percentage", + "rotation_angle", + "width_percentage", + "x_percentage", + "y_percentage", + ) + + def __init__( + self, + x_percentage: float, + y_percentage: float, + width_percentage: float, + height_percentage: float, + rotation_angle: float, + corner_radius_percentage: float, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.x_percentage: float = x_percentage + self.y_percentage: float = y_percentage + self.width_percentage: float = width_percentage + self.height_percentage: float = height_percentage + self.rotation_angle: float = rotation_angle + self.corner_radius_percentage: float = corner_radius_percentage + + self._id_attrs = ( + self.x_percentage, + self.y_percentage, + self.width_percentage, + self.height_percentage, + self.rotation_angle, + self.corner_radius_percentage, + ) + self._freeze() + + +class LocationAddress(TelegramObject): + """Describes the physical address of a location. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`country_code`, :attr:`state`, :attr:`city` and :attr:`street` + are equal. + + .. versionadded:: 22.1 + + Args: + country_code (:obj:`str`): The two-letter ``ISO 3166-1 alpha-2`` country code of the + country where the location is located. + state (:obj:`str`, optional): State of the location. + city (:obj:`str`, optional): City of the location. + street (:obj:`str`, optional): Street address of the location. + + Attributes: + country_code (:obj:`str`): The two-letter ``ISO 3166-1 alpha-2`` country code of the + country where the location is located. + state (:obj:`str`): Optional. State of the location. + city (:obj:`str`): Optional. City of the location. + street (:obj:`str`): Optional. Street address of the location. + + """ + + __slots__ = ("city", "country_code", "state", "street") + + def __init__( + self, + country_code: str, + state: str | None = None, + city: str | None = None, + street: str | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.country_code: str = country_code + self.state: str | None = state + self.city: str | None = city + self.street: str | None = street + + self._id_attrs = (self.country_code, self.state, self.city, self.street) + self._freeze() + + +class StoryAreaType(TelegramObject): + """Describes the type of a clickable area on a story. Currently, it can be one of: + + * :class:`telegram.StoryAreaTypeLocation` + * :class:`telegram.StoryAreaTypeSuggestedReaction` + * :class:`telegram.StoryAreaTypeLink` + * :class:`telegram.StoryAreaTypeWeather` + * :class:`telegram.StoryAreaTypeUniqueGift` + + 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:: 22.1 + + Args: + type (:obj:`str`): Type of the area. + + Attributes: + type (:obj:`str`): Type of the area. + + """ + + __slots__ = ("type",) + + LOCATION: Final[str] = constants.StoryAreaTypeType.LOCATION + """:const:`telegram.constants.StoryAreaTypeType.LOCATION`""" + SUGGESTED_REACTION: Final[str] = constants.StoryAreaTypeType.SUGGESTED_REACTION + """:const:`telegram.constants.StoryAreaTypeType.SUGGESTED_REACTION`""" + LINK: Final[str] = constants.StoryAreaTypeType.LINK + """:const:`telegram.constants.StoryAreaTypeType.LINK`""" + WEATHER: Final[str] = constants.StoryAreaTypeType.WEATHER + """:const:`telegram.constants.StoryAreaTypeType.WEATHER`""" + UNIQUE_GIFT: Final[str] = constants.StoryAreaTypeType.UNIQUE_GIFT + """:const:`telegram.constants.StoryAreaTypeType.UNIQUE_GIFT`""" + + def __init__( + self, + type: str, # pylint: disable=redefined-builtin + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.StoryAreaTypeType, type, type) + + self._id_attrs = (self.type,) + self._freeze() + + +class StoryAreaTypeLocation(StoryAreaType): + """Describes a story area pointing to a location. Currently, a story can have up to + :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_LOCATION_AREAS` location areas. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`latitude` and :attr:`longitude` are equal. + + .. versionadded:: 22.1 + + Args: + latitude (:obj:`float`): Location latitude in degrees. + longitude (:obj:`float`): Location longitude in degrees. + address (:class:`telegram.LocationAddress`, optional): Address of the location. + + Attributes: + type (:obj:`str`): Type of the area, always :attr:`~telegram.StoryAreaType.LOCATION`. + latitude (:obj:`float`): Location latitude in degrees. + longitude (:obj:`float`): Location longitude in degrees. + address (:class:`telegram.LocationAddress`): Optional. Address of the location. + + """ + + __slots__ = ("address", "latitude", "longitude") + + def __init__( + self, + latitude: float, + longitude: float, + address: LocationAddress | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=StoryAreaType.LOCATION, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.latitude: float = latitude + self.longitude: float = longitude + self.address: LocationAddress | None = address + + self._id_attrs = (self.type, self.latitude, self.longitude) + + +class StoryAreaTypeSuggestedReaction(StoryAreaType): + """ + Describes a story area pointing to a suggested reaction. Currently, a story can have up to + :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_SUGGESTED_REACTION_AREAS` + suggested reaction areas. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`reaction_type`, :attr:`is_dark` and :attr:`is_flipped` + are equal. + + .. versionadded:: 22.1 + + Args: + reaction_type (:class:`ReactionType`): Type of the reaction. + is_dark (:obj:`bool`, optional): Pass :obj:`True` if the reaction area has a dark + background. + is_flipped (:obj:`bool`, optional): Pass :obj:`True` if reaction area corner is flipped. + + Attributes: + type (:obj:`str`): Type of the area, always + :tg-const:`~telegram.StoryAreaType.SUGGESTED_REACTION`. + reaction_type (:class:`ReactionType`): Type of the reaction. + is_dark (:obj:`bool`): Optional. Pass :obj:`True` if the reaction area has a dark + background. + is_flipped (:obj:`bool`): Optional. Pass :obj:`True` if reaction area corner is flipped. + + """ + + __slots__ = ("is_dark", "is_flipped", "reaction_type") + + def __init__( + self, + reaction_type: ReactionType, + is_dark: bool | None = None, + is_flipped: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=StoryAreaType.SUGGESTED_REACTION, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.reaction_type: ReactionType = reaction_type + self.is_dark: bool | None = is_dark + self.is_flipped: bool | None = is_flipped + + self._id_attrs = (self.type, self.reaction_type, self.is_dark, self.is_flipped) + + +class StoryAreaTypeLink(StoryAreaType): + """Describes a story area pointing to an ``HTTP`` or ``tg://`` link. Currently, a story can + have up to :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_LINK_AREAS` link areas. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`url` is equal. + + .. versionadded:: 22.1 + + Args: + url (:obj:`str`): ``HTTP`` or ``tg://`` URL to be opened when the area is clicked. + + Attributes: + type (:obj:`str`): Type of the area, always :attr:`~telegram.StoryAreaType.LINK`. + url (:obj:`str`): ``HTTP`` or ``tg://`` URL to be opened when the area is clicked. + + """ + + __slots__ = ("url",) + + def __init__( + self, + url: str, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=StoryAreaType.LINK, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.url: str = url + + self._id_attrs = (self.type, self.url) + + +class StoryAreaTypeWeather(StoryAreaType): + """ + Describes a story area containing weather information. Currently, a story can have up to + :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_WEATHER_AREAS` weather areas. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`temperature`, :attr:`emoji` and + :attr:`background_color` are equal. + + .. versionadded:: 22.1 + + Args: + temperature (:obj:`float`): Temperature, in degree Celsius. + emoji (:obj:`str`): Emoji representing the weather. + background_color (:obj:`int`): A color of the area background in the ``ARGB`` format. + + Attributes: + type (:obj:`str`): Type of the area, always + :tg-const:`~telegram.StoryAreaType.WEATHER`. + temperature (:obj:`float`): Temperature, in degree Celsius. + emoji (:obj:`str`): Emoji representing the weather. + background_color (:obj:`int`): A color of the area background in the ``ARGB`` format. + + """ + + __slots__ = ("background_color", "emoji", "temperature") + + def __init__( + self, + temperature: float, + emoji: str, + background_color: int, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=StoryAreaType.WEATHER, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.temperature: float = temperature + self.emoji: str = emoji + self.background_color: int = background_color + + self._id_attrs = (self.type, self.temperature, self.emoji, self.background_color) + + +class StoryAreaTypeUniqueGift(StoryAreaType): + """ + Describes a story area pointing to a unique gift. Currently, a story can have at most + :tg-const:`~telegram.constants.StoryAreaTypeLimit.MAX_UNIQUE_GIFT_AREAS` unique gift area. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`name` is equal. + + .. versionadded:: 22.1 + + Args: + name (:obj:`str`): Unique name of the gift. + + Attributes: + type (:obj:`str`): Type of the area, always + :tg-const:`~telegram.StoryAreaType.UNIQUE_GIFT`. + name (:obj:`str`): Unique name of the gift. + + """ + + __slots__ = ("name",) + + def __init__( + self, + name: str, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=StoryAreaType.UNIQUE_GIFT, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.name: str = name + + self._id_attrs = (self.type, self.name) + + +class StoryArea(TelegramObject): + """Describes a clickable area on a story media. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`position` and :attr:`type` are equal. + + .. versionadded:: 22.1 + + Args: + position (:class:`telegram.StoryAreaPosition`): Position of the area. + type (:class:`telegram.StoryAreaType`): Type of the area. + + Attributes: + position (:class:`telegram.StoryAreaPosition`): Position of the area. + type (:class:`telegram.StoryAreaType`): Type of the area. + + """ + + __slots__ = ("position", "type") + + def __init__( + self, + position: StoryAreaPosition, + type: StoryAreaType, # pylint: disable=redefined-builtin + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.position: StoryAreaPosition = position + self.type: StoryAreaType = type + self._id_attrs = (self.position, self.type) + + self._freeze() diff --git a/src/telegram/_suggestedpost.py b/src/telegram/_suggestedpost.py new file mode 100644 index 00000000000..b820706685a --- /dev/null +++ b/src/telegram/_suggestedpost.py @@ -0,0 +1,573 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 related to Telegram suggested posts.""" + +import datetime as dtm +from typing import TYPE_CHECKING, Final, Optional + +from telegram import constants +from telegram._message import Message +from telegram._payment.stars.staramount import StarAmount +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional +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 SuggestedPostPrice(TelegramObject): + """ + Desribes the price of a suggested post. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`currency` and :attr:`amount` are equal. + + .. versionadded:: 22.4 + + Args: + currency (:obj:`str`): + Currency in which the post will be paid. Currently, must be one of ``“XTR”`` for + Telegram Stars or ``“TON”`` for toncoins. + amount (:obj:`int`): + The amount of the currency that will be paid for the post in the smallest units of the + currency, i.e. Telegram Stars or nanotoncoins. Currently, price in Telegram Stars must + be between :tg-const:`telegram.constants.SuggestedPost.MIN_PRICE_STARS` + and :tg-const:`telegram.constants.SuggestedPost.MAX_PRICE_STARS`, and price in + nanotoncoins must be between + :tg-const:`telegram.constants.SuggestedPost.MIN_PRICE_NANOTONCOINS` + and :tg-const:`telegram.constants.SuggestedPost.MAX_PRICE_NANOTONCOINS`. + + Attributes: + currency (:obj:`str`): + Currency in which the post will be paid. Currently, must be one of ``“XTR”`` for + Telegram Stars or ``“TON”`` for toncoins. + amount (:obj:`int`): + The amount of the currency that will be paid for the post in the smallest units of the + currency, i.e. Telegram Stars or nanotoncoins. Currently, price in Telegram Stars must + be between :tg-const:`telegram.constants.SuggestedPost.MIN_PRICE_STARS` + and :tg-const:`telegram.constants.SuggestedPost.MAX_PRICE_STARS`, and price in + nanotoncoins must be between + :tg-const:`telegram.constants.SuggestedPost.MIN_PRICE_NANOTONCOINS` + and :tg-const:`telegram.constants.SuggestedPost.MAX_PRICE_NANOTONCOINS`. + """ + + __slots__ = ("amount", "currency") + + def __init__( + self, + currency: str, + amount: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.currency: str = currency + self.amount: int = amount + + self._id_attrs = (self.currency, self.amount) + + self._freeze() + + +class SuggestedPostParameters(TelegramObject): + """ + Contains parameters of a post that is being suggested by the bot. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`price` and :attr:`send_date` are equal. + + .. versionadded:: 22.4 + + Args: + price (:class:`telegram.SuggestedPostPrice`, optional): + Proposed price for the post. If the field is omitted, then the post is unpaid. + send_date (:class:`datetime.datetime`, optional): + Proposed send date of the post. If specified, then the date + must be between :tg-const:`telegram.constants.SuggestedPost.MIN_SEND_DATE` + second and :tg-const:`telegram.constants.SuggestedPost.MAX_SEND_DATE` seconds (30 days) + in the future. If the field is omitted, then the post can be published at any time + within :tg-const:`telegram.constants.SuggestedPost.MAX_SEND_DATE` seconds (30 days) at + the sole discretion of the user who approves it. + |datetime_localization| + + Attributes: + price (:class:`telegram.SuggestedPostPrice`): + Optional. Proposed price for the post. If the field is omitted, then the post + is unpaid. + send_date (:class:`datetime.datetime`): + Optional. Proposed send date of the post. If specified, then the date + must be between :tg-const:`telegram.constants.SuggestedPost.MIN_SEND_DATE` + second and :tg-const:`telegram.constants.SuggestedPost.MAX_SEND_DATE` seconds (30 days) + in the future. If the field is omitted, then the post can be published at any time + within :tg-const:`telegram.constants.SuggestedPost.MAX_SEND_DATE` seconds (30 days) at + the sole discretion of the user who approves it. + |datetime_localization| + + """ + + __slots__ = ("price", "send_date") + + def __init__( + self, + price: SuggestedPostPrice | None = None, + send_date: dtm.datetime | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.price: SuggestedPostPrice | None = price + self.send_date: dtm.datetime | None = send_date + + self._id_attrs = (self.price, self.send_date) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SuggestedPostParameters": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["price"] = de_json_optional(data.get("price"), SuggestedPostPrice, bot) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["send_date"] = from_timestamp(data.get("send_date"), tzinfo=loc_tzinfo) + + return super().de_json(data=data, bot=bot) + + +class SuggestedPostInfo(TelegramObject): + """ + Contains information about a suggested post. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`state` and :attr:`price` are equal. + + .. versionadded:: 22.4 + + Args: + state (:obj:`str`): + State of the suggested post. Currently, it can be one of + :tg-const:`~telegram.constants.SuggestedPostInfoState.PENDING`, + :tg-const:`~telegram.constants.SuggestedPostInfoState.APPROVED`, + :tg-const:`~telegram.constants.SuggestedPostInfoState.DECLINED`. + price (:obj:`SuggestedPostPrice`, optional): + Proposed price of the post. If the field is omitted, then the post is unpaid. + send_date (:class:`datetime.datetime`, optional): + Proposed send date of the post. If the field is omitted, then the post can be published + at any time within 30 days at the sole discretion of the user or administrator who + approves it. + |datetime_localization| + + Attributes: + state (:obj:`str`): + State of the suggested post. Currently, it can be one of + :tg-const:`~telegram.constants.SuggestedPostInfoState.PENDING`, + :tg-const:`~telegram.constants.SuggestedPostInfoState.APPROVED`, + :tg-const:`~telegram.constants.SuggestedPostInfoState.DECLINED`. + price (:obj:`SuggestedPostPrice`): + Optional. Proposed price of the post. If the field is omitted, then the post is unpaid. + send_date (:class:`datetime.datetime`): + Optional. Proposed send date of the post. If the field is omitted, then the post can be + published at any time within 30 days at the sole discretion of the user or + administrator who approves it. + |datetime_localization| + + """ + + __slots__ = ("price", "send_date", "state") + + PENDING: Final[str] = constants.SuggestedPostInfoState.PENDING + """:const:`telegram.constants.SuggestedPostInfoState.PENDING`""" + APPROVED: Final[str] = constants.SuggestedPostInfoState.APPROVED + """:const:`telegram.constants.SuggestedPostInfoState.APPROVED`""" + DECLINED: Final[str] = constants.SuggestedPostInfoState.DECLINED + """:const:`telegram.constants.SuggestedPostInfoState.DECLINED`""" + + def __init__( + self, + state: str, + price: SuggestedPostPrice | None = None, + send_date: dtm.datetime | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.state: str = enum.get_member(constants.SuggestedPostInfoState, state, state) + # Optionals + self.price: SuggestedPostPrice | None = price + self.send_date: dtm.datetime | None = send_date + + self._id_attrs = (self.state, self.price) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SuggestedPostInfo": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["price"] = de_json_optional(data.get("price"), SuggestedPostPrice, bot) + data["send_date"] = from_timestamp(data.get("send_date"), tzinfo=loc_tzinfo) + + return super().de_json(data=data, bot=bot) + + +class SuggestedPostDeclined(TelegramObject): + """ + Describes a service message about the rejection of a suggested post. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`suggested_post_message` and :attr:`comment` are equal. + + .. versionadded:: 22.4 + + Args: + suggested_post_message (:class:`telegram.Message`, optional): + Message containing the suggested post. Note that the :class:`~telegram.Message` object + in this field will not contain the :attr:`~telegram.Message.reply_to_message` field + even if it itself is a reply. + comment (:obj:`str`, optional): + Comment with which the post was declined. + + Attributes: + suggested_post_message (:class:`telegram.Message`): + Optional. Message containing the suggested post. Note that the + :class:`~telegram.Message` object in this field will not contain + the :attr:`~telegram.Message.reply_to_message` field even if it itself is a reply. + comment (:obj:`str`): + Optional. Comment with which the post was declined. + + """ + + __slots__ = ("comment", "suggested_post_message") + + def __init__( + self, + suggested_post_message: Message | None = None, + comment: str | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.suggested_post_message: Message | None = suggested_post_message + self.comment: str | None = comment + + self._id_attrs = (self.suggested_post_message, self.comment) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SuggestedPostDeclined": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["suggested_post_message"] = de_json_optional( + data.get("suggested_post_message"), Message, bot + ) + + return super().de_json(data=data, bot=bot) + + +class SuggestedPostPaid(TelegramObject): + """ + Describes a service message about a successful payment for a suggested post. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if all of their attributes are equal. + + .. versionadded:: 22.4 + + Args: + suggested_post_message (:class:`telegram.Message`, optional): + Message containing the suggested post. Note that the :class:`~telegram.Message` object + in this field will not contain the :attr:`~telegram.Message.reply_to_message` field + even if it itself is a reply. + currency (:obj:`str`): + Currency in which the payment was made. Currently, one of ``“XTR”`` for Telegram Stars + or ``“TON”`` for toncoins. + amount (:obj:`int`, optional): + The amount of the currency that was received by the channel in nanotoncoins; for + payments in toncoins only. + star_amount (:class:`telegram.StarAmount`, optional): + The amount of Telegram Stars that was received by the channel; for payments in Telegram + Stars only. + + + Attributes: + suggested_post_message (:class:`telegram.Message`): + Optional. Message containing the suggested post. Note that the + :class:`~telegram.Message` object in this field will not contain + the :attr:`~telegram.Message.reply_to_message` field even if it itself is a reply. + currency (:obj:`str`): + Currency in which the payment was made. Currently, one of ``“XTR”`` for Telegram Stars + or ``“TON”`` for toncoins. + amount (:obj:`int`): + Optional. The amount of the currency that was received by the channel in nanotoncoins; + for payments in toncoins only. + star_amount (:class:`telegram.StarAmount`): + Optional. The amount of Telegram Stars that was received by the channel; for payments + in Telegram Stars only. + + """ + + __slots__ = ("amount", "currency", "star_amount", "suggested_post_message") + + def __init__( + self, + currency: str, + suggested_post_message: Message | None = None, + amount: int | None = None, + star_amount: StarAmount | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.currency: str = currency + # Optionals + self.suggested_post_message: Message | None = suggested_post_message + self.amount: int | None = amount + self.star_amount: StarAmount | None = star_amount + + self._id_attrs = ( + self.currency, + self.suggested_post_message, + self.amount, + self.star_amount, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SuggestedPostPaid": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["suggested_post_message"] = de_json_optional( + data.get("suggested_post_message"), Message, bot + ) + data["star_amount"] = de_json_optional(data.get("star_amount"), StarAmount, bot) + + return super().de_json(data=data, bot=bot) + + +class SuggestedPostRefunded(TelegramObject): + """ + Describes a service message about a payment refund for a suggested post. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`suggested_post_message` and :attr:`reason` are equal. + + .. versionadded:: 22.4 + + Args: + suggested_post_message (:class:`telegram.Message`, optional): + Message containing the suggested post. Note that the :class:`~telegram.Message` object + in this field will not contain the :attr:`~telegram.Message.reply_to_message` field + even if it itself is a reply. + reason (:obj:`str`): + Reason for the refund. Currently, + one of :tg-const:`telegram.constants.SuggestedPostRefunded.POST_DELETED` if the post + was deleted within 24 hours of being posted or removed from scheduled messages without + being posted, or :tg-const:`telegram.constants.SuggestedPostRefunded.PAYMENT_REFUNDED` + if the payer refunded their payment. + + Attributes: + suggested_post_message (:class:`telegram.Message`): + Optional. Message containing the suggested post. Note that the + :class:`~telegram.Message` object in this field will not contain + the :attr:`~telegram.Message.reply_to_message` field even if it itself is a reply. + reason (:obj:`str`): + Reason for the refund. Currently, + one of :tg-const:`telegram.constants.SuggestedPostRefunded.POST_DELETED` if the post + was deleted within 24 hours of being posted or removed from scheduled messages without + being posted, or :tg-const:`telegram.constants.SuggestedPostRefunded.PAYMENT_REFUNDED` + if the payer refunded their payment. + + """ + + __slots__ = ("reason", "suggested_post_message") + + def __init__( + self, + reason: str, + suggested_post_message: Message | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.reason: str = reason + # Optionals + self.suggested_post_message: Message | None = suggested_post_message + + self._id_attrs = (self.reason, self.suggested_post_message) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SuggestedPostRefunded": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["suggested_post_message"] = de_json_optional( + data.get("suggested_post_message"), Message, bot + ) + + return super().de_json(data=data, bot=bot) + + +class SuggestedPostApproved(TelegramObject): + """ + Describes a service message about the approval of a suggested post. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if all of their attributes are equal. + + .. versionadded:: 22.4 + + Args: + suggested_post_message (:class:`telegram.Message`, optional): + Message containing the suggested post. Note that the :class:`~telegram.Message` object + in this field will not contain the :attr:`~telegram.Message.reply_to_message` field + even if it itself is a reply. + price (:obj:`SuggestedPostPrice`, optional): + Amount paid for the post. + send_date (:class:`datetime.datetime`): + Date when the post will be published. + |datetime_localization| + + Attributes: + suggested_post_message (:class:`telegram.Message`): + Optional. Message containing the suggested post. Note that the + :class:`~telegram.Message` object in this field will not contain + the :attr:`~telegram.Message.reply_to_message` field even if it itself is a reply. + price (:obj:`SuggestedPostPrice`): + Optional. Amount paid for the post. + send_date (:class:`datetime.datetime`): + Date when the post will be published. + |datetime_localization| + + """ + + __slots__ = ("price", "send_date", "suggested_post_message") + + def __init__( + self, + send_date: dtm.datetime, + suggested_post_message: Message | None = None, + price: SuggestedPostPrice | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.send_date: dtm.datetime = send_date + # Optionals + self.suggested_post_message: Message | None = suggested_post_message + self.price: SuggestedPostPrice | None = price + + self._id_attrs = (self.send_date, self.suggested_post_message, self.price) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SuggestedPostApproved": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + + data["send_date"] = from_timestamp(data.get("send_date"), tzinfo=loc_tzinfo) + data["price"] = de_json_optional(data.get("price"), SuggestedPostPrice, bot) + data["suggested_post_message"] = de_json_optional( + data.get("suggested_post_message"), Message, bot + ) + + return super().de_json(data=data, bot=bot) + + +class SuggestedPostApprovalFailed(TelegramObject): + """ + Describes a service message about the failed approval of a suggested post. Currently, only + caused by insufficient user funds at the time of approval. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`suggested_post_message` and :attr:`price` are equal. + + .. versionadded:: 22.4 + + Args: + suggested_post_message (:class:`telegram.Message`, optional): + Message containing the suggested post. Note that the :class:`~telegram.Message` object + in this field will not contain the :attr:`~telegram.Message.reply_to_message` field + even if it itself is a reply. + price (:obj:`SuggestedPostPrice`): + Expected price of the post. + + Attributes: + suggested_post_message (:class:`telegram.Message`): + Optional. Message containing the suggested post. Note that the + :class:`~telegram.Message` object in this field will not contain + the :attr:`~telegram.Message.reply_to_message` field even if it itself is a reply. + price (:obj:`SuggestedPostPrice`): + Expected price of the post. + + """ + + __slots__ = ("price", "suggested_post_message") + + def __init__( + self, + price: SuggestedPostPrice, + suggested_post_message: Message | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.price: SuggestedPostPrice = price + # Optionals + self.suggested_post_message: Message | None = suggested_post_message + + self._id_attrs = (self.price, self.suggested_post_message) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "SuggestedPostApprovalFailed": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["price"] = de_json_optional(data.get("price"), SuggestedPostPrice, bot) + data["suggested_post_message"] = de_json_optional( + data.get("suggested_post_message"), Message, bot + ) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_switchinlinequerychosenchat.py b/src/telegram/_switchinlinequerychosenchat.py similarity index 84% rename from telegram/_switchinlinequerychosenchat.py rename to src/telegram/_switchinlinequerychosenchat.py index 8b0a3c9ad0f..30e721321e3 100644 --- a/telegram/_switchinlinequerychosenchat.py +++ b/src/telegram/_switchinlinequerychosenchat.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,7 +16,6 @@ # # You should have received a copy of the GNU Lesser Public License """This module contains a class that represents a Telegram SwitchInlineQueryChosenChat.""" -from typing import Optional from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -64,30 +63,30 @@ class SwitchInlineQueryChosenChat(TelegramObject): """ __slots__ = ( - "query", - "allow_user_chats", "allow_bot_chats", - "allow_group_chats", "allow_channel_chats", + "allow_group_chats", + "allow_user_chats", + "query", ) def __init__( self, - query: Optional[str] = None, - allow_user_chats: Optional[bool] = None, - allow_bot_chats: Optional[bool] = None, - allow_group_chats: Optional[bool] = None, - allow_channel_chats: Optional[bool] = None, + query: str | None = None, + allow_user_chats: bool | None = None, + allow_bot_chats: bool | None = None, + allow_group_chats: bool | None = None, + allow_channel_chats: bool | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Optional - self.query: Optional[str] = query - self.allow_user_chats: Optional[bool] = allow_user_chats - self.allow_bot_chats: Optional[bool] = allow_bot_chats - self.allow_group_chats: Optional[bool] = allow_group_chats - self.allow_channel_chats: Optional[bool] = allow_channel_chats + self.query: str | None = query + self.allow_user_chats: bool | None = allow_user_chats + self.allow_bot_chats: bool | None = allow_bot_chats + self.allow_group_chats: bool | None = allow_group_chats + self.allow_channel_chats: bool | None = allow_channel_chats self._id_attrs = ( self.query, diff --git a/telegram/_telegramobject.py b/src/telegram/_telegramobject.py similarity index 75% rename from telegram/_telegramobject.py rename to src/telegram/_telegramobject.py index 96fd4f8e9b1..4098c75c37e 100644 --- a/telegram/_telegramobject.py +++ b/src/telegram/_telegramobject.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,31 +17,20 @@ # 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 Objects.""" -import datetime + +import contextlib +import datetime as dtm import inspect import json -from collections.abc import Sized +from collections.abc import Iterator, Mapping, Sized from contextlib import contextmanager from copy import deepcopy from itertools import chain from types import MappingProxyType -from typing import ( - TYPE_CHECKING, - Any, - Dict, - Iterator, - List, - Mapping, - Optional, - Set, - Tuple, - Type, - TypeVar, - Union, - cast, -) +from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast from telegram._utils.datetime import to_timestamp +from telegram._utils.defaultvalue import DefaultValue from telegram._utils.types import JSONDict from telegram._utils.warnings import warn @@ -49,6 +38,7 @@ from telegram import Bot Tele_co = TypeVar("Tele_co", bound="TelegramObject", covariant=True) +Tele = TypeVar("Tele", bound="TelegramObject") class TelegramObject: @@ -77,7 +67,7 @@ class TelegramObject: :obj:`list` are now of type :obj:`tuple`. Arguments: - api_kwargs (Dict[:obj:`str`, any], optional): |toapikwargsarg| + api_kwargs (dict[:obj:`str`, any], optional): |toapikwargsarg| .. versionadded:: 20.0 @@ -88,64 +78,74 @@ class TelegramObject: """ - __slots__ = ("_id_attrs", "_bot", "_frozen", "api_kwargs") + __slots__ = ("_bot", "_frozen", "_id_attrs", "api_kwargs") # Used to cache the names of the parameters of the __init__ method of the class # Must be a private attribute to avoid name clashes between subclasses - __INIT_PARAMS: Set[str] = set() + __INIT_PARAMS: ClassVar[set[str]] = set() # Used to check if __INIT_PARAMS has been set for the current class. Unfortunately, we can't # just check if `__INIT_PARAMS is None`, since subclasses use the parent class' __INIT_PARAMS # unless it's overridden - __INIT_PARAMS_CHECK: Optional[Type["TelegramObject"]] = None + __INIT_PARAMS_CHECK: type["TelegramObject"] | None = None - def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, *, api_kwargs: JSONDict | None = None) -> None: # Setting _frozen to `False` here means that classes without arguments still need to # implement __init__. However, with `True` would mean increased usage of # `with self._unfrozen()` in the `__init__` of subclasses and we have fewer empty # classes than classes with arguments. self._frozen: bool = False - self._id_attrs: Tuple[object, ...] = () - self._bot: Optional["Bot"] = None + self._id_attrs: tuple[object, ...] = () + self._bot: Bot | None = None # We don't do anything with api_kwargs here - see docstring of _apply_api_kwargs self.api_kwargs: Mapping[str, Any] = MappingProxyType(api_kwargs or {}) - def _freeze(self) -> None: - self._frozen = True + def __eq__(self, other: object) -> bool: + """Compares this object with :paramref:`other` in terms of equality. + If this object and :paramref:`other` are `not` objects of the same class, + this comparison will fall back to Python's default implementation of :meth:`object.__eq__`. + Otherwise, both objects may be compared in terms of equality, if the corresponding + subclass of :class:`TelegramObject` has defined a set of attributes to compare and + the objects are considered to be equal, if all of these attributes are equal. + If the subclass has not defined a set of attributes to compare, a warning will be issued. - def _unfreeze(self) -> None: - self._frozen = False + Tip: + If instances of a class in the :mod:`telegram` module are comparable in terms of + equality, the documentation of the class will state the attributes that will be used + for this comparison. - @contextmanager - def _unfrozen(self: Tele_co) -> Iterator[Tele_co]: - """Context manager to temporarily unfreeze the object. For internal use only. + Args: + other (:obj:`object`): The object to compare with. + + Returns: + :obj:`bool` - Note: - with to._unfrozen() as other_to: - assert to is other_to """ - self._unfreeze() - yield self - self._freeze() + if isinstance(other, self.__class__): + if not self._id_attrs: + warn( + f"Objects of type {self.__class__.__name__} can not be meaningfully tested for" + " equivalence.", + stacklevel=2, + ) + if not other._id_attrs: + warn( + f"Objects of type {other.__class__.__name__} can not be meaningfully tested" + " for equivalence.", + stacklevel=2, + ) + return self._id_attrs == other._id_attrs + return super().__eq__(other) - def _apply_api_kwargs(self, api_kwargs: JSONDict) -> None: - """Loops through the api kwargs and for every key that exists as attribute of the - object (and is None), it moves the value from `api_kwargs` to the attribute. - *Edits `api_kwargs` in place!* + def __hash__(self) -> int: + """Builds a hash value for this object such that the hash of two objects is equal if and + only if the objects are equal in terms of :meth:`__eq__`. - This method is currently only called in the unpickling process, i.e. not on "normal" init. - This is because - * automating this is tricky to get right: It should be called at the *end* of the __init__, - preferably only once at the end of the __init__ of the last child class. This could be - done via __init_subclass__, but it's hard to not destroy the signature of __init__ in the - process. - * calling it manually in every __init__ is tedious - * There probably is no use case for it anyway. If you manually initialize a TO subclass, - then you can pass everything as proper argument. + Returns: + :obj:`int` """ - # we convert to list to ensure that the list doesn't change length while we loop - for key in list(api_kwargs.keys()): - if getattr(self, key, True) is None: - setattr(self, key, api_kwargs.pop(key)) + if self._id_attrs: + return hash((self.__class__, self._id_attrs)) + return super().__hash__() def __setattr__(self, key: str, value: object) -> None: """Overrides :meth:`object.__setattr__` to prevent the overriding of attributes. @@ -208,8 +208,7 @@ def __repr__(self) -> str: if ( as_dict[k] is not None and not ( - isinstance(as_dict[k], Sized) - and len(as_dict[k]) == 0 # type: ignore[arg-type] + isinstance(as_dict[k], Sized) and len(as_dict[k]) == 0 # type: ignore[arg-type] ) ) ) @@ -250,7 +249,7 @@ def __getitem__(self, item: str) -> object: f"`{item}`." ) from exc - def __getstate__(self) -> Dict[str, Union[str, object]]: + def __getstate__(self) -> dict[str, str | object]: """ Overrides :meth:`object.__getstate__` to customize the pickling process of objects of this type. @@ -258,15 +257,17 @@ def __getstate__(self) -> Dict[str, Union[str, object]]: :meth:`set_bot` (if any), as it can't be pickled. Returns: - state (Dict[:obj:`str`, :obj:`object`]): The state of the object. + state (dict[:obj:`str`, :obj:`object`]): The state of the object. """ - out = self._get_attrs(include_private=True, recursive=False, remove_bot=True) + out = self._get_attrs( + include_private=True, recursive=False, remove_bot=True, convert_default_value=False + ) # MappingProxyType is not pickable, so we convert it to a dict and revert in # __setstate__ out["api_kwargs"] = dict(self.api_kwargs) return out - def __setstate__(self, state: Dict[str, object]) -> None: + def __setstate__(self, state: dict[str, object]) -> None: """ Overrides :meth:`object.__setstate__` to customize the unpickling process of objects of this type. Modifies the object in-place. @@ -290,7 +291,7 @@ def __setstate__(self, state: Dict[str, object]) -> None: self._bot = None # get api_kwargs first because we may need to add entries to it (see try-except below) - api_kwargs = cast(Dict[str, object], state.pop("api_kwargs", {})) + api_kwargs = cast("dict[str, object]", state.pop("api_kwargs", {})) # get _frozen before the loop to avoid setting it to True in the loop frozen = state.pop("_frozen", False) @@ -298,7 +299,20 @@ def __setstate__(self, state: Dict[str, object]) -> None: try: setattr(self, key, val) except AttributeError: - # catch cases when old attributes are removed from new versions + # So an attribute was deprecated and removed from the class. Let's handle this: + # 1) Is the attribute now a property with no setter? Let's check that: + if isinstance(getattr(self.__class__, key, None), property): + # It is, so let's try to set the "private attribute" instead + try: + setattr(self, f"_{key}", val) + # If this fails as well, guess we've completely removed it. Let's add it to + # api_kwargs as fallback + except AttributeError: + api_kwargs[key] = val + + # 2) The attribute is a private attribute, i.e. it went through case 1) in the past + elif key.startswith("_"): + continue # skip adding this to api_kwargs, the attribute is lost forever. api_kwargs[key] = val # add it to api_kwargs as fallback # For api_kwargs we first apply any kwargs that are already attributes of the object @@ -313,7 +327,7 @@ def __setstate__(self, state: Dict[str, object]) -> None: if frozen: self._freeze() - def __deepcopy__(self: Tele_co, memodict: Dict[int, object]) -> Tele_co: + def __deepcopy__(self: Tele_co, memodict: dict[int, object]) -> Tele_co: """ Customizes how :func:`copy.deepcopy` processes objects of this type. The only difference to the default implementation is that the :class:`telegram.Bot` @@ -326,7 +340,7 @@ def __deepcopy__(self: Tele_co, memodict: Dict[int, object]) -> Tele_co: memodict (:obj:`dict`): A dictionary that maps objects to their copies. Returns: - :obj:`telegram.TelegramObject`: The copied object. + :class:`telegram.TelegramObject`: The copied object. """ bot = self._bot # Save bot so we can set it after copying self.set_bot(None) # set to None so it is not deepcopied @@ -363,6 +377,135 @@ def __deepcopy__(self: Tele_co, memodict: Dict[int, object]) -> Tele_co: self.set_bot(bot) return result + @staticmethod + def _parse_data(data: JSONDict) -> JSONDict: + """Should be called by subclasses that override de_json to ensure that the input + is not altered. Whoever calls de_json might still want to use the original input + for something else. + """ + return data.copy() + + @classmethod + def _de_json( + cls: type[Tele_co], + data: JSONDict, + bot: "Bot | None", + api_kwargs: JSONDict | None = None, + ) -> Tele_co: + # try-except is significantly faster in case we already have a correct argument set + try: + obj = cls(**data, api_kwargs=api_kwargs) + except TypeError as exc: + if "__init__() got an unexpected keyword argument" not in str(exc): + raise + + if cls.__INIT_PARAMS_CHECK is not cls: + signature = inspect.signature(cls) + cls.__INIT_PARAMS = set(signature.parameters.keys()) + cls.__INIT_PARAMS_CHECK = cls + + api_kwargs = api_kwargs or {} + existing_kwargs: JSONDict = {} + for key, value in data.items(): + (existing_kwargs if key in cls.__INIT_PARAMS else api_kwargs)[key] = value + + obj = cls(api_kwargs=api_kwargs, **existing_kwargs) + + obj.set_bot(bot=bot) + return obj + + @classmethod + def de_json(cls: type[Tele_co], data: JSONDict, bot: "Bot | None" = None) -> Tele_co: + """Converts JSON data to a Telegram object. + + Args: + data (dict[:obj:`str`, ...]): The JSON data. + 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. + + """ + return cls._de_json(data=data, bot=bot) + + @classmethod + def de_list( + cls: type[Tele_co], data: list[JSONDict], bot: "Bot | None" = None + ) -> tuple[Tele_co, ...]: + """Converts a list of JSON objects to a tuple of Telegram objects. + + .. versionchanged:: 20.0 + + * Returns a tuple instead of a list. + * Filters out any :obj:`None` values. + + Args: + data (list[dict[:obj:`str`, ...]]): The JSON data. + 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. + + """ + return tuple(cls.de_json(d, bot) for d in data) + + @contextmanager + def _unfrozen(self: Tele) -> Iterator[Tele]: + """Context manager to temporarily unfreeze the object. For internal use only. + + Note: + with to._unfrozen() as other_to: + assert to is other_to + """ + self._unfreeze() + yield self + self._freeze() + + def _freeze(self) -> None: + self._frozen = True + + def _unfreeze(self) -> None: + self._frozen = False + + def _apply_api_kwargs(self, api_kwargs: JSONDict) -> None: + """Loops through the api kwargs and for every key that exists as attribute of the + object (and is None), it moves the value from `api_kwargs` to the attribute. + *Edits `api_kwargs` in place!* + + This method is currently only called in the unpickling process, i.e. not on "normal" init. + This is because + * automating this is tricky to get right: It should be called at the *end* of the __init__, + preferably only once at the end of the __init__ of the last child class. This could be + done via __init_subclass__, but it's hard to not destroy the signature of __init__ in the + process. + * calling it manually in every __init__ is tedious + * There probably is no use case for it anyway. If you manually initialize a TO subclass, + then you can pass everything as proper argument. + """ + # we convert to list to ensure that the list doesn't change length while we loop + for key in list(api_kwargs.keys()): + # property attributes are not settable, so we need to set the private attribute + if isinstance(getattr(self.__class__, key, None), property): + # if setattr fails, we'll just leave the value in api_kwargs: + with contextlib.suppress(AttributeError): + setattr(self, f"_{key}", api_kwargs.pop(key)) + elif getattr(self, key, True) is None: + setattr(self, key, api_kwargs.pop(key)) + + def _is_deprecated_attr(self, attr: str) -> bool: + """Checks whether `attr` is in the list of deprecated time period attributes.""" + return ( + class_name := self.__class__.__name__ + ) in _TIME_PERIOD_DEPRECATIONS and attr in _TIME_PERIOD_DEPRECATIONS[class_name] + def _get_attrs_names(self, include_private: bool) -> Iterator[str]: """ Returns the names of the attributes of this object. This is used to determine which @@ -385,14 +528,20 @@ def _get_attrs_names(self, include_private: bool) -> Iterator[str]: if include_private: return all_attrs - return (attr for attr in all_attrs if not attr.startswith("_")) + return ( + attr + for attr in all_attrs + # Include deprecated private attributes, which are exposed via properties + if not attr.startswith("_") or self._is_deprecated_attr(attr) + ) def _get_attrs( self, include_private: bool = False, recursive: bool = False, remove_bot: bool = False, - ) -> Dict[str, Union[str, object]]: + convert_default_value: bool = True, + ) -> dict[str, str | object]: """This method is used for obtaining the attributes of the object. Args: @@ -400,6 +549,10 @@ def _get_attrs( recursive (:obj:`bool`): If :obj:`True`, will convert any ``TelegramObjects`` (if found) in the attributes to a dictionary. Else, preserves it as an object itself. remove_bot (:obj:`bool`): Whether the bot should be included in the result. + convert_default_value (:obj:`bool`): Whether :class:`telegram.DefaultValue` should be + converted to its true value. This is necessary when converting to a dictionary for + end users since DefaultValue is used in some classes that work with + `tg.ext.defaults` (like `LinkPreviewOptions`) Returns: :obj:`dict`: A dict where the keys are attribute names and values are their values. @@ -407,7 +560,12 @@ def _get_attrs( data = {} for key in self._get_attrs_names(include_private=include_private): - value = getattr(self, key, None) + value = ( + DefaultValue.get_value(getattr(self, key, None)) + if convert_default_value + else getattr(self, key, None) + ) + if value is not None: if recursive and hasattr(value, "to_dict"): data[key] = value.to_dict(recursive=True) @@ -422,84 +580,6 @@ def _get_attrs( data.pop("_bot", None) return data - @staticmethod - def _parse_data(data: Optional[JSONDict]) -> Optional[JSONDict]: - """Should be called by subclasses that override de_json to ensure that the input - is not altered. Whoever calls de_json might still want to use the original input - for something else. - """ - return None if data is None else data.copy() - - @classmethod - def de_json(cls: Type[Tele_co], data: Optional[JSONDict], bot: "Bot") -> 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. - - Returns: - The Telegram object. - - """ - return cls._de_json(data=data, bot=bot) - - @classmethod - def _de_json( - cls: Type[Tele_co], - data: Optional[JSONDict], - bot: "Bot", - api_kwargs: Optional[JSONDict] = None, - ) -> Optional[Tele_co]: - if data is None: - return None - - # try-except is significantly faster in case we already have a correct argument set - try: - obj = cls(**data, api_kwargs=api_kwargs) - except TypeError as exc: - if "__init__() got an unexpected keyword argument" not in str(exc): - raise exc - - if cls.__INIT_PARAMS_CHECK is not cls: - signature = inspect.signature(cls) - cls.__INIT_PARAMS = set(signature.parameters.keys()) - cls.__INIT_PARAMS_CHECK = cls - - api_kwargs = api_kwargs or {} - existing_kwargs: JSONDict = {} - for key, value in data.items(): - (existing_kwargs if key in cls.__INIT_PARAMS else api_kwargs)[key] = value - - obj = cls(api_kwargs=api_kwargs, **existing_kwargs) - - obj.set_bot(bot=bot) - return obj - - @classmethod - def de_list( - cls: Type[Tele_co], data: Optional[List[JSONDict]], bot: "Bot" - ) -> Tuple[Tele_co, ...]: - """Converts a list of JSON objects to a tuple of Telegram objects. - - .. versionchanged:: 20.0 - - * Returns a tuple instead of a list. - * Filters out any :obj:`None` values. - - Args: - data (List[Dict[:obj:`str`, ...]]): The JSON data. - bot (:class:`telegram.Bot`): The bot associated with these objects. - - Returns: - A tuple of Telegram objects. - - """ - if not data: - return () - - return tuple(obj for obj in (cls.de_json(d, bot) for d in data) if obj is not None) - def to_json(self) -> str: """Gives a JSON representation of object. @@ -534,9 +614,10 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # Now we should convert TGObjects to dicts inside objects such as sequences, and convert # datetimes to timestamps. This mostly eliminates the need for subclasses to override # `to_dict` - pop_keys: Set[str] = set() + pop_keys: set[str] = set() + timedelta_dict: dict = {} for key, value in out.items(): - if isinstance(value, (tuple, list)): + if isinstance(value, tuple | list): if not value: # not popping directly to avoid changing the dict size during iteration pop_keys.add(key) @@ -546,8 +627,8 @@ def to_dict(self, recursive: bool = True) -> JSONDict: for item in value: if hasattr(item, "to_dict"): val.append(item.to_dict(recursive=recursive)) - # This branch is useful for e.g. Tuple[Tuple[PhotoSize|KeyboardButton]] - elif isinstance(item, (tuple, list)): + # This branch is useful for e.g. tuple[tuple[PhotoSize|KeyboardButton]] + elif isinstance(item, tuple | list): val.append( [ i.to_dict(recursive=recursive) if hasattr(i, "to_dict") else i @@ -558,12 +639,28 @@ def to_dict(self, recursive: bool = True) -> JSONDict: val.append(item) out[key] = val - elif isinstance(value, datetime.datetime): + elif isinstance(value, dtm.datetime): out[key] = to_timestamp(value) + elif isinstance(value, dtm.timedelta): + # Converting to int here is neccassry in some cases where Bot API returns + # 'BadRquest' when expecting integers (e.g. InputMediaVideo.duration). + # Other times, floats are accepted but the Bot API handles ints just as well + # (e.g. InputStoryContentVideo.duration). + # Not updating `out` directly to avoid changing the dict size during iteration + timedelta_dict[key.removeprefix("_")] = ( + int(seconds) if (seconds := value.total_seconds()).is_integer() else seconds + ) + # This will sometimes add non-deprecated timedelta attributes to pop_keys. + # We'll restore them shortly. + pop_keys.add(key) for key in pop_keys: out.pop(key) + # `out.update` must to be called *after* we pop deprecated time period attributes + # this ensures that we restore attributes that were already using datetime.timdelta + out.update(timedelta_dict) + # Effectively "unpack" api_kwargs into `out`: out.update(out.pop("api_kwargs", {})) # type: ignore[call-overload] return out @@ -584,7 +681,7 @@ def get_bot(self) -> "Bot": ) return self._bot - def set_bot(self, bot: Optional["Bot"]) -> None: + def set_bot(self, bot: "Bot | None") -> None: """Sets the :class:`telegram.Bot` instance associated with this object. .. seealso:: :meth:`get_bot` @@ -596,50 +693,30 @@ def set_bot(self, bot: Optional["Bot"]) -> None: """ self._bot = bot - def __eq__(self, other: object) -> bool: - """Compares this object with :paramref:`other` in terms of equality. - If this object and :paramref:`other` are `not` objects of the same class, - this comparison will fall back to Python's default implementation of :meth:`object.__eq__`. - Otherwise, both objects may be compared in terms of equality, if the corresponding - subclass of :class:`TelegramObject` has defined a set of attributes to compare and - the objects are considered to be equal, if all of these attributes are equal. - If the subclass has not defined a set of attributes to compare, a warning will be issued. - - Tip: - If instances of a class in the :mod:`telegram` module are comparable in terms of - equality, the documentation of the class will state the attributes that will be used - for this comparison. - - Args: - other (:obj:`object`): The object to compare with. - - Returns: - :obj:`bool` - - """ - if isinstance(other, self.__class__): - if not self._id_attrs: - warn( - f"Objects of type {self.__class__.__name__} can not be meaningfully tested for" - " equivalence.", - stacklevel=2, - ) - if not other._id_attrs: - warn( - f"Objects of type {other.__class__.__name__} can not be meaningfully tested" - " for equivalence.", - stacklevel=2, - ) - return self._id_attrs == other._id_attrs - return super().__eq__(other) - - def __hash__(self) -> int: - """Builds a hash value for this object such that the hash of two objects is equal if and - only if the objects are equal in terms of :meth:`__eq__`. - Returns: - :obj:`int` - """ - if self._id_attrs: - return hash((self.__class__, self._id_attrs)) - return super().__hash__() +# We use str keys to avoid importing which causes circular dependencies +_TIME_PERIOD_DEPRECATIONS: dict[str, tuple[str, ...]] = { + "ChatFullInfo": ("_message_auto_delete_time", "_slow_mode_delay"), + "Animation": ("_duration",), + "Audio": ("_duration",), + "Video": ("_duration", "_start_timestamp"), + "VideoNote": ("_duration",), + "Voice": ("_duration",), + "PaidMediaPreview": ("_duration",), + "VideoChatEnded": ("_duration",), + "InputMediaVideo": ("_duration",), + "InputMediaAnimation": ("_duration",), + "InputMediaAudio": ("_duration",), + "InputPaidMediaVideo": ("_duration",), + "InlineQueryResultGif": ("_gif_duration",), + "InlineQueryResultMpeg4Gif": ("_mpeg4_duration",), + "InlineQueryResultVideo": ("_video_duration",), + "InlineQueryResultAudio": ("_audio_duration",), + "InlineQueryResultVoice": ("_voice_duration",), + "InlineQueryResultLocation": ("_live_period",), + "Poll": ("_open_period",), + "Location": ("_live_period",), + "MessageAutoDeleteTimerChanged": ("_message_auto_delete_time",), + "ChatInviteLink": ("_subscription_period",), + "InputLocationMessageContent": ("_live_period",), +} diff --git a/src/telegram/_uniquegift.py b/src/telegram/_uniquegift.py new file mode 100644 index 00000000000..56fe64e37af --- /dev/null +++ b/src/telegram/_uniquegift.py @@ -0,0 +1,664 @@ +#!/usr/bin/env python +# +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 classes related to unique gifs.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING, Final + +from telegram import constants +from telegram._chat import Chat +from telegram._files.sticker import Sticker +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +from telegram._utils.argumentparsing import de_json_optional, parse_sequence_arg +from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp +from telegram._utils.types import JSONDict +from telegram._utils.warnings import warn +from telegram._utils.warnings_transition import ( + build_deprecation_warning_message, + warn_about_deprecated_attr_in_property, +) +from telegram.warnings import PTBDeprecationWarning + +if TYPE_CHECKING: + from telegram import Bot + + +class UniqueGiftColors(TelegramObject): + """This object contains information about the color scheme for a user's name, message replies + and link previews based on a unique gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`model_custom_emoji_id`, :attr:`symbol_custom_emoji_id`, + :attr:`light_theme_main_color`, :attr:`light_theme_other_colors`, + :attr:`dark_theme_main_color`, and :attr:`dark_theme_other_colors` are equal. + + .. versionadded:: 22.6 + + Args: + model_custom_emoji_id (:obj:`str`): Custom emoji identifier of the unique gift's model. + symbol_custom_emoji_id (:obj:`str`): Custom emoji identifier of the unique gift's symbol. + light_theme_main_color (:obj:`int`): Main color used in light themes; RGB format. + light_theme_other_colors (Sequence[:obj:`int`]): List of 1-3 additional colors used in + light themes; RGB format. |sequenceclassargs| + dark_theme_main_color (:obj:`int`): Main color used in dark themes; RGB format. + dark_theme_other_colors (Sequence[:obj:`int`]): List of 1-3 additional colors used in dark + themes; RGB format. |sequenceclassargs| + + Attributes: + model_custom_emoji_id (:obj:`str`): Custom emoji identifier of the unique gift's model. + symbol_custom_emoji_id (:obj:`str`): Custom emoji identifier of the unique gift's symbol. + light_theme_main_color (:obj:`int`): Main color used in light themes; RGB format. + light_theme_other_colors (Tuple[:obj:`int`]): Tuple of 1-3 additional colors used in + light themes; RGB format. + dark_theme_main_color (:obj:`int`): Main color used in dark themes; RGB format. + dark_theme_other_colors (Tuple[:obj:`int`]): Tuple of 1-3 additional colors used in dark + themes; RGB format. + """ + + __slots__ = ( + "dark_theme_main_color", + "dark_theme_other_colors", + "light_theme_main_color", + "light_theme_other_colors", + "model_custom_emoji_id", + "symbol_custom_emoji_id", + ) + + def __init__( + self, + model_custom_emoji_id: str, + symbol_custom_emoji_id: str, + light_theme_main_color: int, + light_theme_other_colors: Sequence[int], + dark_theme_main_color: int, + dark_theme_other_colors: Sequence[int], + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.model_custom_emoji_id: str = model_custom_emoji_id + self.symbol_custom_emoji_id: str = symbol_custom_emoji_id + self.light_theme_main_color: int = light_theme_main_color + self.light_theme_other_colors: tuple[int, ...] = parse_sequence_arg( + light_theme_other_colors + ) + self.dark_theme_main_color: int = dark_theme_main_color + self.dark_theme_other_colors: tuple[int, ...] = parse_sequence_arg(dark_theme_other_colors) + + self._id_attrs = ( + self.model_custom_emoji_id, + self.symbol_custom_emoji_id, + self.light_theme_main_color, + self.light_theme_other_colors, + self.dark_theme_main_color, + self.dark_theme_other_colors, + ) + + self._freeze() + + +class UniqueGiftModel(TelegramObject): + """This object describes the model of a unique gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`name`, :attr:`sticker` and :attr:`rarity_per_mille` are equal. + + .. versionadded:: 22.1 + + Args: + name (:obj:`str`): Name of the model. + sticker (:class:`telegram.Sticker`): The sticker that represents the unique gift. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this + model for every ``1000`` gifts upgraded. + + Attributes: + name (:obj:`str`): Name of the model. + sticker (:class:`telegram.Sticker`): The sticker that represents the unique gift. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this + model for every ``1000`` gifts upgraded. + + """ + + __slots__ = ( + "name", + "rarity_per_mille", + "sticker", + ) + + def __init__( + self, + name: str, + sticker: Sticker, + rarity_per_mille: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.name: str = name + self.sticker: Sticker = sticker + self.rarity_per_mille: int = rarity_per_mille + + self._id_attrs = (self.name, self.sticker, self.rarity_per_mille) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "UniqueGiftModel": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + + return super().de_json(data=data, bot=bot) + + +class UniqueGiftSymbol(TelegramObject): + """This object describes the symbol shown on the pattern of a unique gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`name`, :attr:`sticker` and :attr:`rarity_per_mille` are equal. + + .. versionadded:: 22.1 + + Args: + name (:obj:`str`): Name of the symbol. + sticker (:class:`telegram.Sticker`): The sticker that represents the unique gift. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this + model for every ``1000`` gifts upgraded. + + Attributes: + name (:obj:`str`): Name of the symbol. + sticker (:class:`telegram.Sticker`): The sticker that represents the unique gift. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this + model for every ``1000`` gifts upgraded. + + """ + + __slots__ = ( + "name", + "rarity_per_mille", + "sticker", + ) + + def __init__( + self, + name: str, + sticker: Sticker, + rarity_per_mille: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.name: str = name + self.sticker: Sticker = sticker + self.rarity_per_mille: int = rarity_per_mille + + self._id_attrs = (self.name, self.sticker, self.rarity_per_mille) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "UniqueGiftSymbol": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + + return super().de_json(data=data, bot=bot) + + +class UniqueGiftBackdropColors(TelegramObject): + """This object describes the colors of the backdrop of a unique gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`center_color`, :attr:`edge_color`, :attr:`symbol_color`, + and :attr:`text_color` are equal. + + .. versionadded:: 22.1 + + Args: + center_color (:obj:`int`): The color in the center of the backdrop in RGB format. + edge_color (:obj:`int`): The color on the edges of the backdrop in RGB format. + symbol_color (:obj:`int`): The color to be applied to the symbol in RGB format. + text_color (:obj:`int`): The color for the text on the backdrop in RGB format. + + Attributes: + center_color (:obj:`int`): The color in the center of the backdrop in RGB format. + edge_color (:obj:`int`): The color on the edges of the backdrop in RGB format. + symbol_color (:obj:`int`): The color to be applied to the symbol in RGB format. + text_color (:obj:`int`): The color for the text on the backdrop in RGB format. + + """ + + __slots__ = ( + "center_color", + "edge_color", + "symbol_color", + "text_color", + ) + + def __init__( + self, + center_color: int, + edge_color: int, + symbol_color: int, + text_color: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.center_color: int = center_color + self.edge_color: int = edge_color + self.symbol_color: int = symbol_color + self.text_color: int = text_color + + self._id_attrs = (self.center_color, self.edge_color, self.symbol_color, self.text_color) + + self._freeze() + + +class UniqueGiftBackdrop(TelegramObject): + """This object describes the backdrop of a unique gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`name`, :attr:`colors`, and :attr:`rarity_per_mille` are equal. + + .. versionadded:: 22.1 + + Args: + name (:obj:`str`): Name of the backdrop. + colors (:class:`telegram.UniqueGiftBackdropColors`): Colors of the backdrop. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this backdrop + for every ``1000`` gifts upgraded. + + Attributes: + name (:obj:`str`): Name of the backdrop. + colors (:class:`telegram.UniqueGiftBackdropColors`): Colors of the backdrop. + rarity_per_mille (:obj:`int`): The number of unique gifts that receive this backdrop + for every ``1000`` gifts upgraded. + + """ + + __slots__ = ( + "colors", + "name", + "rarity_per_mille", + ) + + def __init__( + self, + name: str, + colors: UniqueGiftBackdropColors, + rarity_per_mille: int, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.name: str = name + self.colors: UniqueGiftBackdropColors = colors + self.rarity_per_mille: int = rarity_per_mille + + self._id_attrs = (self.name, self.colors, self.rarity_per_mille) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "UniqueGiftBackdrop": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["colors"] = de_json_optional(data.get("colors"), UniqueGiftBackdropColors, bot) + + return super().de_json(data=data, bot=bot) + + +class UniqueGift(TelegramObject): + """This object describes a unique gift that was upgraded from a regular gift. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`base_name`, :attr:`name`, :attr:`number`, :class:`model`, + :attr:`symbol`, and :attr:`backdrop` are equal. + + .. versionadded:: 22.1 + + Args: + gift_id (:obj:`str`): Identifier of the regular gift from which the gift was upgraded. + + .. versionadded:: 22.6 + base_name (:obj:`str`): Human-readable name of the regular gift from which this unique + gift was upgraded. + name (:obj:`str`): Unique name of the gift. This name can be used + in ``https://t.me/nft/...`` links and story areas. + number (:obj:`int`): Unique number of the upgraded gift among gifts upgraded from the + same regular gift. + model (:class:`UniqueGiftModel`): Model of the gift. + symbol (:class:`UniqueGiftSymbol`): Symbol of the gift. + backdrop (:class:`UniqueGiftBackdrop`): Backdrop of the gift. + publisher_chat (:class:`telegram.Chat`, optional): Information about the chat that + published the gift. + + .. versionadded:: 22.4 + is_premium (:obj:`bool`, optional): :obj:`True`, if the original regular gift was + exclusively purchaseable by Telegram Premium subscribers. + + .. versionadded:: 22.6 + is_from_blockchain (:obj:`bool`, optional): :obj:`True`, if the gift is assigned from the + TON blockchain and can't be resold or transferred in Telegram. + + .. versionadded:: 22.6 + colors (:class:`telegram.UniqueGiftColors`, optional): The color scheme that can be used + by the gift's owner for the chat's name, replies to messages and link previews; for + business account gifts and gifts that are currently on sale only. + + .. versionadded:: 22.6 + + Attributes: + gift_id (:obj:`str`): Identifier of the regular gift from which the gift was upgraded. + + .. versionadded:: 22.6 + base_name (:obj:`str`): Human-readable name of the regular gift from which this unique + gift was upgraded. + name (:obj:`str`): Unique name of the gift. This name can be used + in ``https://t.me/nft/...`` links and story areas. + number (:obj:`int`): Unique number of the upgraded gift among gifts upgraded from the + same regular gift. + model (:class:`telegram.UniqueGiftModel`): Model of the gift. + symbol (:class:`telegram.UniqueGiftSymbol`): Symbol of the gift. + backdrop (:class:`telegram.UniqueGiftBackdrop`): Backdrop of the gift. + publisher_chat (:class:`telegram.Chat`): Optional. Information about the chat that + published the gift. + + .. versionadded:: 22.4 + is_premium (:obj:`bool`): Optional. :obj:`True`, if the original regular gift was + exclusively purchaseable by Telegram Premium subscribers. + + .. versionadded:: 22.6 + is_from_blockchain (:obj:`bool`): Optional. :obj:`True`, if the gift is assigned from the + TON blockchain and can't be resold or transferred in Telegram. + + .. versionadded:: 22.6 + colors (:class:`telegram.UniqueGiftColors`): Optional. The color scheme that can be used + by the gift's owner for the chat's name, replies to messages and link previews; for + business account gifts and gifts that are currently on sale only. + + .. versionadded:: 22.6 + + """ + + __slots__ = ( + "backdrop", + "base_name", + "colors", + "gift_id", + "is_from_blockchain", + "is_premium", + "model", + "name", + "number", + "publisher_chat", + "symbol", + ) + + def __init__( + self, + base_name: str, + name: str, + number: int, + model: UniqueGiftModel, + symbol: UniqueGiftSymbol, + backdrop: UniqueGiftBackdrop, + publisher_chat: Chat | None = None, + # tags: deprecated 22.6, bot api 9.3 + # temporarily optional to account for changed signature + gift_id: str | None = None, + is_from_blockchain: bool | None = None, + is_premium: bool | None = None, + colors: UniqueGiftColors | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + # tags: deprecated 22.6, bot api 9.3 + if gift_id is None: + raise TypeError("`gift_id` is a required argument since Bot API 9.3") + + super().__init__(api_kwargs=api_kwargs) + self.gift_id: str = gift_id + self.base_name: str = base_name + self.name: str = name + self.number: int = number + self.model: UniqueGiftModel = model + self.symbol: UniqueGiftSymbol = symbol + self.backdrop: UniqueGiftBackdrop = backdrop + self.publisher_chat: Chat | None = publisher_chat + self.is_from_blockchain: bool | None = is_from_blockchain + self.is_premium: bool | None = is_premium + self.colors: UniqueGiftColors | None = colors + + self._id_attrs = ( + self.base_name, + self.name, + self.number, + self.model, + self.symbol, + self.backdrop, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "UniqueGift": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["model"] = de_json_optional(data.get("model"), UniqueGiftModel, bot) + data["symbol"] = de_json_optional(data.get("symbol"), UniqueGiftSymbol, bot) + data["backdrop"] = de_json_optional(data.get("backdrop"), UniqueGiftBackdrop, bot) + data["publisher_chat"] = de_json_optional(data.get("publisher_chat"), Chat, bot) + data["colors"] = de_json_optional(data.get("colors"), UniqueGiftColors, bot) + + return super().de_json(data=data, bot=bot) + + +class UniqueGiftInfo(TelegramObject): + """Describes a service message about a unique gift that was sent or received. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal if their :attr:`gift`, and :attr:`origin` are equal. + + .. versionadded:: 22.1 + + Args: + gift (:class:`UniqueGift`): Information about the gift. + origin (:obj:`str`): Origin of the gift. Currently, either :attr:`UPGRADE` for gifts + upgraded from regular gifts, :attr:`TRANSFER` for gifts transferred from other users + or channels, :attr:`RESALE` for gifts bought from other users, + :attr:`GIFTED_UPGRADE` for upgrades purchased after the gift was sent, or :attr:`OFFER` + for gifts bought or sold through gift purchase offers + + .. versionchanged:: 22.3 + The :attr:`RESALE` origin was added. + .. versionchanged:: 22.6 + Bot API 9.3 added the :attr:`GIFTED_UPGRADE` and :attr:`OFFER` origins. + owned_gift_id (:obj:`str`, optional) Unique identifier of the received gift for the + bot; only present for gifts received on behalf of business accounts. + transfer_star_count (:obj:`int`, optional): Number of Telegram Stars that must be paid + to transfer the gift; omitted if the bot cannot transfer the gift. + last_resale_star_count (:obj:`int`, optional): For gifts bought from other users, the price + paid for the gift. + + .. versionadded:: 22.3 + .. deprecated:: 22.6 + Bot API 9.3 deprecated this field. Use :attr:`last_resale_currency` and + :attr:`last_resale_amount` instead. + last_resale_currency (:obj:`str`, optional): For gifts bought from other users, the + currency in which the payment for the gift was done. Currently, one of ``XTR`` for + Telegram Stars or ``TON`` for toncoins. + + .. versionadded:: 22.6 + last_resale_amount (:obj:`int`, optional): For gifts bought from other users, the price + paid for the gift in either Telegram Stars or nanotoncoins. + + .. versionadded:: 22.6 + next_transfer_date (:obj:`datetime.datetime`, optional): Date when the gift can be + transferred. If it's in the past, then the gift can be transferred now. + |datetime_localization| + + .. versionadded:: 22.3 + + Attributes: + gift (:class:`UniqueGift`): Information about the gift. + origin (:obj:`str`): Origin of the gift. Currently, either :attr:`UPGRADE` for gifts + upgraded from regular gifts, :attr:`TRANSFER` for gifts transferred from other users + or channels, :attr:`RESALE` for gifts bought from other users, + :attr:`GIFTED_UPGRADE` for upgrades purchased after the gift was sent, or :attr:`OFFER` + for gifts bought or sold through gift purchase offers + + .. versionchanged:: 22.3 + The :attr:`RESALE` origin was added. + .. versionchanged:: 22.6 + Bot API 9.3 added the :attr:`GIFTED_UPGRADE` and :attr:`OFFER` origins. + owned_gift_id (:obj:`str`) Optional. Unique identifier of the received gift for the + bot; only present for gifts received on behalf of business accounts. + transfer_star_count (:obj:`int`): Optional. Number of Telegram Stars that must be paid + to transfer the gift; omitted if the bot cannot transfer the gift. + last_resale_currency (:obj:`str`): Optional. For gifts bought from other users, the + currency in which the payment for the gift was done. Currently, one of ``XTR`` for + Telegram Stars or ``TON`` for toncoins. + + .. versionadded:: 22.6 + last_resale_amount (:obj:`int`): Optional. For gifts bought from other users, the price + paid for the gift in either Telegram Stars or nanotoncoins. + + .. versionadded:: 22.6 + next_transfer_date (:obj:`datetime.datetime`): Optional. Date when the gift can be + transferred. If it's in the past, then the gift can be transferred now. + |datetime_localization| + + .. versionadded:: 22.3 + """ + + GIFTED_UPGRADE: Final[str] = constants.UniqueGiftInfoOrigin.GIFTED_UPGRADE + """:const:`telegram.constants.UniqueGiftInfoOrigin.GIFTED_UPGRADE` + + .. versionadded:: 22.6 + """ + OFFER: Final[str] = constants.UniqueGiftInfoOrigin.OFFER + """:const:`telegram.constants.UniqueGiftInfoOrigin.OFFER` + + .. versionadded:: 22.6 + """ + RESALE: Final[str] = constants.UniqueGiftInfoOrigin.RESALE + """:const:`telegram.constants.UniqueGiftInfoOrigin.RESALE` + + .. versionadded:: 22.3 + """ + TRANSFER: Final[str] = constants.UniqueGiftInfoOrigin.TRANSFER + """:const:`telegram.constants.UniqueGiftInfoOrigin.TRANSFER`""" + UPGRADE: Final[str] = constants.UniqueGiftInfoOrigin.UPGRADE + """:const:`telegram.constants.UniqueGiftInfoOrigin.UPGRADE`""" + + __slots__ = ( + "_last_resale_star_count", + "gift", + "last_resale_amount", + "last_resale_currency", + "next_transfer_date", + "origin", + "owned_gift_id", + "transfer_star_count", + ) + + def __init__( + self, + gift: UniqueGift, + origin: str, + owned_gift_id: str | None = None, + transfer_star_count: int | None = None, + # tags: deprecated 22.6; bot api 9.3 + last_resale_star_count: int | None = None, + next_transfer_date: dtm.datetime | None = None, + last_resale_currency: str | None = None, + last_resale_amount: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + if last_resale_star_count is not None: + warn( + PTBDeprecationWarning( + version="22.6", + message=build_deprecation_warning_message( + deprecated_name="last_resale_star_count", + new_name="last_resale_currency/amount", + bot_api_version="9.3", + object_type="parameter", + ), + ), + stacklevel=2, + ) + + super().__init__(api_kwargs=api_kwargs) + # Required + self.gift: UniqueGift = gift + self.origin: str = enum.get_member(constants.UniqueGiftInfoOrigin, origin, origin) + # Optional + self.owned_gift_id: str | None = owned_gift_id + self.transfer_star_count: int | None = transfer_star_count + self._last_resale_star_count: int | None = last_resale_star_count + self.next_transfer_date: dtm.datetime | None = next_transfer_date + self.last_resale_currency: str | None = last_resale_currency + self.last_resale_amount: int | None = last_resale_amount + + self._id_attrs = (self.gift, self.origin) + + self._freeze() + + # tags: deprecated 22.6; bot api 9.3 + @property + def last_resale_star_count(self) -> int | None: + """:obj:`int`: Optional. For gifts bought from other users, the price + paid for the gift. + + .. versionadded:: 22.3 + .. deprecated:: 22.6 + Bot API 9.3 deprecated this field. Use :attr:`last_resale_currency` and + :attr:`last_resale_amount` instead. + """ + warn_about_deprecated_attr_in_property( + deprecated_attr_name="last_resale_star_count", + new_attr_name="last_resale_currency/amount", + bot_api_version="9.3", + ptb_version="22.6", + ) + return self._last_resale_star_count + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "UniqueGiftInfo": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["gift"] = de_json_optional(data.get("gift"), UniqueGift, bot) + data["next_transfer_date"] = from_timestamp( + data.get("next_transfer_date"), tzinfo=loc_tzinfo + ) + + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_update.py b/src/telegram/_update.py new file mode 100644 index 00000000000..9e018e4cb0f --- /dev/null +++ b/src/telegram/_update.py @@ -0,0 +1,811 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 Update.""" + +from typing import TYPE_CHECKING, Final + +from telegram import constants +from telegram._business import BusinessConnection, BusinessMessagesDeleted +from telegram._callbackquery import CallbackQuery +from telegram._chatboost import ChatBoostRemoved, ChatBoostUpdated +from telegram._chatjoinrequest import ChatJoinRequest +from telegram._chatmemberupdated import ChatMemberUpdated +from telegram._choseninlineresult import ChosenInlineResult +from telegram._inline.inlinequery import InlineQuery +from telegram._message import Message +from telegram._messagereactionupdated import MessageReactionCountUpdated, MessageReactionUpdated +from telegram._paidmedia import PaidMediaPurchased +from telegram._payment.precheckoutquery import PreCheckoutQuery +from telegram._payment.shippingquery import ShippingQuery +from telegram._poll import Poll, PollAnswer +from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import de_json_optional +from telegram._utils.types import JSONDict +from telegram._utils.warnings import warn + +if TYPE_CHECKING: + from telegram import Bot, Chat, User + + +class Update(TelegramObject): + """This object represents an incoming update. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`update_id` is equal. + + Note: + At most one of the optional parameters can be present in any given update. + + .. seealso:: :wiki:`Your First Bot ` + + Args: + update_id (:obj:`int`): The update's unique identifier. Update identifiers start from a + certain positive number and increase sequentially. This ID becomes especially handy if + you're using Webhooks, since it allows you to ignore repeated updates or to restore the + correct update sequence, should they get out of order. If there are no new updates for + at least a week, then identifier of the next update will be chosen randomly instead of + sequentially. + message (:class:`telegram.Message`, optional): New incoming message of any kind - text, + photo, sticker, etc. + edited_message (:class:`telegram.Message`, optional): New version of a message that is + known to the bot and was edited. This update may at times be triggered by changes to + message fields that are either unavailable or not actively used by your bot. + channel_post (:class:`telegram.Message`, optional): New incoming channel post of any kind + - text, photo, sticker, etc. + edited_channel_post (:class:`telegram.Message`, optional): New version of a channel post + that is known to the bot and was edited. This update may at times be triggered by + changes to message fields that are either unavailable or not actively used by your bot. + inline_query (:class:`telegram.InlineQuery`, optional): New incoming inline query. + chosen_inline_result (:class:`telegram.ChosenInlineResult`, optional): The result of an + inline query that was chosen by a user and sent to their chat partner. + callback_query (:class:`telegram.CallbackQuery`, optional): New incoming callback query. + shipping_query (:class:`telegram.ShippingQuery`, optional): New incoming shipping query. + Only for invoices with flexible price. + pre_checkout_query (:class:`telegram.PreCheckoutQuery`, optional): New incoming + pre-checkout query. Contains full information about checkout. + poll (:class:`telegram.Poll`, optional): New poll state. Bots receive only updates about + manually stopped polls and polls, which are sent by the bot. + poll_answer (:class:`telegram.PollAnswer`, optional): A user changed their answer + in a non-anonymous poll. Bots receive new votes only in polls that were sent + by the bot itself. + my_chat_member (:class:`telegram.ChatMemberUpdated`, optional): The bot's chat member + status was updated in a chat. For private chats, this update is received only when the + bot is blocked or unblocked by the user. + + .. versionadded:: 13.4 + chat_member (:class:`telegram.ChatMemberUpdated`, optional): A chat member's status was + updated in a chat. The bot must be an administrator in the chat and must explicitly + specify :attr:`CHAT_MEMBER` in the list of + :paramref:`telegram.ext.Application.run_polling.allowed_updates` to receive these + updates (see :meth:`telegram.Bot.get_updates`, :meth:`telegram.Bot.set_webhook`, + :meth:`telegram.ext.Application.run_polling` and + :meth:`telegram.ext.Application.run_webhook`). + + .. versionadded:: 13.4 + chat_join_request (:class:`telegram.ChatJoinRequest`, optional): A request to join the + chat has been sent. The bot must have the + :attr:`telegram.ChatPermissions.can_invite_users` administrator right in the chat to + receive these updates. + + .. versionadded:: 13.8 + + chat_boost (:class:`telegram.ChatBoostUpdated`, optional): A chat boost was added or + changed. The bot must be an administrator in the chat to receive these updates. + + .. versionadded:: 20.8 + + removed_chat_boost (:class:`telegram.ChatBoostRemoved`, optional): A boost was removed from + a chat. The bot must be an administrator in the chat to receive these updates. + + .. versionadded:: 20.8 + + message_reaction (:class:`telegram.MessageReactionUpdated`, optional): A reaction to a + message was changed by a user. The bot must be an administrator in the chat and must + explicitly specify :attr:`MESSAGE_REACTION` in the list of + :paramref:`telegram.ext.Application.run_polling.allowed_updates` to receive these + updates (see :meth:`telegram.Bot.get_updates`, :meth:`telegram.Bot.set_webhook`, + :meth:`telegram.ext.Application.run_polling` and + :meth:`telegram.ext.Application.run_webhook`). The update isn't received for reactions + set by bots. + + .. versionadded:: 20.8 + + message_reaction_count (:class:`telegram.MessageReactionCountUpdated`, optional): Reactions + to a message with anonymous reactions were changed. The bot must be an administrator in + the chat and must explicitly specify :attr:`MESSAGE_REACTION_COUNT` in the list of + :paramref:`telegram.ext.Application.run_polling.allowed_updates` to receive these + updates (see :meth:`telegram.Bot.get_updates`, :meth:`telegram.Bot.set_webhook`, + :meth:`telegram.ext.Application.run_polling` and + :meth:`telegram.ext.Application.run_webhook`). The updates are grouped and can be sent + with delay up to a few minutes. + + .. versionadded:: 20.8 + + business_connection (:class:`telegram.BusinessConnection`, optional): The bot was connected + to or disconnected from a business account, or a user edited an existing connection + with the bot. + + .. versionadded:: 21.1 + + business_message (:class:`telegram.Message`, optional): New message from a connected + business account. + + .. versionadded:: 21.1 + + edited_business_message (:class:`telegram.Message`, optional): New version of a message + from a connected business account. + + .. versionadded:: 21.1 + + deleted_business_messages (:class:`telegram.BusinessMessagesDeleted`, optional): Messages + were deleted from a connected business account. + + .. versionadded:: 21.1 + + purchased_paid_media (:class:`telegram.PaidMediaPurchased`, optional): A user purchased + paid media with a non-empty payload sent by the bot in a non-channel chat. + + .. versionadded:: 21.6 + + + Attributes: + update_id (:obj:`int`): The update's unique identifier. Update identifiers start from a + certain positive number and increase sequentially. This ID becomes especially handy if + you're using Webhooks, since it allows you to ignore repeated updates or to restore the + correct update sequence, should they get out of order. If there are no new updates for + at least a week, then identifier of the next update will be chosen randomly instead of + sequentially. + message (:class:`telegram.Message`): Optional. New incoming message of any kind - text, + photo, sticker, etc. + edited_message (:class:`telegram.Message`): Optional. New version of a message that is + known to the bot and was edited. This update may at times be triggered by changes to + message fields that are either unavailable or not actively used by your bot. + channel_post (:class:`telegram.Message`): Optional. New incoming channel post of any kind + - text, photo, sticker, etc. + edited_channel_post (:class:`telegram.Message`): Optional. New version of a channel post + that is known to the bot and was edited. This update may at times be triggered by + changes to message fields that are either unavailable or not actively used by your bot. + inline_query (:class:`telegram.InlineQuery`): Optional. New incoming inline query. + chosen_inline_result (:class:`telegram.ChosenInlineResult`): Optional. The result of an + inline query that was chosen by a user and sent to their chat partner. + callback_query (:class:`telegram.CallbackQuery`): Optional. New incoming callback query. + + Examples: + :any:`Arbitrary Callback Data Bot ` + shipping_query (:class:`telegram.ShippingQuery`): Optional. New incoming shipping query. + Only for invoices with flexible price. + pre_checkout_query (:class:`telegram.PreCheckoutQuery`): Optional. New incoming + pre-checkout query. Contains full information about checkout. + poll (:class:`telegram.Poll`): Optional. New poll state. Bots receive only updates about + manually stopped polls and polls, which are sent by the bot. + poll_answer (:class:`telegram.PollAnswer`): Optional. A user changed their answer + in a non-anonymous poll. Bots receive new votes only in polls that were sent + by the bot itself. + my_chat_member (:class:`telegram.ChatMemberUpdated`): Optional. The bot's chat member + status was updated in a chat. For private chats, this update is received only when the + bot is blocked or unblocked by the user. + + .. versionadded:: 13.4 + chat_member (:class:`telegram.ChatMemberUpdated`): Optional. A chat member's status was + updated in a chat. The bot must be an administrator in the chat and must explicitly + specify :attr:`CHAT_MEMBER` in the list of + :paramref:`telegram.ext.Application.run_polling.allowed_updates` to receive these + updates (see :meth:`telegram.Bot.get_updates`, :meth:`telegram.Bot.set_webhook`, + :meth:`telegram.ext.Application.run_polling` and + :meth:`telegram.ext.Application.run_webhook`). + + .. versionadded:: 13.4 + chat_join_request (:class:`telegram.ChatJoinRequest`): Optional. A request to join the + chat has been sent. The bot must have the + :attr:`telegram.ChatPermissions.can_invite_users` administrator right in the chat to + receive these updates. + + .. versionadded:: 13.8 + + chat_boost (:class:`telegram.ChatBoostUpdated`): Optional. A chat boost was added or + changed. The bot must be an administrator in the chat to receive these updates. + + .. versionadded:: 20.8 + + removed_chat_boost (:class:`telegram.ChatBoostRemoved`): Optional. A boost was removed from + a chat. The bot must be an administrator in the chat to receive these updates. + + .. versionadded:: 20.8 + + message_reaction (:class:`telegram.MessageReactionUpdated`): Optional. A reaction to a + message was changed by a user. The bot must be an administrator in the chat and must + explicitly specify :attr:`MESSAGE_REACTION` in the list of + :paramref:`telegram.ext.Application.run_polling.allowed_updates` to receive these + updates (see :meth:`telegram.Bot.get_updates`, :meth:`telegram.Bot.set_webhook`, + :meth:`telegram.ext.Application.run_polling` and + :meth:`telegram.ext.Application.run_webhook`). The update isn't received for reactions + set by bots. + + .. versionadded:: 20.8 + + message_reaction_count (:class:`telegram.MessageReactionCountUpdated`): Optional. Reactions + to a message with anonymous reactions were changed. The bot must be an administrator in + the chat and must explicitly specify :attr:`MESSAGE_REACTION_COUNT` in the list of + :paramref:`telegram.ext.Application.run_polling.allowed_updates` to receive these + updates (see :meth:`telegram.Bot.get_updates`, :meth:`telegram.Bot.set_webhook`, + :meth:`telegram.ext.Application.run_polling` and + :meth:`telegram.ext.Application.run_webhook`). The updates are grouped and can be sent + with delay up to a few minutes. + + .. versionadded:: 20.8 + + business_connection (:class:`telegram.BusinessConnection`): Optional. The bot was connected + to or disconnected from a business account, or a user edited an existing connection + with the bot. + + .. versionadded:: 21.1 + + business_message (:class:`telegram.Message`): Optional. New message from a connected + business account. + + .. versionadded:: 21.1 + + edited_business_message (:class:`telegram.Message`): Optional. New version of a message + from a connected business account. + + .. versionadded:: 21.1 + + deleted_business_messages (:class:`telegram.BusinessMessagesDeleted`): Optional. Messages + were deleted from a connected business account. + + .. versionadded:: 21.1 + + purchased_paid_media (:class:`telegram.PaidMediaPurchased`): Optional. A user purchased + paid media with a non-empty payload sent by the bot in a non-channel chat. + + .. versionadded:: 21.6 + """ + + __slots__ = ( + "_effective_chat", + "_effective_message", + "_effective_sender", + "_effective_user", + "business_connection", + "business_message", + "callback_query", + "channel_post", + "chat_boost", + "chat_join_request", + "chat_member", + "chosen_inline_result", + "deleted_business_messages", + "edited_business_message", + "edited_channel_post", + "edited_message", + "inline_query", + "message", + "message_reaction", + "message_reaction_count", + "my_chat_member", + "poll", + "poll_answer", + "pre_checkout_query", + "purchased_paid_media", + "removed_chat_boost", + "shipping_query", + "update_id", + ) + + MESSAGE: Final[str] = constants.UpdateType.MESSAGE + """:const:`telegram.constants.UpdateType.MESSAGE` + + .. versionadded:: 13.5""" + EDITED_MESSAGE: Final[str] = constants.UpdateType.EDITED_MESSAGE + """:const:`telegram.constants.UpdateType.EDITED_MESSAGE` + + .. versionadded:: 13.5""" + CHANNEL_POST: Final[str] = constants.UpdateType.CHANNEL_POST + """:const:`telegram.constants.UpdateType.CHANNEL_POST` + + .. versionadded:: 13.5""" + EDITED_CHANNEL_POST: Final[str] = constants.UpdateType.EDITED_CHANNEL_POST + """:const:`telegram.constants.UpdateType.EDITED_CHANNEL_POST` + + .. versionadded:: 13.5""" + INLINE_QUERY: Final[str] = constants.UpdateType.INLINE_QUERY + """:const:`telegram.constants.UpdateType.INLINE_QUERY` + + .. versionadded:: 13.5""" + CHOSEN_INLINE_RESULT: Final[str] = constants.UpdateType.CHOSEN_INLINE_RESULT + """:const:`telegram.constants.UpdateType.CHOSEN_INLINE_RESULT` + + .. versionadded:: 13.5""" + CALLBACK_QUERY: Final[str] = constants.UpdateType.CALLBACK_QUERY + """:const:`telegram.constants.UpdateType.CALLBACK_QUERY` + + .. versionadded:: 13.5""" + SHIPPING_QUERY: Final[str] = constants.UpdateType.SHIPPING_QUERY + """:const:`telegram.constants.UpdateType.SHIPPING_QUERY` + + .. versionadded:: 13.5""" + PRE_CHECKOUT_QUERY: Final[str] = constants.UpdateType.PRE_CHECKOUT_QUERY + """:const:`telegram.constants.UpdateType.PRE_CHECKOUT_QUERY` + + .. versionadded:: 13.5""" + POLL: Final[str] = constants.UpdateType.POLL + """:const:`telegram.constants.UpdateType.POLL` + + .. versionadded:: 13.5""" + POLL_ANSWER: Final[str] = constants.UpdateType.POLL_ANSWER + """:const:`telegram.constants.UpdateType.POLL_ANSWER` + + .. versionadded:: 13.5""" + MY_CHAT_MEMBER: Final[str] = constants.UpdateType.MY_CHAT_MEMBER + """:const:`telegram.constants.UpdateType.MY_CHAT_MEMBER` + + .. versionadded:: 13.5""" + CHAT_MEMBER: Final[str] = constants.UpdateType.CHAT_MEMBER + """:const:`telegram.constants.UpdateType.CHAT_MEMBER` + + .. versionadded:: 13.5""" + CHAT_JOIN_REQUEST: Final[str] = constants.UpdateType.CHAT_JOIN_REQUEST + """:const:`telegram.constants.UpdateType.CHAT_JOIN_REQUEST` + + .. versionadded:: 13.8""" + CHAT_BOOST: Final[str] = constants.UpdateType.CHAT_BOOST + """:const:`telegram.constants.UpdateType.CHAT_BOOST` + + .. versionadded:: 20.8""" + REMOVED_CHAT_BOOST: Final[str] = constants.UpdateType.REMOVED_CHAT_BOOST + """:const:`telegram.constants.UpdateType.REMOVED_CHAT_BOOST` + + .. versionadded:: 20.8""" + MESSAGE_REACTION: Final[str] = constants.UpdateType.MESSAGE_REACTION + """:const:`telegram.constants.UpdateType.MESSAGE_REACTION` + + .. versionadded:: 20.8""" + MESSAGE_REACTION_COUNT: Final[str] = constants.UpdateType.MESSAGE_REACTION_COUNT + """:const:`telegram.constants.UpdateType.MESSAGE_REACTION_COUNT` + + .. versionadded:: 20.8""" + BUSINESS_CONNECTION: Final[str] = constants.UpdateType.BUSINESS_CONNECTION + """:const:`telegram.constants.UpdateType.BUSINESS_CONNECTION` + + .. versionadded:: 21.1""" + BUSINESS_MESSAGE: Final[str] = constants.UpdateType.BUSINESS_MESSAGE + """:const:`telegram.constants.UpdateType.BUSINESS_MESSAGE` + + .. versionadded:: 21.1""" + EDITED_BUSINESS_MESSAGE: Final[str] = constants.UpdateType.EDITED_BUSINESS_MESSAGE + """:const:`telegram.constants.UpdateType.EDITED_BUSINESS_MESSAGE` + + .. versionadded:: 21.1""" + DELETED_BUSINESS_MESSAGES: Final[str] = constants.UpdateType.DELETED_BUSINESS_MESSAGES + """:const:`telegram.constants.UpdateType.DELETED_BUSINESS_MESSAGES` + + .. versionadded:: 21.1""" + + PURCHASED_PAID_MEDIA: Final[str] = constants.UpdateType.PURCHASED_PAID_MEDIA + """:const:`telegram.constants.UpdateType.PURCHASED_PAID_MEDIA` + + .. versionadded:: 21.6 + """ + + ALL_TYPES: Final[list[str]] = list(constants.UpdateType) + """list[:obj:`str`]: A list of all available update types. + + .. versionadded:: 13.5""" + + def __init__( + self, + update_id: int, + message: Message | None = None, + edited_message: Message | None = None, + channel_post: Message | None = None, + edited_channel_post: Message | None = None, + inline_query: InlineQuery | None = None, + chosen_inline_result: ChosenInlineResult | None = None, + callback_query: CallbackQuery | None = None, + shipping_query: ShippingQuery | None = None, + pre_checkout_query: PreCheckoutQuery | None = None, + poll: Poll | None = None, + poll_answer: PollAnswer | None = None, + my_chat_member: ChatMemberUpdated | None = None, + chat_member: ChatMemberUpdated | None = None, + chat_join_request: ChatJoinRequest | None = None, + chat_boost: ChatBoostUpdated | None = None, + removed_chat_boost: ChatBoostRemoved | None = None, + message_reaction: MessageReactionUpdated | None = None, + message_reaction_count: MessageReactionCountUpdated | None = None, + business_connection: BusinessConnection | None = None, + business_message: Message | None = None, + edited_business_message: Message | None = None, + deleted_business_messages: BusinessMessagesDeleted | None = None, + purchased_paid_media: PaidMediaPurchased | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.update_id: int = update_id + # Optionals + self.message: Message | None = message + self.edited_message: Message | None = edited_message + self.inline_query: InlineQuery | None = inline_query + self.chosen_inline_result: ChosenInlineResult | None = chosen_inline_result + self.callback_query: CallbackQuery | None = callback_query + self.shipping_query: ShippingQuery | None = shipping_query + self.pre_checkout_query: PreCheckoutQuery | None = pre_checkout_query + self.channel_post: Message | None = channel_post + self.edited_channel_post: Message | None = edited_channel_post + self.poll: Poll | None = poll + self.poll_answer: PollAnswer | None = poll_answer + self.my_chat_member: ChatMemberUpdated | None = my_chat_member + self.chat_member: ChatMemberUpdated | None = chat_member + self.chat_join_request: ChatJoinRequest | None = chat_join_request + self.chat_boost: ChatBoostUpdated | None = chat_boost + self.removed_chat_boost: ChatBoostRemoved | None = removed_chat_boost + self.message_reaction: MessageReactionUpdated | None = message_reaction + self.message_reaction_count: MessageReactionCountUpdated | None = message_reaction_count + self.business_connection: BusinessConnection | None = business_connection + self.business_message: Message | None = business_message + self.edited_business_message: Message | None = edited_business_message + self.deleted_business_messages: BusinessMessagesDeleted | None = deleted_business_messages + self.purchased_paid_media: PaidMediaPurchased | None = purchased_paid_media + + self._effective_user: User | None = None + self._effective_sender: User | Chat | None = None + self._effective_chat: Chat | None = None + self._effective_message: Message | None = None + + self._id_attrs = (self.update_id,) + + self._freeze() + + @property + def effective_user(self) -> "User | None": + """ + :class:`telegram.User`: The user that sent this update, no matter what kind of update this + is. If no user is associated with this update, this gives :obj:`None`. This is the case + if any of + + * :attr:`channel_post` + * :attr:`edited_channel_post` + * :attr:`poll` + * :attr:`chat_boost` + * :attr:`removed_chat_boost` + * :attr:`message_reaction_count` + * :attr:`deleted_business_messages` + + is present. + + .. versionchanged:: 21.1 + This property now also considers :attr:`business_connection`, :attr:`business_message` + and :attr:`edited_business_message`. + + .. versionchanged:: 21.6 + This property now also considers :attr:`purchased_paid_media`. + + Example: + * If :attr:`message` is present, this will give + :attr:`telegram.Message.from_user`. + * If :attr:`poll_answer` is present, this will give :attr:`telegram.PollAnswer.user`. + + """ + if self._effective_user: + return self._effective_user + + user = None + + if self.message: + user = self.message.from_user + + elif self.edited_message: + user = self.edited_message.from_user + + elif self.inline_query: + user = self.inline_query.from_user + + elif self.chosen_inline_result: + user = self.chosen_inline_result.from_user + + elif self.callback_query: + user = self.callback_query.from_user + + elif self.shipping_query: + user = self.shipping_query.from_user + + elif self.pre_checkout_query: + user = self.pre_checkout_query.from_user + + elif self.poll_answer: + user = self.poll_answer.user + + elif self.my_chat_member: + user = self.my_chat_member.from_user + + elif self.chat_member: + user = self.chat_member.from_user + + elif self.chat_join_request: + user = self.chat_join_request.from_user + + elif self.message_reaction: + user = self.message_reaction.user + + elif self.business_message: + user = self.business_message.from_user + + elif self.edited_business_message: + user = self.edited_business_message.from_user + + elif self.business_connection: + user = self.business_connection.user + + elif self.purchased_paid_media: + user = self.purchased_paid_media.from_user + + self._effective_user = user + return user + + @property + def effective_sender(self) -> "User | Chat | None": + """ + :class:`telegram.User` or :class:`telegram.Chat`: The user or chat that sent this update, + no matter what kind of update this is. + + Note: + * Depending on the type of update and the user's 'Remain anonymous' setting, this + could either be :class:`telegram.User`, :class:`telegram.Chat` or :obj:`None`. + + If no user whatsoever is associated with this update, this gives :obj:`None`. This + is the case if any of + + * :attr:`poll` + * :attr:`chat_boost` + * :attr:`removed_chat_boost` + * :attr:`message_reaction_count` + * :attr:`deleted_business_messages` + + is present. + + Example: + * If :attr:`message` is present, this will give either + :attr:`telegram.Message.from_user` or :attr:`telegram.Message.sender_chat`. + * If :attr:`poll_answer` is present, this will give either + :attr:`telegram.PollAnswer.user` or :attr:`telegram.PollAnswer.voter_chat`. + * If :attr:`channel_post` is present, this will give + :attr:`telegram.Message.sender_chat`. + + .. versionadded:: 21.1 + """ + if self._effective_sender: + return self._effective_sender + + sender: User | Chat | None = None + + if message := ( + self.message + or self.edited_message + or self.channel_post + or self.edited_channel_post + or self.business_message + or self.edited_business_message + ): + sender = message.sender_chat + + elif self.poll_answer: + sender = self.poll_answer.voter_chat + + elif self.message_reaction: + sender = self.message_reaction.actor_chat + + if sender is None: + sender = self.effective_user + + self._effective_sender = sender + return sender + + @property + def effective_chat(self) -> "Chat | None": + """ + :class:`telegram.Chat`: The chat that this update was sent in, no matter what kind of + update this is. + If no chat is associated with this update, this gives :obj:`None`. + This is the case, if :attr:`inline_query`, + :attr:`chosen_inline_result`, :attr:`callback_query` from inline messages, + :attr:`shipping_query`, :attr:`pre_checkout_query`, :attr:`poll`, + :attr:`poll_answer`, :attr:`business_connection`, or :attr:`purchased_paid_media` + is present. + + .. versionchanged:: 21.1 + This property now also considers :attr:`business_message`, + :attr:`edited_business_message`, and :attr:`deleted_business_messages`. + + Example: + If :attr:`message` is present, this will give :attr:`telegram.Message.chat`. + + """ + if self._effective_chat: + return self._effective_chat + + chat = None + + if self.message: + chat = self.message.chat + + elif self.edited_message: + chat = self.edited_message.chat + + elif self.callback_query and self.callback_query.message: + chat = self.callback_query.message.chat + + elif self.channel_post: + chat = self.channel_post.chat + + elif self.edited_channel_post: + chat = self.edited_channel_post.chat + + elif self.my_chat_member: + chat = self.my_chat_member.chat + + elif self.chat_member: + chat = self.chat_member.chat + + elif self.chat_join_request: + chat = self.chat_join_request.chat + + elif self.chat_boost: + chat = self.chat_boost.chat + + elif self.removed_chat_boost: + chat = self.removed_chat_boost.chat + + elif self.message_reaction: + chat = self.message_reaction.chat + + elif self.message_reaction_count: + chat = self.message_reaction_count.chat + + elif self.business_message: + chat = self.business_message.chat + + elif self.edited_business_message: + chat = self.edited_business_message.chat + + elif self.deleted_business_messages: + chat = self.deleted_business_messages.chat + + self._effective_chat = chat + return chat + + @property + def effective_message(self) -> Message | None: + """ + :class:`telegram.Message`: The message included in this update, no matter what kind of + update this is. More precisely, this will be the message contained in :attr:`message`, + :attr:`edited_message`, :attr:`channel_post`, :attr:`edited_channel_post` or + :attr:`callback_query` (i.e. :attr:`telegram.CallbackQuery.message`) or :obj:`None`, if + none of those are present. + + .. versionchanged:: 21.1 + This property now also considers :attr:`business_message`, and + :attr:`edited_business_message`. + + Tip: + This property will only ever return objects of type :class:`telegram.Message` or + :obj:`None`, never :class:`telegram.MaybeInaccessibleMessage` or + :class:`telegram.InaccessibleMessage`. + Currently, this is only relevant for :attr:`callback_query`, as + :attr:`telegram.CallbackQuery.message` is the only attribute considered by this + property that can be an object of these types. + """ + if self._effective_message: + return self._effective_message + + message: Message | None = None + + if self.message: + message = self.message + + elif self.edited_message: + message = self.edited_message + + elif self.callback_query: + if ( + isinstance(cbq_message := self.callback_query.message, Message) + or cbq_message is None + ): + message = cbq_message + else: + warn( + ( + "`update.callback_query` is not `None`, but of type " + f"`{cbq_message.__class__.__name__}`. This is not considered by " + "`Update.effective_message`. Please manually access this attribute " + "if necessary." + ), + stacklevel=2, + ) + + elif self.channel_post: + message = self.channel_post + + elif self.edited_channel_post: + message = self.edited_channel_post + + elif self.business_message: + message = self.business_message + + elif self.edited_business_message: + message = self.edited_business_message + + self._effective_message = message + return message + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Update": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["message"] = de_json_optional(data.get("message"), Message, bot) + data["edited_message"] = de_json_optional(data.get("edited_message"), Message, bot) + data["inline_query"] = de_json_optional(data.get("inline_query"), InlineQuery, bot) + data["chosen_inline_result"] = de_json_optional( + data.get("chosen_inline_result"), ChosenInlineResult, bot + ) + data["callback_query"] = de_json_optional(data.get("callback_query"), CallbackQuery, bot) + data["shipping_query"] = de_json_optional(data.get("shipping_query"), ShippingQuery, bot) + data["pre_checkout_query"] = de_json_optional( + data.get("pre_checkout_query"), PreCheckoutQuery, bot + ) + data["channel_post"] = de_json_optional(data.get("channel_post"), Message, bot) + data["edited_channel_post"] = de_json_optional( + data.get("edited_channel_post"), Message, bot + ) + data["poll"] = de_json_optional(data.get("poll"), Poll, bot) + data["poll_answer"] = de_json_optional(data.get("poll_answer"), PollAnswer, bot) + data["my_chat_member"] = de_json_optional( + data.get("my_chat_member"), ChatMemberUpdated, bot + ) + data["chat_member"] = de_json_optional(data.get("chat_member"), ChatMemberUpdated, bot) + data["chat_join_request"] = de_json_optional( + data.get("chat_join_request"), ChatJoinRequest, bot + ) + data["chat_boost"] = de_json_optional(data.get("chat_boost"), ChatBoostUpdated, bot) + data["removed_chat_boost"] = de_json_optional( + data.get("removed_chat_boost"), ChatBoostRemoved, bot + ) + data["message_reaction"] = de_json_optional( + data.get("message_reaction"), MessageReactionUpdated, bot + ) + data["message_reaction_count"] = de_json_optional( + data.get("message_reaction_count"), MessageReactionCountUpdated, bot + ) + data["business_connection"] = de_json_optional( + data.get("business_connection"), BusinessConnection, bot + ) + data["business_message"] = de_json_optional(data.get("business_message"), Message, bot) + data["edited_business_message"] = de_json_optional( + data.get("edited_business_message"), Message, bot + ) + data["deleted_business_messages"] = de_json_optional( + data.get("deleted_business_messages"), BusinessMessagesDeleted, bot + ) + data["purchased_paid_media"] = de_json_optional( + data.get("purchased_paid_media"), PaidMediaPurchased, bot + ) + + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_user.py b/src/telegram/_user.py new file mode 100644 index 00000000000..284f6d17d2b --- /dev/null +++ b/src/telegram/_user.py @@ -0,0 +1,2630 @@ +#!/usr/bin/env python +# pylint: disable=redefined-builtin +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 User.""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from telegram._inline.inlinekeyboardbutton import InlineKeyboardButton +from telegram._menubutton import MenuButton +from telegram._telegramobject import TelegramObject +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.types import ( + CorrectOptionID, + JSONDict, + ODVInput, + TimePeriod, +) +from telegram._utils.usernames import get_full_name, get_link, get_name +from telegram.helpers import mention_html as helpers_mention_html +from telegram.helpers import mention_markdown as helpers_mention_markdown + +if TYPE_CHECKING: + from telegram import ( + Animation, + Audio, + Contact, + Document, + Gift, + InlineKeyboardMarkup, + InputMediaAudio, + InputMediaDocument, + InputMediaPhoto, + InputMediaVideo, + InputPollOption, + LabeledPrice, + LinkPreviewOptions, + Location, + Message, + MessageEntity, + MessageId, + OwnedGifts, + PhotoSize, + ReplyParameters, + Sticker, + Story, + SuggestedPostParameters, + UserChatBoosts, + UserProfilePhotos, + Venue, + Video, + VideoNote, + Voice, + ) + from telegram._utils.types import FileInput, ReplyMarkup + + +class User(TelegramObject): + """This object represents a Telegram user or bot. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`id` is equal. + + .. versionchanged:: 20.0 + The following are now keyword-only arguments in Bot methods: + ``location``, ``filename``, ``venue``, ``contact``, + ``{read, write, connect, pool}_timeout`` ``api_kwargs``. Use a named argument for those, + and notice that some positional arguments changed position as a result. + + Args: + id (:obj:`int`): Unique identifier for this user or bot. + is_bot (:obj:`bool`): :obj:`True`, if this user is a bot. + first_name (:obj:`str`): User's or bot's first name. + last_name (:obj:`str`, optional): User's or bot's last name. + username (:obj:`str`, optional): User's or bot's username. + language_code (:obj:`str`, optional): IETF language tag of the user's language. + can_join_groups (:obj:`str`, optional): :obj:`True`, if the bot can be invited to groups. + Returned only in :meth:`telegram.Bot.get_me`. + can_read_all_group_messages (:obj:`str`, optional): :obj:`True`, if privacy mode is + disabled for the bot. Returned only in :meth:`telegram.Bot.get_me`. + supports_inline_queries (:obj:`str`, optional): :obj:`True`, if the bot supports inline + queries. Returned only in :meth:`telegram.Bot.get_me`. + + is_premium (:obj:`bool`, optional): :obj:`True`, if this user is a Telegram Premium user. + + .. versionadded:: 20.0 + added_to_attachment_menu (:obj:`bool`, optional): :obj:`True`, if this user added + the bot to the attachment menu. + + .. versionadded:: 20.0 + can_connect_to_business (:obj:`bool`, optional): :obj:`True`, if the bot can be connected + to a Telegram Business account to receive its messages. Returned only in + :meth:`telegram.Bot.get_me`. + + .. versionadded:: 21.1 + has_main_web_app (:obj:`bool`, optional): :obj:`True`, if the bot has the main Web App. + Returned only in :meth:`telegram.Bot.get_me`. + + .. versionadded:: 21.5 + has_topics_enabled (:obj:`bool`, optional): :obj:`True`, if the bot has forum topic mode + enabled in private chats. Returned only in :meth:`telegram.Bot.get_me`. + + .. versionadded:: 22.6 + + Attributes: + id (:obj:`int`): Unique identifier for this user or bot. + is_bot (:obj:`bool`): :obj:`True`, if this user is a bot. + first_name (:obj:`str`): User's or bot's first name. + last_name (:obj:`str`): Optional. User's or bot's last name. + username (:obj:`str`): Optional. User's or bot's username. + language_code (:obj:`str`): Optional. IETF language tag of the user's language. + can_join_groups (:obj:`str`): Optional. :obj:`True`, if the bot can be invited to groups. + Returned only in :attr:`telegram.Bot.get_me` requests. + can_read_all_group_messages (:obj:`str`): Optional. :obj:`True`, if privacy mode is + disabled for the bot. Returned only in :attr:`telegram.Bot.get_me` requests. + supports_inline_queries (:obj:`str`): Optional. :obj:`True`, if the bot supports inline + queries. Returned only in :attr:`telegram.Bot.get_me` requests. + is_premium (:obj:`bool`): Optional. :obj:`True`, if this user is a Telegram + Premium user. + + .. versionadded:: 20.0 + added_to_attachment_menu (:obj:`bool`): Optional. :obj:`True`, if this user added + the bot to the attachment menu. + + .. versionadded:: 20.0 + can_connect_to_business (:obj:`bool`): Optional. :obj:`True`, if the bot can be connected + to a Telegram Business account to receive its messages. Returned only in + :meth:`telegram.Bot.get_me`. + + .. versionadded:: 21.1 + has_main_web_app (:obj:`bool`) Optional. :obj:`True`, if the bot has the main Web App. + Returned only in :meth:`telegram.Bot.get_me`. + + .. versionadded:: 21.5 + has_topics_enabled (:obj:`bool`): Optional. :obj:`True`, if the bot has forum topic mode + enabled in private chats. Returned only in :meth:`telegram.Bot.get_me`. + + .. versionadded:: 22.6 + + .. |user_chat_id_note| replace:: This shortcuts build on the assumption that :attr:`User.id` + coincides with the :attr:`Chat.id` of the private chat with the user. This has been the + case so far, but Telegram does not guarantee that this stays this way. + """ + + __slots__ = ( + "added_to_attachment_menu", + "can_connect_to_business", + "can_join_groups", + "can_read_all_group_messages", + "first_name", + "has_main_web_app", + "has_topics_enabled", + "id", + "is_bot", + "is_premium", + "language_code", + "last_name", + "supports_inline_queries", + "username", + ) + + def __init__( + self, + id: int, + first_name: str, + is_bot: bool, + last_name: str | None = None, + username: str | None = None, + language_code: str | None = None, + can_join_groups: bool | None = None, + can_read_all_group_messages: bool | None = None, + supports_inline_queries: bool | None = None, + is_premium: bool | None = None, + added_to_attachment_menu: bool | None = None, + can_connect_to_business: bool | None = None, + has_main_web_app: bool | None = None, + has_topics_enabled: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.id: int = id + self.first_name: str = first_name + self.is_bot: bool = is_bot + # Optionals + self.last_name: str | None = last_name + self.username: str | None = username + self.language_code: str | None = language_code + self.can_join_groups: bool | None = can_join_groups + self.can_read_all_group_messages: bool | None = can_read_all_group_messages + self.supports_inline_queries: bool | None = supports_inline_queries + self.is_premium: bool | None = is_premium + self.added_to_attachment_menu: bool | None = added_to_attachment_menu + self.can_connect_to_business: bool | None = can_connect_to_business + self.has_main_web_app: bool | None = has_main_web_app + self.has_topics_enabled: bool | None = has_topics_enabled + + self._id_attrs = (self.id,) + + self._freeze() + + @property + def name(self) -> str: + """:obj:`str`: Convenience property. If available, returns the user's :attr:`username` + prefixed with "@". If :attr:`username` is not available, returns :attr:`full_name`. + """ + return get_name(self) + + @property + def full_name(self) -> str: + """:obj:`str`: Convenience property. The user's :attr:`first_name`, followed by (if + available) :attr:`last_name`. + """ + return get_full_name(self) + + @property + def link(self) -> str | None: + """:obj:`str`: Convenience property. If :attr:`username` is available, returns a t.me link + of the user. + """ + return get_link(self) + + async def get_profile_photos( + self, + offset: int | None = None, + limit: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "UserProfilePhotos": + """Shortcut for:: + + await bot.get_user_profile_photos(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.get_user_profile_photos`. + + Returns: + :class:`telegram.UserProfilePhotos` + + """ + return await self.get_bot().get_user_profile_photos( + user_id=self.id, + offset=offset, + limit=limit, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + def mention_markdown(self, name: str | None = None) -> str: + """ + Note: + :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by + Telegram for backward compatibility. You should use :meth:`mention_markdown_v2` + instead. + + Args: + name (:obj:`str`): The name used as a link for the user. Defaults to :attr:`full_name`. + + Returns: + :obj:`str`: The inline mention for the user as markdown (version 1). + + """ + if name: + return helpers_mention_markdown(self.id, name) + return helpers_mention_markdown(self.id, self.full_name) + + def mention_markdown_v2(self, name: str | None = None) -> str: + """ + Args: + name (:obj:`str`): The name used as a link for the user. Defaults to :attr:`full_name`. + + Returns: + :obj:`str`: The inline mention for the user as markdown (version 2). + + """ + if name: + return helpers_mention_markdown(self.id, name, version=2) + return helpers_mention_markdown(self.id, self.full_name, version=2) + + def mention_html(self, name: str | None = None) -> str: + """ + Args: + name (:obj:`str`): The name used as a link for the user. Defaults to :attr:`full_name`. + + Returns: + :obj:`str`: The inline mention for the user as HTML. + + """ + if name: + return helpers_mention_html(self.id, name) + return helpers_mention_html(self.id, self.full_name) + + def mention_button(self, name: str | None = None) -> InlineKeyboardButton: + """Shortcut for:: + + InlineKeyboardButton(text=name, url=f"tg://user?id={update.effective_user.id}") + + .. versionadded:: 13.9 + + Args: + name (:obj:`str`): The name used as a link for the user. Defaults to :attr:`full_name`. + + Returns: + :class:`telegram.InlineKeyboardButton`: InlineButton with url set to the user mention + """ + return InlineKeyboardButton(text=name or self.full_name, url=f"tg://user?id={self.id}") + + async def pin_message( + self, + message_id: int, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + business_connection_id: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.pin_chat_message(chat_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.pin_chat_message`. + + Note: + |user_chat_id_note| + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().pin_chat_message( + chat_id=self.id, + message_id=message_id, + disable_notification=disable_notification, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + business_connection_id=business_connection_id, + api_kwargs=api_kwargs, + ) + + async def unpin_message( + self, + message_id: int | None = None, + business_connection_id: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.unpin_chat_message(chat_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.unpin_chat_message`. + + Note: + |user_chat_id_note| + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().unpin_chat_message( + chat_id=self.id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + message_id=message_id, + business_connection_id=business_connection_id, + ) + + async def unpin_all_messages( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.unpin_all_chat_messages(chat_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.unpin_all_chat_messages`. + + Note: + |user_chat_id_note| + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().unpin_all_chat_messages( + chat_id=self.id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def send_message( + self, + text: str, + parse_mode: ODVInput[str] = DEFAULT_NONE, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + disable_web_page_preview: bool | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_message(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_message`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_message( + chat_id=self.id, + text=text, + parse_mode=parse_mode, + disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + allow_sending_without_reply=allow_sending_without_reply, + entities=entities, + protect_content=protect_content, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_message_draft( + self, + draft_id: int, + text: str, + message_thread_id: int | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + entities: Sequence["MessageEntity"] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.send_message_draft(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_message_draft`. + + Note: + |user_chat_id_note| + + .. versionadded:: 22.6 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().send_message_draft( + chat_id=self.id, + draft_id=draft_id, + text=text, + message_thread_id=message_thread_id, + parse_mode=parse_mode, + entities=entities, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_message( + self, + message_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.delete_message(update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.delete_message`. + + .. versionadded:: 20.8 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().delete_message( + chat_id=self.id, + message_id=message_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_messages( + self, + message_ids: Sequence[int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.delete_messages(update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.delete_messages`. + + .. versionadded:: 20.8 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().delete_messages( + chat_id=self.id, + message_ids=message_ids, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def send_photo( + self, + photo: "FileInput | PhotoSize", + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_photo(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_photo`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_photo( + chat_id=self.id, + photo=photo, + caption=caption, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + parse_mode=parse_mode, + allow_sending_without_reply=allow_sending_without_reply, + caption_entities=caption_entities, + filename=filename, + protect_content=protect_content, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + has_spoiler=has_spoiler, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_media_group( + self, + media: Sequence[ + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + ], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + ) -> tuple["Message", ...]: + """Shortcut for:: + + await bot.send_media_group(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_media_group`. + + Note: + |user_chat_id_note| + + Returns: + tuple[:class:`telegram.Message`:] On success, a tuple of :class:`~telegram.Message` + instances that were sent is returned. + + """ + return await self.get_bot().send_media_group( + chat_id=self.id, + media=media, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + allow_sending_without_reply=allow_sending_without_reply, + protect_content=protect_content, + message_thread_id=message_thread_id, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + ) + + async def send_audio( + self, + audio: "FileInput | Audio", + duration: TimePeriod | None = None, + performer: str | None = None, + title: str | None = None, + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_audio(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_audio`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_audio( + chat_id=self.id, + audio=audio, + duration=duration, + performer=performer, + title=title, + caption=caption, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + parse_mode=parse_mode, + allow_sending_without_reply=allow_sending_without_reply, + caption_entities=caption_entities, + filename=filename, + protect_content=protect_content, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + thumbnail=thumbnail, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_chat_action( + self, + action: str, + message_thread_id: int | None = None, + business_connection_id: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.send_chat_action(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_chat_action`. + + Note: + |user_chat_id_note| + + Returns: + :obj:`True`: On success. + + """ + return await self.get_bot().send_chat_action( + chat_id=self.id, + action=action, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + ) + + send_action = send_chat_action + """Alias for :attr:`send_chat_action`""" + + async def send_contact( + self, + phone_number: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + vcard: str | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + contact: "Contact | None" = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_contact(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_contact`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_contact( + chat_id=self.id, + phone_number=phone_number, + first_name=first_name, + last_name=last_name, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + contact=contact, + vcard=vcard, + api_kwargs=api_kwargs, + allow_sending_without_reply=allow_sending_without_reply, + protect_content=protect_content, + message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_dice( + self, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + emoji: str | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_dice(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_dice`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_dice( + chat_id=self.id, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + emoji=emoji, + api_kwargs=api_kwargs, + allow_sending_without_reply=allow_sending_without_reply, + protect_content=protect_content, + message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_document( + self, + document: "FileInput | Document", + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + disable_content_type_detection: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_document(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_document`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_document( + chat_id=self.id, + document=document, + filename=filename, + caption=caption, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + parse_mode=parse_mode, + thumbnail=thumbnail, + api_kwargs=api_kwargs, + disable_content_type_detection=disable_content_type_detection, + allow_sending_without_reply=allow_sending_without_reply, + caption_entities=caption_entities, + protect_content=protect_content, + message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_game( + self, + game_short_name: str, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "InlineKeyboardMarkup | None" = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_game(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_game`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_game( + chat_id=self.id, + game_short_name=game_short_name, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + allow_sending_without_reply=allow_sending_without_reply, + protect_content=protect_content, + message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + ) + + async def send_invoice( + self, + title: str, + description: str, + payload: str, + currency: str, + prices: Sequence["LabeledPrice"], + provider_token: str | None = None, + start_parameter: str | None = None, + photo_url: str | None = None, + photo_size: int | None = None, + photo_width: int | None = None, + photo_height: int | None = None, + need_name: bool | None = None, + need_phone_number: bool | None = None, + need_email: bool | None = None, + need_shipping_address: bool | None = None, + is_flexible: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "InlineKeyboardMarkup | None" = None, + provider_data: str | object | None = None, + send_phone_number_to_provider: bool | None = None, + send_email_to_provider: bool | None = None, + max_tip_amount: int | None = None, + suggested_tip_amounts: Sequence[int] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_invoice(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_invoice`. + + Warning: + As of API 5.2 :paramref:`start_parameter ` + is an optional argument and therefore the + order of the arguments had to be changed. Use keyword arguments to make sure that the + arguments are passed correctly. + + Note: + |user_chat_id_note| + + .. versionchanged:: 13.5 + As of Bot API 5.2, the parameter + :paramref:`start_parameter ` is optional. + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_invoice( + chat_id=self.id, + title=title, + description=description, + payload=payload, + provider_token=provider_token, + currency=currency, + prices=prices, + start_parameter=start_parameter, + photo_url=photo_url, + photo_size=photo_size, + photo_width=photo_width, + photo_height=photo_height, + need_name=need_name, + need_phone_number=need_phone_number, + need_email=need_email, + need_shipping_address=need_shipping_address, + is_flexible=is_flexible, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + provider_data=provider_data, + send_phone_number_to_provider=send_phone_number_to_provider, + send_email_to_provider=send_email_to_provider, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + allow_sending_without_reply=allow_sending_without_reply, + max_tip_amount=max_tip_amount, + suggested_tip_amounts=suggested_tip_amounts, + protect_content=protect_content, + message_thread_id=message_thread_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_location( + self, + latitude: float | None = None, + longitude: float | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + live_period: TimePeriod | None = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + location: "Location | None" = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_location(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_location`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_location( + chat_id=self.id, + latitude=latitude, + longitude=longitude, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + location=location, + live_period=live_period, + api_kwargs=api_kwargs, + horizontal_accuracy=horizontal_accuracy, + heading=heading, + proximity_alert_radius=proximity_alert_radius, + allow_sending_without_reply=allow_sending_without_reply, + protect_content=protect_content, + message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_animation( + self, + animation: "FileInput | Animation", + duration: TimePeriod | None = None, + width: int | None = None, + height: int | None = None, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + caption_entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_animation(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_animation`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_animation( + chat_id=self.id, + animation=animation, + duration=duration, + width=width, + height=height, + caption=caption, + parse_mode=parse_mode, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + allow_sending_without_reply=allow_sending_without_reply, + caption_entities=caption_entities, + filename=filename, + protect_content=protect_content, + message_thread_id=message_thread_id, + has_spoiler=has_spoiler, + thumbnail=thumbnail, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_sticker( + self, + sticker: "FileInput | Sticker", + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + emoji: str | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_sticker(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_sticker`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_sticker( + chat_id=self.id, + sticker=sticker, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + allow_sending_without_reply=allow_sending_without_reply, + protect_content=protect_content, + message_thread_id=message_thread_id, + emoji=emoji, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_video( + self, + video: "FileInput | Video", + duration: TimePeriod | None = None, + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + width: int | None = None, + height: int | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + supports_streaming: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + cover: "FileInput | None" = None, + start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_video(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_video`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_video( + chat_id=self.id, + video=video, + duration=duration, + caption=caption, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + width=width, + height=height, + parse_mode=parse_mode, + supports_streaming=supports_streaming, + thumbnail=thumbnail, + cover=cover, + start_timestamp=start_timestamp, + api_kwargs=api_kwargs, + allow_sending_without_reply=allow_sending_without_reply, + caption_entities=caption_entities, + filename=filename, + protect_content=protect_content, + message_thread_id=message_thread_id, + has_spoiler=has_spoiler, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_venue( + self, + latitude: float | None = None, + longitude: float | None = None, + title: str | None = None, + address: str | None = None, + foursquare_id: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + venue: "Venue | None" = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_venue(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_venue`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_venue( + chat_id=self.id, + latitude=latitude, + longitude=longitude, + title=title, + address=address, + foursquare_id=foursquare_id, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + venue=venue, + foursquare_type=foursquare_type, + api_kwargs=api_kwargs, + google_place_id=google_place_id, + google_place_type=google_place_type, + allow_sending_without_reply=allow_sending_without_reply, + protect_content=protect_content, + message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_video_note( + self, + video_note: "FileInput | VideoNote", + duration: TimePeriod | None = None, + length: int | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_video_note(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_video_note`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_video_note( + chat_id=self.id, + video_note=video_note, + duration=duration, + length=length, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + allow_sending_without_reply=allow_sending_without_reply, + filename=filename, + protect_content=protect_content, + message_thread_id=message_thread_id, + thumbnail=thumbnail, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_voice( + self, + voice: "FileInput | Voice", + duration: TimePeriod | None = None, + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_voice(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_voice`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_voice( + chat_id=self.id, + voice=voice, + duration=duration, + caption=caption, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + parse_mode=parse_mode, + api_kwargs=api_kwargs, + allow_sending_without_reply=allow_sending_without_reply, + caption_entities=caption_entities, + filename=filename, + protect_content=protect_content, + message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_poll( + self, + question: str, + options: Sequence["str | InputPollOption"], + is_anonymous: bool | None = None, + type: str | None = None, + allows_multiple_answers: bool | None = None, + correct_option_id: CorrectOptionID | None = None, + is_closed: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + explanation: str | None = None, + explanation_parse_mode: ODVInput[str] = DEFAULT_NONE, + open_period: TimePeriod | None = None, + close_date: int | dtm.datetime | None = None, + explanation_entities: Sequence["MessageEntity"] | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + question_parse_mode: ODVInput[str] = DEFAULT_NONE, + question_entities: Sequence["MessageEntity"] | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_poll(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_poll`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_poll( + chat_id=self.id, + question=question, + options=options, + is_anonymous=is_anonymous, + type=type, # pylint=pylint, + allows_multiple_answers=allows_multiple_answers, + correct_option_id=correct_option_id, + is_closed=is_closed, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + explanation=explanation, + explanation_parse_mode=explanation_parse_mode, + open_period=open_period, + close_date=close_date, + api_kwargs=api_kwargs, + allow_sending_without_reply=allow_sending_without_reply, + explanation_entities=explanation_entities, + protect_content=protect_content, + message_thread_id=message_thread_id, + business_connection_id=business_connection_id, + question_parse_mode=question_parse_mode, + question_entities=question_entities, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + ) + + async def send_gift( + self, + gift_id: "str | Gift", + text: str | None = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Sequence["MessageEntity"] | None = None, + pay_for_upgrade: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.send_gift(user_id=update.effective_user.id, *args, **kwargs ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_gift`. + + .. versionadded:: 21.8 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().send_gift( + chat_id=None, + user_id=self.id, + gift_id=gift_id, + text=text, + text_parse_mode=text_parse_mode, + text_entities=text_entities, + pay_for_upgrade=pay_for_upgrade, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def gift_premium_subscription( + self, + month_count: int, + star_count: int, + text: str | None = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Sequence["MessageEntity"] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.gift_premium_subscription(user_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.gift_premium_subscription`. + + .. versionadded:: 22.1 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().gift_premium_subscription( + user_id=self.id, + month_count=month_count, + star_count=star_count, + text=text, + text_parse_mode=text_parse_mode, + text_entities=text_entities, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def send_copy( + self, + from_chat_id: str | int, + message_id: int, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "MessageId": + """Shortcut for:: + + await bot.copy_message(chat_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.copy_message`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().copy_message( + chat_id=self.id, + from_chat_id=from_chat_id, + message_id=message_id, + caption=caption, + video_start_timestamp=video_start_timestamp, + parse_mode=parse_mode, + caption_entities=caption_entities, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + allow_sending_without_reply=allow_sending_without_reply, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + protect_content=protect_content, + message_thread_id=message_thread_id, + show_caption_above_media=show_caption_above_media, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, + ) + + async def copy_message( + self, + chat_id: int | str, + message_id: int, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "MessageId": + """Shortcut for:: + + await bot.copy_message(from_chat_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.copy_message`. + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.MessageId`: On success, returns the MessageId of the sent message. + + """ + return await self.get_bot().copy_message( + from_chat_id=self.id, + chat_id=chat_id, + message_id=message_id, + caption=caption, + video_start_timestamp=video_start_timestamp, + parse_mode=parse_mode, + caption_entities=caption_entities, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + reply_parameters=reply_parameters, + allow_sending_without_reply=allow_sending_without_reply, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + protect_content=protect_content, + message_thread_id=message_thread_id, + show_caption_above_media=show_caption_above_media, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, + ) + + async def send_copies( + self, + from_chat_id: str | int, + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + remove_caption: bool | None = None, + direct_messages_topic_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> tuple["MessageId", ...]: + """Shortcut for:: + + await bot.copy_messages(chat_id=update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.copy_messages`. + + .. seealso:: :meth:`copy_message`, :meth:`send_copy`, :meth:`copy_messages`. + + .. versionadded:: 20.8 + + Returns: + tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of the sent messages is returned. + + """ + return await self.get_bot().copy_messages( + chat_id=self.id, + from_chat_id=from_chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + remove_caption=remove_caption, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, + ) + + async def copy_messages( + self, + chat_id: str | int, + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + remove_caption: bool | None = None, + direct_messages_topic_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> tuple["MessageId", ...]: + """Shortcut for:: + + await bot.copy_messages(from_chat_id=update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.copy_messages`. + + .. seealso:: :meth:`copy_message`, :meth:`send_copy`, :meth:`send_copies`. + + .. versionadded:: 20.8 + + Returns: + tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of the sent messages is returned. + + """ + return await self.get_bot().copy_messages( + from_chat_id=self.id, + chat_id=chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + remove_caption=remove_caption, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, + ) + + async def forward_from( + self, + from_chat_id: str | int, + message_id: int, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.forward_message(chat_id=update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.forward_message`. + + .. seealso:: :meth:`forward_to`, :meth:`forward_messages_from`, :meth:`forward_messages_to` + + .. versionadded:: 20.0 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().forward_message( + chat_id=self.id, + from_chat_id=from_chat_id, + message_id=message_id, + video_start_timestamp=video_start_timestamp, + disable_notification=disable_notification, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + protect_content=protect_content, + message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, + ) + + async def forward_to( + self, + chat_id: int | str, + message_id: int, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.forward_message(from_chat_id=update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.forward_message`. + + .. seealso:: :meth:`forward_from`, :meth:`forward_messages_from`, + :meth:`forward_messages_to` + + .. versionadded:: 20.0 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().forward_message( + from_chat_id=self.id, + chat_id=chat_id, + message_id=message_id, + video_start_timestamp=video_start_timestamp, + disable_notification=disable_notification, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + protect_content=protect_content, + message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, + ) + + async def forward_messages_from( + self, + from_chat_id: str | int, + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> tuple["MessageId", ...]: + """Shortcut for:: + + await bot.forward_messages(chat_id=update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.forward_messages`. + + .. seealso:: :meth:`forward_to`, :meth:`forward_from`, :meth:`forward_messages_to`. + + .. versionadded:: 20.8 + + Returns: + tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of sent messages is returned. + + """ + return await self.get_bot().forward_messages( + chat_id=self.id, + from_chat_id=from_chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, + ) + + async def forward_messages_to( + self, + chat_id: int | str, + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> tuple["MessageId", ...]: + """Shortcut for:: + + await bot.forward_messages(from_chat_id=update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.forward_messages`. + + .. seealso:: :meth:`forward_from`, :meth:`forward_to`, :meth:`forward_messages_from`. + + .. versionadded:: 20.8 + + Returns: + tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` + of sent messages is returned. + + """ + return await self.get_bot().forward_messages( + from_chat_id=self.id, + chat_id=chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + direct_messages_topic_id=direct_messages_topic_id, + ) + + async def approve_join_request( + self, + chat_id: int | str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.approve_chat_join_request(user_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.approve_chat_join_request`. + + Note: + |user_chat_id_note| + + .. versionadded:: 13.8 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().approve_chat_join_request( + user_id=self.id, + chat_id=chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def decline_join_request( + self, + chat_id: int | str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.decline_chat_join_request(user_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.decline_chat_join_request`. + + Note: + |user_chat_id_note| + + .. versionadded:: 13.8 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + + """ + return await self.get_bot().decline_chat_join_request( + user_id=self.id, + chat_id=chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def set_menu_button( + self, + menu_button: MenuButton | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.set_chat_menu_button(chat_id=update.effective_user.id, *argss, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.set_chat_menu_button`. + + .. seealso:: :meth:`get_menu_button` + + Note: + |user_chat_id_note| + + .. versionadded:: 20.0 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().set_chat_menu_button( + chat_id=self.id, + menu_button=menu_button, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def get_menu_button( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> MenuButton: + """Shortcut for:: + + await bot.get_chat_menu_button(chat_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.get_chat_menu_button`. + + .. seealso:: :meth:`set_menu_button` + + Note: + |user_chat_id_note| + + .. versionadded:: 20.0 + + Returns: + :class:`telegram.MenuButton`: On success, the current menu button is returned. + """ + return await self.get_bot().get_chat_menu_button( + chat_id=self.id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def get_chat_boosts( + self, + chat_id: int | str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "UserChatBoosts": + """Shortcut for:: + + await bot.get_user_chat_boosts(user_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.get_user_chat_boosts`. + + .. versionadded:: 20.8 + + Returns: + :class:`telegram.UserChatBoosts`: On success, returns the boosts applied by the user. + """ + return await self.get_bot().get_user_chat_boosts( + chat_id=chat_id, + user_id=self.id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def refund_star_payment( + self, + telegram_payment_charge_id: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.refund_star_payment(user_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.refund_star_payment`. + + .. versionadded:: 21.3 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().refund_star_payment( + user_id=self.id, + telegram_payment_charge_id=telegram_payment_charge_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def verify( + self, + custom_description: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.verify_user(user_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.verify_user`. + + .. versionadded:: 21.10 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().verify_user( + user_id=self.id, + custom_description=custom_description, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def remove_verification( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.remove_user_verification(user_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.remove_user_verification`. + + .. versionadded:: 21.10 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().remove_user_verification( + user_id=self.id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def repost_story( + self, + business_connection_id: str, + from_story_id: int, + active_period: TimePeriod, + post_to_chat_page: bool | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Story": + """Shortcut for:: + + await bot.repost_story( + from_chat_id=update.effective_user.id, + *args, **kwargs + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.repost_story`. + + .. versionadded:: 22.6 + + Returns: + :class:`Story`: On success, :class:`Story` is returned. + + """ + return await self.get_bot().repost_story( + business_connection_id=business_connection_id, + from_chat_id=self.id, + from_story_id=from_story_id, + active_period=active_period, + post_to_chat_page=post_to_chat_page, + protect_content=protect_content, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def get_gifts( + self, + exclude_unlimited: bool | None = None, + exclude_limited_upgradable: bool | None = None, + exclude_limited_non_upgradable: bool | None = None, + exclude_from_blockchain: bool | None = None, + exclude_unique: bool | None = None, + sort_by_price: bool | None = None, + offset: str | None = None, + limit: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "OwnedGifts": + """Shortcut for:: + + await bot.get_user_gifts(user_id=update.effective_user.id) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.get_user_gifts`. + + .. versionadded:: 22.6 + + Returns: + :class:`telegram.OwnedGifts`: On success, returns the gifts owned by the user. + """ + return await self.get_bot().get_user_gifts( + user_id=self.id, + exclude_unlimited=exclude_unlimited, + exclude_limited_upgradable=exclude_limited_upgradable, + exclude_limited_non_upgradable=exclude_limited_non_upgradable, + exclude_from_blockchain=exclude_from_blockchain, + exclude_unique=exclude_unique, + sort_by_price=sort_by_price, + offset=offset, + limit=limit, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) diff --git a/telegram/_userprofilephotos.py b/src/telegram/_userprofilephotos.py similarity index 86% rename from telegram/_userprofilephotos.py rename to src/telegram/_userprofilephotos.py index 60aa926053f..59cebae1835 100644 --- a/telegram/_userprofilephotos.py +++ b/src/telegram/_userprofilephotos.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,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/]. """This module contains an object that represents a Telegram UserProfilePhotos.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._files.photosize import PhotoSize from telegram._telegramobject import TelegramObject @@ -43,7 +45,7 @@ class UserProfilePhotos(TelegramObject): Attributes: total_count (:obj:`int`): Total number of profile pictures. - photos (Tuple[Tuple[:class:`telegram.PhotoSize`]]): Requested profile pictures (in up to 4 + photos (tuple[tuple[:class:`telegram.PhotoSize`]]): Requested profile pictures (in up to 4 sizes each). .. versionchanged:: 20.0 @@ -58,25 +60,22 @@ def __init__( total_count: int, photos: Sequence[Sequence[PhotoSize]], *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required self.total_count: int = total_count - self.photos: Tuple[Tuple[PhotoSize, ...], ...] = tuple(tuple(sizes) for sizes in photos) + self.photos: tuple[tuple[PhotoSize, ...], ...] = tuple(tuple(sizes) for sizes in photos) self._id_attrs = (self.total_count, self.photos) self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["UserProfilePhotos"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "UserProfilePhotos": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - data["photos"] = [PhotoSize.de_list(photo, bot) for photo in data["photos"]] return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_userrating.py b/src/telegram/_userrating.py new file mode 100644 index 00000000000..f8045eed9ec --- /dev/null +++ b/src/telegram/_userrating.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 user rating.""" + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class UserRating(TelegramObject): + """ + This object describes the rating of a user based on their Telegram Star spendings. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`level` and :attr:`rating` are equal. + + .. versionadded:: 22.6 + + Args: + level (:obj:`int`): Current level of the user, indicating their reliability when purchasing + digital goods and services. A higher level suggests a more trustworthy customer; a + negative level is likely reason for concern. + rating (:obj:`int`): Numerical value of the user's rating; the higher the rating, the + better + current_level_rating (:obj:`int`): The rating value required to get the current level + next_level_rating (:obj:`int`, optional): The rating value required to get to the next + level; omitted if the maximum level was reached + + Attributes: + level (:obj:`int`): Current level of the user, indicating their reliability when purchasing + digital goods and services. A higher level suggests a more trustworthy customer; a + negative level is likely reason for concern. + rating (:obj:`int`): Numerical value of the user's rating; the higher the rating, the + better + current_level_rating (:obj:`int`): The rating value required to get the current level + next_level_rating (:obj:`int`): Optional. The rating value required to get to the next + level; omitted if the maximum level was reached + + """ + + __slots__ = ("current_level_rating", "level", "next_level_rating", "rating") + + def __init__( + self, + level: int, + rating: int, + current_level_rating: int, + next_level_rating: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.level: int = level + self.rating: int = rating + self.current_level_rating: int = current_level_rating + self.next_level_rating: int | None = next_level_rating + + self._id_attrs = (self.level, self.rating) + + self._freeze() diff --git a/src/telegram/_utils/__init__.py b/src/telegram/_utils/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/telegram/_utils/argumentparsing.py b/src/telegram/_utils/argumentparsing.py new file mode 100644 index 00000000000..240b43ea6dd --- /dev/null +++ b/src/telegram/_utils/argumentparsing.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 helper functions related to parsing arguments for classes and methods. + +Warning: + Contents of this module are intended to be used internally by the library and *not* by the + user. Changes to this module are not considered breaking changes and may not be documented in + the changelog. +""" + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING, Protocol, TypeVar, overload + +from telegram._linkpreviewoptions import LinkPreviewOptions +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict, ODVInput + +if TYPE_CHECKING: + from typing import type_check_only + + from telegram import Bot, FileCredentials + +T = TypeVar("T") + + +def parse_sequence_arg(arg: Sequence[T] | None) -> tuple[T, ...]: + """Parses an optional sequence into a tuple + + Args: + arg (:obj:`Sequence`): The sequence to parse. + + Returns: + :obj:`Tuple`: The sequence converted to a tuple or an empty tuple. + """ + return tuple(arg) if arg else () + + +@overload +def to_timedelta(arg: None) -> None: ... + + +@overload +def to_timedelta( + arg: ( + int | float | dtm.timedelta # noqa: PYI041 (be more explicit about `int` and `float` args) + ), +) -> dtm.timedelta: ... + + +def to_timedelta(arg: int | float | dtm.timedelta | None) -> dtm.timedelta | None: # noqa: PYI041 + """Parses an optional time period in seconds into a timedelta + + Args: + arg (:obj:`int` | :class:`datetime.timedelta`, optional): The time period to parse. + + Returns: + :obj:`timedelta`: The time period converted to a timedelta object or :obj:`None`. + """ + if arg is None: + return None + if isinstance(arg, int | float): + return dtm.timedelta(seconds=arg) + return arg + + +def parse_lpo_and_dwpp( + disable_web_page_preview: bool | None, link_preview_options: ODVInput[LinkPreviewOptions] +) -> ODVInput[LinkPreviewOptions]: + """Wrapper around warn_about_deprecated_arg_return_new_arg. Takes care of converting + disable_web_page_preview to LinkPreviewOptions. + """ + if disable_web_page_preview and link_preview_options: + raise ValueError( + "Parameters `disable_web_page_preview` and `link_preview_options` are mutually " + "exclusive." + ) + + if disable_web_page_preview is not None: + link_preview_options = LinkPreviewOptions(is_disabled=disable_web_page_preview) + + return link_preview_options + + +Tele_co = TypeVar("Tele_co", bound=TelegramObject, covariant=True) +TeleCrypto_co = TypeVar("TeleCrypto_co", bound="HasDecryptMethod", covariant=True) + +if TYPE_CHECKING: + + @type_check_only + class HasDecryptMethod(Protocol): + __slots__ = () + + @classmethod + def de_json_decrypted( + cls: type[TeleCrypto_co], + data: JSONDict, + bot: "Bot | None", + credentials: list["FileCredentials"], + ) -> TeleCrypto_co: ... + + @classmethod + def de_list_decrypted( + cls: type[TeleCrypto_co], + data: list[JSONDict], + bot: "Bot | None", + credentials: list["FileCredentials"], + ) -> tuple[TeleCrypto_co, ...]: ... + + +def de_json_optional( + data: JSONDict | None, cls: type[Tele_co], bot: "Bot | None" +) -> Tele_co | None: + """Wrapper around TO.de_json that returns None if data is None.""" + if data is None: + return None + + return cls.de_json(data, bot) + + +def de_json_decrypted_optional( + data: JSONDict | None, + cls: type[TeleCrypto_co], + bot: "Bot | None", + credentials: list["FileCredentials"], +) -> TeleCrypto_co | None: + """Wrapper around TO.de_json_decrypted that returns None if data is None.""" + if data is None: + return None + + return cls.de_json_decrypted(data, bot, credentials) + + +def de_list_optional( + data: list[JSONDict] | None, cls: type[Tele_co], bot: "Bot | None" +) -> tuple[Tele_co, ...]: + """Wrapper around TO.de_list that returns an empty list if data is None.""" + if data is None: + return () + + return cls.de_list(data, bot) + + +def de_list_decrypted_optional( + data: list[JSONDict] | None, + cls: type[TeleCrypto_co], + bot: "Bot | None", + credentials: list["FileCredentials"], +) -> tuple[TeleCrypto_co, ...]: + """Wrapper around TO.de_list_decrypted that returns an empty list if data is None.""" + if data is None: + return () + + return cls.de_list_decrypted(data, bot, credentials) diff --git a/telegram/_utils/datetime.py b/src/telegram/_utils/datetime.py similarity index 62% rename from telegram/_utils/datetime.py rename to src/telegram/_utils/datetime.py index d8a6e3dda0a..a68faa39908 100644 --- a/telegram/_utils/datetime.py +++ b/src/telegram/_utils/datetime.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -27,35 +27,46 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ -import datetime as dtm # skipcq: PYL-W0406 + +import contextlib +import datetime as dtm +import os import time -from typing import TYPE_CHECKING, Optional, Union +import zoneinfo +from typing import TYPE_CHECKING + +from telegram._utils.warnings import warn +from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram import Bot -# pytz is only available if it was installed as dependency of APScheduler, so we make a little -# workaround here -DTM_UTC = dtm.timezone.utc +UTC = dtm.timezone.utc try: import pytz - - UTC = pytz.utc except ImportError: - UTC = DTM_UTC # type: ignore[assignment] + pytz = None # type: ignore[assignment] -def _localize(datetime: dtm.datetime, tzinfo: dtm.tzinfo) -> dtm.datetime: - """Localize the datetime, where UTC is handled depending on whether pytz is available or not""" - if tzinfo is DTM_UTC: - return datetime.replace(tzinfo=DTM_UTC) - return tzinfo.localize(datetime) # type: ignore[attr-defined] +def localize(datetime: dtm.datetime, tzinfo: dtm.tzinfo) -> dtm.datetime: + """Localize the datetime, both for pytz and zoneinfo timezones.""" + if tzinfo is UTC: + return datetime.replace(tzinfo=UTC) + + with contextlib.suppress(AttributeError): + # Since pytz might not be available, we need the suppress context manager + if isinstance(tzinfo, pytz.BaseTzInfo): + return tzinfo.localize(datetime) + + if datetime.tzinfo is None: + return datetime.replace(tzinfo=tzinfo) + return datetime.astimezone(tzinfo) def to_float_timestamp( - time_object: Union[int, float, dtm.timedelta, dtm.datetime, dtm.time], - reference_timestamp: Optional[float] = None, - tzinfo: Optional[dtm.tzinfo] = None, + time_object: float | dtm.timedelta | dtm.datetime | dtm.time, + reference_timestamp: float | None = None, + tzinfo: dtm.tzinfo | None = None, ) -> float: """ Converts a given time object to a float POSIX timestamp. @@ -65,12 +76,11 @@ def to_float_timestamp( to be in UTC, if ``bot`` is not passed or ``bot.defaults`` is :obj:`None`. Args: - time_object (:obj:`int` | :obj:`float` | :obj:`datetime.timedelta` | \ + time_object (:obj:`float` | :obj:`datetime.timedelta` | \ :obj:`datetime.datetime` | :obj:`datetime.time`): Time value to convert. The semantics of this parameter will depend on its type: - * :obj:`int` or :obj:`float` will be interpreted as "seconds from - :paramref:`reference_t`" + * :obj:`float` will be interpreted as "seconds from :paramref:`reference_t`" * :obj:`datetime.timedelta` will be interpreted as "time increment from :paramref:`reference_timestamp`" * :obj:`datetime.datetime` will be interpreted as an absolute date/time value @@ -88,7 +98,7 @@ def to_float_timestamp( will be raised. tzinfo (:class:`datetime.tzinfo`, optional): If :paramref:`time_object` is a naive object from the :mod:`datetime` module, it will be interpreted as this timezone. Defaults to - ``pytz.utc``, if available, and :attr:`datetime.timezone.utc` otherwise. + :attr:`datetime.timezone.utc` otherwise. Note: Only to be used by ``telegram.ext``. @@ -118,10 +128,16 @@ def to_float_timestamp( if isinstance(time_object, dtm.timedelta): return reference_timestamp + time_object.total_seconds() - if isinstance(time_object, (int, float)): + if isinstance(time_object, int | float): return reference_timestamp + time_object if tzinfo is None: + # We do this here rather than in the signature to ensure that we can make calls like + # to_float_timestamp( + # time, tzinfo=bot.defaults.tzinfo if bot.defaults else None + # ) + # This ensures clean separation of concerns, i.e. the default timezone should not be + # the responsibility of the caller tzinfo = UTC if isinstance(time_object, dtm.time): @@ -133,7 +149,9 @@ def to_float_timestamp( aware_datetime = dtm.datetime.combine(reference_date, time_object) if aware_datetime.tzinfo is None: - aware_datetime = _localize(aware_datetime, tzinfo) + # datetime.combine uses the tzinfo of `time_object`, which might be None + # so we still need to localize + aware_datetime = localize(aware_datetime, tzinfo) # if the time of day has passed today, use tomorrow if reference_time > aware_datetime.timetz(): @@ -141,17 +159,17 @@ def to_float_timestamp( return _datetime_to_float_timestamp(aware_datetime) if isinstance(time_object, dtm.datetime): if time_object.tzinfo is None: - time_object = _localize(time_object, tzinfo) + time_object = localize(time_object, tzinfo) return _datetime_to_float_timestamp(time_object) raise TypeError(f"Unable to convert {type(time_object).__name__} object to timestamp") def to_timestamp( - dt_obj: Union[int, float, dtm.timedelta, dtm.datetime, dtm.time, None], - reference_timestamp: Optional[float] = None, - tzinfo: Optional[dtm.tzinfo] = None, -) -> Optional[int]: + dt_obj: float | dtm.timedelta | dtm.datetime | dtm.time | None, + reference_timestamp: float | None = None, + tzinfo: dtm.tzinfo | None = None, +) -> int | None: """ Wrapper over :func:`to_float_timestamp` which returns an integer (the float value truncated down to the nearest integer). @@ -166,9 +184,9 @@ def to_timestamp( def from_timestamp( - unixtime: Optional[int], - tzinfo: Optional[dtm.tzinfo] = None, -) -> Optional[dtm.datetime]: + unixtime: int | None, + tzinfo: dtm.tzinfo | None = None, +) -> dtm.datetime | None: """ Converts an (integer) unix timestamp to a timezone aware datetime object. :obj:`None` s are left alone (i.e. ``from_timestamp(None)`` is :obj:`None`). @@ -189,13 +207,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: "Bot | None") -> 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 - # in `python-telegram-bot-raw` as well + # 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 @@ -209,3 +230,60 @@ def _datetime_to_float_timestamp(dt_obj: dtm.datetime) -> float: if dt_obj.tzinfo is None: dt_obj = dt_obj.replace(tzinfo=dtm.timezone.utc) return dt_obj.timestamp() + + +def get_zone_info(tz: str) -> zoneinfo.ZoneInfo: + """Wrapper around the `ZoneInfo` constructor with slightly more helpful error message + in case tzdata is not installed. + """ + try: + return zoneinfo.ZoneInfo(tz) + except zoneinfo.ZoneInfoNotFoundError as err: + raise zoneinfo.ZoneInfoNotFoundError( + f"No time zone found with key {tz}. " + "Make sure to use a valid time zone name and " + f"correctly install the tzdata (https://pypi.org/project/tzdata/) package if " + "your system does not provide the time zone data." + ) from err + + +def get_timedelta_value(value: dtm.timedelta | None, attribute: str) -> int | dtm.timedelta | None: + """ + Convert a `datetime.timedelta` to seconds or return it as-is, based on environment config. + + This utility is part of the migration process from integer-based time representations + to using `datetime.timedelta`. The behavior is controlled by the `PTB_TIMEDELTA` + environment variable. + + Note: + When `PTB_TIMEDELTA` is not enabled, the function will issue a deprecation warning. + + Args: + value (:obj:`datetime.timedelta`): The timedelta value to process. + attribute (:obj:`str`): The name of the attribute at the caller scope, used for + warning messages. + + Returns: + - :obj:`None` if :paramref:`value` is None. + - :obj:`datetime.timedelta` if `PTB_TIMEDELTA=true` or ``PTB_TIMEDELTA=1``. + - :obj:`int` if the total seconds is a whole number. + - float: otherwise. + """ + if value is None: + return None + if os.getenv("PTB_TIMEDELTA", "false").lower().strip() in ["true", "1"]: + return value + warn( + PTBDeprecationWarning( + "v22.2", + f"In a future major version attribute `{attribute}` will be of type" + " `datetime.timedelta`. You can opt-in early by setting `PTB_TIMEDELTA=true`" + " or ``PTB_TIMEDELTA=1`` as an environment variable.", + ), + stacklevel=2, + ) + return ( + int(seconds) # type: ignore[return-value] + if (seconds := value.total_seconds()).is_integer() + else seconds + ) diff --git a/telegram/_utils/defaultvalue.py b/src/telegram/_utils/defaultvalue.py similarity index 88% rename from telegram/_utils/defaultvalue.py rename to src/telegram/_utils/defaultvalue.py index 6a8084ff118..cdb83cc0a0b 100644 --- a/telegram/_utils/defaultvalue.py +++ b/src/telegram/_utils/defaultvalue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -27,7 +27,8 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ -from typing import Generic, TypeVar, Union, overload + +from typing import Generic, TypeVar, overload DVType = TypeVar("DVType", bound=object) # pylint: disable=invalid-name OT = TypeVar("OT", bound=object) @@ -88,18 +89,24 @@ def __init__(self, value: DVType): def __bool__(self) -> bool: return bool(self.value) + # This is mostly here for readability during debugging + def __str__(self) -> str: + return f"DefaultValue({self.value})" + + # This is here to have the default instances nicely rendered in the docs + def __repr__(self) -> str: + return repr(self.value) + @overload @staticmethod - def get_value(obj: "DefaultValue[OT]") -> OT: - ... + def get_value(obj: "DefaultValue[OT]") -> OT: ... @overload @staticmethod - def get_value(obj: OT) -> OT: - ... + def get_value(obj: OT) -> OT: ... @staticmethod - def get_value(obj: Union[OT, "DefaultValue[OT]"]) -> OT: + def get_value(obj: "OT | DefaultValue[OT]") -> OT: """Shortcut for:: return obj.value if isinstance(obj, DefaultValue) else obj @@ -112,14 +119,6 @@ def get_value(obj: Union[OT, "DefaultValue[OT]"]) -> OT: """ return obj.value if isinstance(obj, DefaultValue) else obj - # This is mostly here for readability during debugging - def __str__(self) -> str: - return f"DefaultValue({self.value})" - - # This is here to have the default instances nicely rendered in the docs - def __repr__(self) -> str: - return repr(self.value) - DEFAULT_NONE: DefaultValue[None] = DefaultValue(None) """:class:`DefaultValue`: Default :obj:`None`""" @@ -133,5 +132,18 @@ def __repr__(self) -> str: .. versionadded:: 20.0 """ + DEFAULT_20: DefaultValue[int] = DefaultValue(20) """:class:`DefaultValue`: Default :obj:`20`""" + +DEFAULT_IP: DefaultValue[str] = DefaultValue("127.0.0.1") +""":class:`DefaultValue`: Default :obj:`127.0.0.1` + +.. versionadded:: 20.8 +""" + +DEFAULT_80: DefaultValue[int] = DefaultValue(80) +""":class:`DefaultValue`: Default :obj:`80` + +.. versionadded:: 20.8 +""" diff --git a/src/telegram/_utils/entities.py b/src/telegram/_utils/entities.py new file mode 100644 index 00000000000..31cc4995329 --- /dev/null +++ b/src/telegram/_utils/entities.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 auxiliary functionality for parsing MessageEntity objects. + +Warning: + Contents of this module are intended to be used internally by the library and *not* by the + user. Changes to this module are not considered breaking changes and may not be documented in + the changelog. +""" + +from collections.abc import Sequence + +from telegram._messageentity import MessageEntity +from telegram._utils.strings import TextEncoding + + +def parse_message_entity(text: str, entity: MessageEntity) -> str: + """Returns the text from a given :class:`telegram.MessageEntity`. + + Args: + text (:obj:`str`): The text to extract the entity from. + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. + + Returns: + :obj:`str`: The text of the given entity. + """ + entity_text = text.encode(TextEncoding.UTF_16_LE) + entity_text = entity_text[entity.offset * 2 : (entity.offset + entity.length) * 2] + + return entity_text.decode(TextEncoding.UTF_16_LE) + + +def parse_message_entities( + text: str, entities: Sequence[MessageEntity], types: Sequence[str] | None = None +) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + Args: + text (:obj:`str`): The text to extract the entity from. + entities (list[:class:`telegram.MessageEntity`]): The entities to extract the text from. + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + """ + if types is None: + types = MessageEntity.ALL_TYPES + + return { + entity: parse_message_entity(text, entity) for entity in entities if entity.type in types + } diff --git a/telegram/_utils/enum.py b/src/telegram/_utils/enum.py similarity index 81% rename from telegram/_utils/enum.py rename to src/telegram/_utils/enum.py index 88bfb047018..a8a77959224 100644 --- a/telegram/_utils/enum.py +++ b/src/telegram/_utils/enum.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,16 +23,17 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ -import enum as _enum # skipcq: PYL-R0201 + +import enum as _enum import sys -from typing import Type, TypeVar, Union +from typing import TypeVar _A = TypeVar("_A") _B = TypeVar("_B") _Enum = TypeVar("_Enum", bound=_enum.Enum) -def get_member(enum_cls: Type[_Enum], value: _A, default: _B) -> Union[_Enum, _A, _B]: +def get_member(enum_cls: type[_Enum], value: _A, default: _B) -> _Enum | _A | _B: """Tries to call ``enum_cls(value)`` to convert the value into an enumeration member. If that fails, the ``default`` is returned. """ @@ -60,7 +61,7 @@ def __str__(self) -> str: # Apply the __repr__ modification and __str__ fix to IntEnum -class IntEnum(_enum.IntEnum): +class IntEnum(_enum.IntEnum): # pylint: disable=invalid-slots """Helper class for int enums where ``str(member)`` prints the value, but ``repr(member)`` gives ``EnumName.MEMBER_NAME``. """ @@ -74,3 +75,17 @@ def __repr__(self) -> str: def __str__(self) -> str: return str(self.value) + + +class FloatEnum(float, _enum.Enum): + """Helper class for float enums where ``str(member)`` prints the value, but ``repr(member)`` + gives ``EnumName.MEMBER_NAME``. + """ + + __slots__ = () + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}.{self.name}>" + + def __str__(self) -> str: + return str(self.value) diff --git a/telegram/_utils/files.py b/src/telegram/_utils/files.py similarity index 78% rename from telegram/_utils/files.py rename to src/telegram/_utils/files.py index 37bc0332e54..e1637ef1988 100644 --- a/telegram/_utils/files.py +++ b/src/telegram/_utils/files.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -29,29 +29,29 @@ """ from pathlib import Path -from typing import IO, TYPE_CHECKING, Any, Optional, Tuple, Type, TypeVar, Union, cast, overload +from typing import IO, TYPE_CHECKING, TypeVar, cast, overload from telegram._utils.types import FileInput, FilePathInput if TYPE_CHECKING: + from typing import Any + from telegram import InputFile, TelegramObject -_T = TypeVar("_T", bound=Union[bytes, "InputFile", str, Path, None]) +_T = TypeVar("_T", bound="bytes | InputFile | str | Path | None") @overload -def load_file(obj: IO[bytes]) -> Tuple[Optional[str], bytes]: - ... +def load_file(obj: IO[bytes]) -> tuple[str | None, bytes]: ... @overload -def load_file(obj: _T) -> Tuple[None, _T]: - ... +def load_file(obj: _T) -> tuple[None, _T]: ... def load_file( - obj: Optional[FileInput], -) -> Tuple[Optional[str], Union[bytes, "InputFile", str, Path, None]]: + obj: "FileInput | None", +) -> tuple[str | None, "bytes | InputFile | str | Path | None"]: """If the input is a file handle, read the data and name and return it. Otherwise, return the input unchanged. """ @@ -61,17 +61,24 @@ def load_file( try: contents = obj.read() # type: ignore[union-attr] except AttributeError: - return None, cast(Union[bytes, "InputFile", str, Path], obj) + return None, cast("bytes | InputFile | str | Path", obj) - if hasattr(obj, "name") and not isinstance(obj.name, int): - filename = Path(obj.name).name - else: - filename = None + filename = guess_file_name(obj) return filename, contents -def is_local_file(obj: Optional[FilePathInput]) -> bool: +def guess_file_name(obj: FileInput) -> str | None: + """If the input is a file handle, read name and return it. Otherwise, return + the input unchanged. + """ + if hasattr(obj, "name") and not isinstance(obj.name, int): + return Path(obj.name).name + + return None + + +def is_local_file(obj: FilePathInput | None) -> bool: """ Checks if a given string is a file on local system. @@ -89,12 +96,12 @@ def is_local_file(obj: Optional[FilePathInput]) -> bool: def parse_file_input( # pylint: disable=too-many-return-statements - file_input: Union[FileInput, "TelegramObject"], - tg_type: Optional[Type["TelegramObject"]] = None, - filename: Optional[str] = None, + file_input: "FileInput | TelegramObject", + tg_type: type["TelegramObject"] | None = None, + filename: str | None = None, attach: bool = False, local_mode: bool = False, -) -> Union[str, "InputFile", Any]: +) -> "str | InputFile | Any": """ Parses input for sending files: @@ -112,8 +119,8 @@ def parse_file_input( # pylint: disable=too-many-return-statements attribute. Args: - file_input (:obj:`str` | :obj:`bytes` | :term:`file object` | Telegram media object): The - input to parse. + file_input (:obj:`str` | :obj:`bytes` | :term:`file object` | :class:`~telegram.InputFile`\ + | Telegram media object): The input to parse. tg_type (:obj:`type`, optional): The Telegram media type the input can be. E.g. :class:`telegram.Animation`. filename (:obj:`str`, optional): The filename. Only relevant in case an @@ -129,24 +136,25 @@ def parse_file_input( # pylint: disable=too-many-return-statements :attr:`file_input`, in case it's no valid file input. """ # Importing on file-level yields cyclic Import Errors - from telegram import InputFile # pylint: disable=import-outside-toplevel + from telegram import InputFile # pylint: disable=import-outside-toplevel # noqa: PLC0415 if isinstance(file_input, str) and file_input.startswith("file://"): if not local_mode: raise ValueError("Specified file input is a file URI, but local mode is not enabled.") return file_input - if isinstance(file_input, (str, Path)): + if isinstance(file_input, str | Path): if is_local_file(file_input): path = Path(file_input) if local_mode: return path.absolute().as_uri() - return InputFile(path.open(mode="rb"), filename=filename, attach=attach) + with path.open(mode="rb") as file_handle: + return InputFile(file_handle, filename=filename, attach=attach) return file_input if isinstance(file_input, bytes): return InputFile(file_input, filename=filename, attach=attach) if hasattr(file_input, "read"): - return InputFile(cast(IO, file_input), filename=filename, attach=attach) + return InputFile(cast("IO", file_input), filename=filename, attach=attach) if tg_type and isinstance(file_input, tg_type): return file_input.file_id # type: ignore[attr-defined] return file_input diff --git a/telegram/_utils/logging.py b/src/telegram/_utils/logging.py similarity index 92% rename from telegram/_utils/logging.py rename to src/telegram/_utils/logging.py index da888cc83aa..47913461028 100644 --- a/telegram/_utils/logging.py +++ b/src/telegram/_utils/logging.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,11 +23,11 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ + import logging -from typing import Optional -def get_logger(file_name: str, class_name: Optional[str] = None) -> logging.Logger: +def get_logger(file_name: str, class_name: str | None = None) -> logging.Logger: """Returns a logger with an appropriate name. Use as follows:: diff --git a/telegram/_utils/markup.py b/src/telegram/_utils/markup.py similarity index 94% rename from telegram/_utils/markup.py rename to src/telegram/_utils/markup.py index 2a79d9bac3d..d2ff0c6a8dd 100644 --- a/telegram/_utils/markup.py +++ b/src/telegram/_utils/markup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -27,6 +27,7 @@ class ``telegram.ReplyMarkup``. user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ + from collections.abc import Sequence @@ -37,11 +38,11 @@ def check_keyboard_type(keyboard: object) -> bool: # string and bytes may actually be used for ReplyKeyboardMarkup in which case each button # would contain a single character. But that use case should be discouraged and we don't # allow it here. - if not isinstance(keyboard, Sequence) or isinstance(keyboard, (str, bytes)): + if not isinstance(keyboard, Sequence) or isinstance(keyboard, str | bytes): return False for row in keyboard: - if not isinstance(row, Sequence) or isinstance(row, (str, bytes)): + if not isinstance(row, Sequence) or isinstance(row, str | bytes): return False for inner in row: if isinstance(inner, Sequence) and not isinstance(inner, str): diff --git a/telegram/_utils/argumentparsing.py b/src/telegram/_utils/repr.py similarity index 55% rename from telegram/_utils/argumentparsing.py rename to src/telegram/_utils/repr.py index b7232d7faa5..3aa78dd51a2 100644 --- a/telegram/_utils/argumentparsing.py +++ b/src/telegram/_utils/repr.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,25 +16,31 @@ # # 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 helper functions related to parsing arguments for classes and methods. +"""This module contains auxiliary functionality for building strings for __repr__ method. Warning: Contents of this module are intended to be used internally by the library and *not* by the user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ -from typing import Optional, Sequence, Tuple, TypeVar -T = TypeVar("T") +from typing import Any -def parse_sequence_arg(arg: Optional[Sequence[T]]) -> Tuple[T, ...]: - """Parses an optional sequence into a tuple +def build_repr_with_selected_attrs(obj: object, **kwargs: Any) -> str: + """Create ``__repr__`` string in the style ``Classname[arg1=1, arg2=2]``. - Args: - arg (:obj:`Sequence`): The sequence to parse. + The square brackets emphasize the fact that an object cannot be instantiated + from this string. - Returns: - :obj:`Tuple`: The sequence converted to a tuple or an empty tuple. + Attributes that are to be used in the representation, are passed as kwargs. """ - return tuple(arg) if arg else () + return ( + f"{obj.__class__.__name__}" + # square brackets emphasize that an object cannot be instantiated with these params + f"[{', '.join(_stringify(name, value) for name, value in kwargs.items())}]" + ) + + +def _stringify(key: str, val: Any) -> str: + return f"{key}={val.__qualname__ if callable(val) else val}" diff --git a/src/telegram/_utils/strings.py b/src/telegram/_utils/strings.py new file mode 100644 index 00000000000..5066db58ab7 --- /dev/null +++ b/src/telegram/_utils/strings.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains a helper functions related to string manipulation. + +Warning: + Contents of this module are intended to be used internally by the library and *not* by the + user. Changes to this module are not considered breaking changes and may not be documented in + the changelog. +""" + +from telegram._utils.enum import StringEnum + +# TODO: Remove this when https://github.com/PyCQA/pylint/issues/6887 is resolved. +# pylint: disable=invalid-enum-extension + + +class TextEncoding(StringEnum): + """This enum contains encoding schemes for text. + + .. versionadded:: 21.5 + """ + + __slots__ = () + + UTF_8 = "utf-8" + UTF_16_LE = "utf-16-le" + + +def to_camel_case(snake_str: str) -> str: + """Converts a snake_case string to camelCase. + + Args: + snake_str (:obj:`str`): The string to convert. + + Returns: + :obj:`str`: The converted string. + """ + components = snake_str.split("_") + return components[0] + "".join(x.title() for x in components[1:]) diff --git a/telegram/_utils/types.py b/src/telegram/_utils/types.py similarity index 57% rename from telegram/_utils/types.py rename to src/telegram/_utils/types.py index e3e5985402f..1b29e2e8de6 100644 --- a/telegram/_utils/types.py +++ b/src/telegram/_utils/types.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,8 +23,13 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ + +import datetime as dtm +from collections.abc import Callable, Collection from pathlib import Path -from typing import IO, TYPE_CHECKING, Any, Collection, Dict, Optional, Tuple, TypeVar, Union +from typing import IO, TYPE_CHECKING, Any, Literal, TypeAlias, TypeVar, Union + +from telegram._utils.defaultvalue import DefaultValue if TYPE_CHECKING: from telegram import ( @@ -34,36 +39,41 @@ ReplyKeyboardMarkup, ReplyKeyboardRemove, ) - from telegram._utils.defaultvalue import DefaultValue -FileLike = Union[IO[bytes], "InputFile"] +# We guarantee that InputFile will be defined at runtime, so we can use a string here and ignore +# ruff. +# See https://github.com/python-telegram-bot/python-telegram-bot/pull/4827#issuecomment-2973060875 +# on why we're doing this workaround. +# TODO: Use `type` syntax when we drop support for Python 3.11. +FileLike: TypeAlias = IO[bytes] | "InputFile" # noqa: TC010 """Either a bytes-stream (e.g. open file handler) or a :class:`telegram.InputFile`.""" -FilePathInput = Union[str, Path] +FilePathInput: TypeAlias = str | Path """A filepath either as string or as :obj:`pathlib.Path` object.""" -FileInput = Union[FilePathInput, FileLike, bytes, str] +FileInput: TypeAlias = FilePathInput | FileLike | bytes | str """Valid input for passing files to Telegram. Either a file id as string, a file like object, a local file path as string, :class:`pathlib.Path` or the file contents as :obj:`bytes`.""" -JSONDict = Dict[str, Any] +JSONDict: TypeAlias = dict[str, Any] """Dictionary containing response from Telegram or data to send to the API.""" DVValueType = TypeVar("DVValueType") # pylint: disable=invalid-name -DVType = Union[DVValueType, "DefaultValue[DVValueType]"] -"""Generic type for a variable which can be either `type` or `DefaultVaule[type]`.""" -ODVInput = Optional[Union["DefaultValue[DVValueType]", DVValueType, "DefaultValue[None]"]] +DVType: TypeAlias = DVValueType | DefaultValue[DVValueType] +"""Generic type for a variable which can be either `type` or `DefaultValue[type]`.""" +ODVInput: TypeAlias = DefaultValue[DVValueType] | DVValueType | DefaultValue[None] | None """Generic type for bot method parameters which can have defaults. ``ODVInput[type]`` is the same -as ``Optional[Union[DefaultValue[type], type, DefaultValue[None]]``.""" -DVInput = Union["DefaultValue[DVValueType]", DVValueType, "DefaultValue[None]"] +as ``Union[DefaultValue[type], type, DefaultValue[None], None]``.""" +DVInput: TypeAlias = DefaultValue[DVValueType] | DVValueType | DefaultValue[None] """Generic type for bot method parameters which can have defaults. ``DVInput[type]`` is the same as ``Union[DefaultValue[type], type, DefaultValue[None]]``.""" RT = TypeVar("RT") -SCT = Union[RT, Collection[RT]] # pylint: disable=invalid-name +SCT: TypeAlias = RT | Collection[RT] # pylint: disable=invalid-name """Single instance or collection of instances.""" -ReplyMarkup = Union[ +# See comment above on why we're stuck using a Union here. +ReplyMarkup: TypeAlias = Union[ "InlineKeyboardMarkup", "ReplyKeyboardMarkup", "ReplyKeyboardRemove", "ForceReply" ] """Type alias for reply markup objects. @@ -71,7 +81,24 @@ .. versionadded:: 20.0 """ -FieldTuple = Tuple[str, bytes, str] +FieldTuple: TypeAlias = tuple[str, bytes | IO[bytes], str] """Alias for return type of `InputFile.field_tuple`.""" -UploadFileDict = Dict[str, FieldTuple] +UploadFileDict: TypeAlias = dict[str, FieldTuple] """Dictionary containing file data to be uploaded to the API.""" + +HTTPVersion: TypeAlias = Literal["1.1", "2.0", "2"] +"""Allowed HTTP versions. + +.. versionadded:: 20.4""" + +CorrectOptionID: TypeAlias = Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] # pylint: disable=invalid-name + +MarkdownVersion: TypeAlias = Literal[1, 2] + +SocketOpt: TypeAlias = ( + tuple[int, int, int] | tuple[int, int, bytes | bytearray] | tuple[int, int, None, int] +) + +BaseUrl: TypeAlias = str | Callable[[str], str] + +TimePeriod: TypeAlias = int | dtm.timedelta diff --git a/src/telegram/_utils/usernames.py b/src/telegram/_utils/usernames.py new file mode 100644 index 00000000000..5ecb2e4699f --- /dev/null +++ b/src/telegram/_utils/usernames.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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/]. +"""Helper utilities around Telegram Objects first_name, last_name and username. +.. versionadded:: 22.4 + +Warning: + Contents of this module are intended to be used internally by the library and *not* by the + user. Changes to this module are not considered breaking changes and may not be documented in + the changelog. +""" + +from typing import TYPE_CHECKING, Protocol, TypeVar, overload + +TeleUserLike = TypeVar("TeleUserLike", bound="UserLike") +TeleUserLikeOptional = TypeVar("TeleUserLikeOptional", bound="UserLikeOptional") + +if TYPE_CHECKING: + from typing import type_check_only + + @type_check_only + class UserLike(Protocol): + first_name: str + last_name: str | None + username: str | None + + @type_check_only + class UserLikeOptional(Protocol): + first_name: str | None + last_name: str | None + username: str | None + + +@overload +def get_name(userlike: TeleUserLike) -> str: ... +@overload +def get_name(userlike: TeleUserLikeOptional) -> str | None: ... + + +def get_name(userlike: TeleUserLike | TeleUserLikeOptional) -> str | None: + """Returns ``username`` prefixed with "@". If ``username`` is not available, calls + :func:`get_full_name` below`. + """ + if userlike.username: + return f"@{userlike.username}" + return get_full_name(userlike=userlike) + + +@overload +def get_full_name(userlike: TeleUserLike) -> str: ... +@overload +def get_full_name(userlike: TeleUserLikeOptional) -> str | None: ... + + +def get_full_name(userlike: TeleUserLike | TeleUserLikeOptional) -> str | None: + """ + If parameter ``first_name`` is not :obj:`None`, gives + ``first_name`` followed by (if available) `UserLike.last_name`. Otherwise, + :obj:`None` is returned. + """ + if not userlike.first_name: + return None + if userlike.last_name: + return f"{userlike.first_name} {userlike.last_name}" + return userlike.first_name + + +# We isolate these TypeVars to accomodiate telegram objects with ``username`` +# and no ``first_name`` or ``last_name`` (e.g ``ChatShared``) +TeleLinkable = TypeVar("TeleLinkable", bound="Linkable") +TeleLinkableOptional = TypeVar("TeleLinkableOptional", bound="LinkableOptional") + +if TYPE_CHECKING: + + @type_check_only + class Linkable(Protocol): + username: str + + @type_check_only + class LinkableOptional(Protocol): + username: str | None + + +@overload +def get_link(linkable: TeleLinkable) -> str: ... +@overload +def get_link(linkable: TeleLinkableOptional) -> str | None: ... + + +def get_link(linkable: TeleLinkable | TeleLinkableOptional) -> str | None: + """If ``username`` is available, returns a t.me link of the user/chat.""" + if linkable.username: + return f"https://t.me/{linkable.username}" + return None diff --git a/telegram/_utils/warnings.py b/src/telegram/_utils/warnings.py similarity index 78% rename from telegram/_utils/warnings.py rename to src/telegram/_utils/warnings.py index 4dea5c079b7..d8f8a476014 100644 --- a/telegram/_utils/warnings.py +++ b/src/telegram/_utils/warnings.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,21 +25,30 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ -import warnings # skipcq: PYL-R0201 -from typing import Type + +import warnings from telegram.warnings import PTBUserWarning -def warn(message: str, category: Type[Warning] = PTBUserWarning, stacklevel: int = 0) -> None: +def warn( + message: str | PTBUserWarning, + category: type[Warning] = PTBUserWarning, + stacklevel: int = 0, +) -> None: """ Helper function used as a shortcut for warning with default values. .. versionadded:: 20.0 Args: - message (:obj:`str`): Specify the warnings message to pass to ``warnings.warn()``. - category (:obj:`Type[Warning]`, optional): Specify the Warning class to pass to + message (:obj:`str` | :obj:`PTBUserWarning`): Specify the warnings message to pass to + ``warnings.warn()``. + + .. versionchanged:: 21.2 + Now also accepts a :obj:`PTBUserWarning` instance. + + category (:obj:`type[Warning]`, optional): Specify the Warning class to pass to ``warnings.warn()``. Defaults to :class:`telegram.warnings.PTBUserWarning`. stacklevel (:obj:`int`, optional): Specify the stacklevel to pass to ``warnings.warn()``. Pass the same value as you'd pass directly to ``warnings.warn()``. Defaults to ``0``. diff --git a/telegram/_utils/warnings_transition.py b/src/telegram/_utils/warnings_transition.py similarity index 62% rename from telegram/_utils/warnings_transition.py rename to src/telegram/_utils/warnings_transition.py index 2e3b0ebad36..24475848752 100644 --- a/telegram/_utils/warnings_transition.py +++ b/src/telegram/_utils/warnings_transition.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,10 +23,29 @@ .. versionadded:: 20.2 """ -from typing import Any, Callable, Type + +from collections.abc import Callable +from typing import Any from telegram._utils.warnings import warn -from telegram.warnings import PTBDeprecationWarning +from telegram.warnings import PTBDeprecationWarning, PTBUserWarning + + +def build_deprecation_warning_message( + deprecated_name: str, + new_name: str, + object_type: str, + bot_api_version: str, +) -> str: + """Builds a warning message for the transition in API when an object is renamed/replaced. + + Returns a warning message that can be used in `warn` function. + """ + return ( + f"The {object_type} '{deprecated_name}' was replaced by '{new_name}' in Bot API " + f"{bot_api_version}. We recommend using '{new_name}' instead of " + f"'{deprecated_name}'." + ) # Narrower type hints will cause linting errors and/or circular imports. @@ -37,8 +56,9 @@ def warn_about_deprecated_arg_return_new_arg( deprecated_arg_name: str, new_arg_name: str, bot_api_version: str, + ptb_version: str, stacklevel: int = 2, - warn_callback: Callable[[str, Type[Warning], int], None] = warn, + warn_callback: Callable[[str | PTBUserWarning, type[Warning], int], None] = warn, ) -> Any: """A helper function for the transition in API when argument is renamed. @@ -50,19 +70,25 @@ def warn_about_deprecated_arg_return_new_arg( different. """ if deprecated_arg and new_arg and deprecated_arg != new_arg: + base_message = build_deprecation_warning_message( + deprecated_name=deprecated_arg_name, + new_name=new_arg_name, + object_type="parameter", + bot_api_version=bot_api_version, + ) raise ValueError( f"You passed different entities as '{deprecated_arg_name}' and '{new_arg_name}'. " - f"The parameter '{deprecated_arg_name}' was renamed to '{new_arg_name}' in Bot API " - f"{bot_api_version}. We recommend using '{new_arg_name}' instead of " - f"'{deprecated_arg_name}'." + f"{base_message}" ) if deprecated_arg: warn_callback( - f"Bot API {bot_api_version} renamed the argument '{deprecated_arg_name}' to " - f"'{new_arg_name}'.", - PTBDeprecationWarning, - stacklevel + 1, + PTBDeprecationWarning( + ptb_version, + f"Bot API {bot_api_version} renamed the argument '{deprecated_arg_name}' to " + f"'{new_arg_name}'.", + ), + stacklevel=stacklevel + 1, # type: ignore[call-arg] ) return deprecated_arg @@ -73,6 +99,7 @@ def warn_about_deprecated_attr_in_property( deprecated_attr_name: str, new_attr_name: str, bot_api_version: str, + ptb_version: str, stacklevel: int = 2, ) -> None: """A helper function for the transition in API when attribute is renamed. Call from properties. @@ -80,28 +107,10 @@ def warn_about_deprecated_attr_in_property( The properties replace deprecated attributes in classes and issue these deprecation warnings. """ warn( - f"Bot API {bot_api_version} renamed the attribute '{deprecated_attr_name}' to " - f"'{new_attr_name}'.", - PTBDeprecationWarning, - stacklevel=stacklevel + 1, - ) - - -def warn_about_thumb_return_thumbnail( - deprecated_arg: Any, - new_arg: Any, - stacklevel: int = 2, - warn_callback: Callable[[str, Type[Warning], int], None] = warn, -) -> Any: - """A helper function to warn about using a deprecated 'thumb' argument and return it or the - new 'thumbnail' argument, introduced in API 6.6. - """ - return warn_about_deprecated_arg_return_new_arg( - deprecated_arg=deprecated_arg, - new_arg=new_arg, - warn_callback=warn_callback, - deprecated_arg_name="thumb", - new_arg_name="thumbnail", - bot_api_version="6.6", + PTBDeprecationWarning( + ptb_version, + f"Bot API {bot_api_version} renamed the attribute '{deprecated_attr_name}' to " + f"'{new_attr_name}'.", + ), stacklevel=stacklevel + 1, ) diff --git a/telegram/_version.py b/src/telegram/_version.py similarity index 71% rename from telegram/_version.py rename to src/telegram/_version.py index 078b8e4cde4..8568c717809 100644 --- a/telegram/_version.py +++ b/src/telegram/_version.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,9 +17,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/]. # pylint: disable=missing-module-docstring -from typing import NamedTuple +from typing import Final, NamedTuple -__all__ = ("__version__", "__version_info__", "__bot_api_version__", "__bot_api_version_info__") +__all__ = ("__version__", "__version_info__") class Version(NamedTuple): @@ -50,14 +50,7 @@ def __str__(self) -> str: return version -__version_info__ = Version(major=20, minor=3, micro=0, releaselevel="final", serial=0) -__version__ = 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__ = constants.BOT_API_VERSION -__bot_api_version_info__ = constants.BOT_API_VERSION_INFO +__version_info__: Final[Version] = Version( + major=22, minor=6, micro=0, releaselevel="final", serial=0 +) +__version__: Final[str] = str(__version_info__) diff --git a/telegram/_videochat.py b/src/telegram/_videochat.py similarity index 71% rename from telegram/_videochat.py rename to src/telegram/_videochat.py index ae854ae9103..b7f75dfc590 100644 --- a/telegram/_videochat.py +++ b/src/telegram/_videochat.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,14 +17,20 @@ # 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 related to Telegram video chats.""" + import datetime as dtm -from typing import TYPE_CHECKING, Optional, Sequence, Tuple +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._user import User -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 +from telegram._utils.argumentparsing import parse_sequence_arg, to_timedelta +from telegram._utils.datetime import ( + extract_tzinfo_from_defaults, + from_timestamp, + get_timedelta_value, +) +from telegram._utils.types import JSONDict, TimePeriod if TYPE_CHECKING: from telegram import Bot @@ -42,7 +48,7 @@ class VideoChatStarted(TelegramObject): __slots__ = () - def __init__(self, *, api_kwargs: Optional[JSONDict] = None) -> None: + def __init__(self, *, api_kwargs: JSONDict | None = None) -> None: super().__init__(api_kwargs=api_kwargs) self._freeze() @@ -61,28 +67,45 @@ class VideoChatEnded(TelegramObject): .. versionchanged:: 20.0 This class was renamed from ``VoiceChatEnded`` in accordance to Bot API 6.0. + .. versionchanged:: v22.2 + As part of the migration to representing time periods using ``datetime.timedelta``, + equality comparison now considers integer durations and equivalent timedeltas as equal. + Args: - duration (:obj:`int`): Voice chat duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`): Voice chat duration + in seconds. + + .. versionchanged:: v22.2 + |time-period-input| Attributes: - duration (:obj:`int`): Voice chat duration in seconds. + duration (:obj:`int` | :class:`datetime.timedelta`): Voice chat duration in seconds. + + .. deprecated:: v22.2 + |time-period-int-deprecated| """ - __slots__ = ("duration",) + __slots__ = ("_duration",) def __init__( self, - duration: int, + duration: TimePeriod, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(api_kwargs=api_kwargs) - self.duration: int = duration - self._id_attrs = (self.duration,) + self._duration: dtm.timedelta = to_timedelta(duration) + self._id_attrs = (self._duration,) self._freeze() + @property + def duration(self) -> int | dtm.timedelta: + return get_timedelta_value( # type: ignore[return-value] + self._duration, attribute="duration" + ) + class VideoChatParticipantsInvited(TelegramObject): """ @@ -102,7 +125,7 @@ class VideoChatParticipantsInvited(TelegramObject): |sequenceclassargs| Attributes: - users (Tuple[:class:`telegram.User`]): New members that were invited to the video chat. + users (tuple[:class:`telegram.User`]): New members that were invited to the video chat. .. versionchanged:: 20.0 |tupleclassattrs| @@ -115,24 +138,19 @@ def __init__( self, users: Sequence[User], *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(api_kwargs=api_kwargs) - self.users: Tuple[User, ...] = parse_sequence_arg(users) + self.users: tuple[User, ...] = parse_sequence_arg(users) self._id_attrs = (self.users,) self._freeze() @classmethod - def de_json( - cls, data: Optional[JSONDict], bot: "Bot" - ) -> Optional["VideoChatParticipantsInvited"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "VideoChatParticipantsInvited": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) - if not data: - return None - data["users"] = User.de_list(data.get("users", []), bot) return super().de_json(data=data, bot=bot) @@ -167,7 +185,7 @@ def __init__( self, start_date: dtm.datetime, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> None: super().__init__(api_kwargs=api_kwargs) self.start_date: dtm.datetime = start_date @@ -177,16 +195,13 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["VideoChatScheduled"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "VideoChatScheduled": """See :meth:`telegram.TelegramObject.de_json`.""" 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["start_date"] = from_timestamp(data["start_date"], tzinfo=loc_tzinfo) + data["start_date"] = from_timestamp(data.get("start_date"), tzinfo=loc_tzinfo) return super().de_json(data=data, bot=bot) diff --git a/telegram/_webappdata.py b/src/telegram/_webappdata.py similarity index 94% rename from telegram/_webappdata.py rename to src/telegram/_webappdata.py index 2922d2e23b0..04583cf99f5 100644 --- a/telegram/_webappdata.py +++ b/src/telegram/_webappdata.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram WebAppData.""" -from typing import Optional - from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -51,9 +49,9 @@ class WebAppData(TelegramObject): Be aware that a bad client can send arbitrary data in this field. """ - __slots__ = ("data", "button_text") + __slots__ = ("button_text", "data") - def __init__(self, data: str, button_text: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, data: str, button_text: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) # Required self.data: str = data diff --git a/telegram/_webappinfo.py b/src/telegram/_webappinfo.py similarity index 92% rename from telegram/_webappinfo.py rename to src/telegram/_webappinfo.py index 4c83af1c272..2d29cef7df5 100644 --- a/telegram/_webappinfo.py +++ b/src/telegram/_webappinfo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,8 +18,6 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Telegram Web App Info.""" -from typing import Optional - from telegram._telegramobject import TelegramObject from telegram._utils.types import JSONDict @@ -39,17 +37,17 @@ class WebAppInfo(TelegramObject): Args: url (:obj:`str`): An HTTPS URL of a Web App to be opened with additional data as specified in `Initializing Web Apps \ - `_. + `_. Attributes: url (:obj:`str`): An HTTPS URL of a Web App to be opened with additional data as specified in `Initializing Web Apps \ - `_. + `_. """ __slots__ = ("url",) - def __init__(self, url: str, *, api_kwargs: Optional[JSONDict] = None): + def __init__(self, url: str, *, api_kwargs: JSONDict | None = None): super().__init__(api_kwargs=api_kwargs) # Required self.url: str = url diff --git a/telegram/_webhookinfo.py b/src/telegram/_webhookinfo.py similarity index 84% rename from telegram/_webhookinfo.py rename to src/telegram/_webhookinfo.py index 5e94b8c8e8c..94f6f7bf22e 100644 --- a/telegram/_webhookinfo.py +++ b/src/telegram/_webhookinfo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,10 @@ # 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 WebhookInfo.""" -from typing import TYPE_CHECKING, Optional, Sequence, Tuple + +import datetime as dtm +from collections.abc import Sequence +from typing import TYPE_CHECKING from telegram._telegramobject import TelegramObject from telegram._utils.argumentparsing import parse_sequence_arg @@ -58,7 +61,7 @@ class WebhookInfo(TelegramObject): most recent error that happened when trying to deliver an update via webhook. max_connections (:obj:`int`, optional): Maximum allowed number of simultaneous HTTPS connections to the webhook for update delivery. - allowed_updates (Sequence[:obj:`str`], optional): A list of update types the bot is + allowed_updates (Sequence[:obj:`str`], optional): A sequence of update types the bot is subscribed to. Defaults to all update types, except :attr:`telegram.Update.chat_member`. @@ -88,7 +91,7 @@ class WebhookInfo(TelegramObject): most recent error that happened when trying to deliver an update via webhook. max_connections (:obj:`int`): Optional. Maximum allowed number of simultaneous HTTPS connections to the webhook for update delivery. - allowed_updates (Tuple[:obj:`str`]): Optional. A list of update types the bot is + allowed_updates (tuple[:obj:`str`]): Optional. A tuple of update types the bot is subscribed to. Defaults to all update types, except :attr:`telegram.Update.chat_member`. @@ -108,14 +111,14 @@ class WebhookInfo(TelegramObject): __slots__ = ( "allowed_updates", - "url", - "max_connections", - "last_error_date", + "has_custom_certificate", "ip_address", + "last_error_date", "last_error_message", - "pending_update_count", - "has_custom_certificate", "last_synchronization_error_date", + "max_connections", + "pending_update_count", + "url", ) def __init__( @@ -123,14 +126,14 @@ def __init__( url: str, has_custom_certificate: bool, pending_update_count: int, - last_error_date: Optional[int] = None, - last_error_message: Optional[str] = None, - max_connections: Optional[int] = None, - allowed_updates: Optional[Sequence[str]] = None, - ip_address: Optional[str] = None, - last_synchronization_error_date: Optional[int] = None, + last_error_date: dtm.datetime | None = None, + last_error_message: str | None = None, + max_connections: int | None = None, + allowed_updates: Sequence[str] | None = None, + ip_address: str | None = None, + last_synchronization_error_date: dtm.datetime | None = None, *, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ): super().__init__(api_kwargs=api_kwargs) # Required @@ -139,12 +142,12 @@ def __init__( self.pending_update_count: int = pending_update_count # Optional - self.ip_address: Optional[str] = ip_address - self.last_error_date: Optional[int] = last_error_date - self.last_error_message: Optional[str] = last_error_message - self.max_connections: Optional[int] = max_connections - self.allowed_updates: Tuple[str, ...] = parse_sequence_arg(allowed_updates) - self.last_synchronization_error_date: Optional[int] = last_synchronization_error_date + self.ip_address: str | None = ip_address + self.last_error_date: dtm.datetime | None = last_error_date + self.last_error_message: str | None = last_error_message + self.max_connections: int | None = max_connections + self.allowed_updates: tuple[str, ...] = parse_sequence_arg(allowed_updates) + self.last_synchronization_error_date: dtm.datetime | None = last_synchronization_error_date self._id_attrs = ( self.url, @@ -161,13 +164,10 @@ def __init__( self._freeze() @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["WebhookInfo"]: + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "WebhookInfo": """See :meth:`telegram.TelegramObject.de_json`.""" 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) diff --git a/src/telegram/_writeaccessallowed.py b/src/telegram/_writeaccessallowed.py new file mode 100644 index 00000000000..c0ff1322fc4 --- /dev/null +++ b/src/telegram/_writeaccessallowed.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 related to the write access allowed service message.""" + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class WriteAccessAllowed(TelegramObject): + """ + This object represents a service message about a user allowing a bot to write messages after + adding it to the attachment menu, launching a Web App from a link, or accepting an explicit + request from a Web App sent by the method + `requestWriteAccess `_. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`web_app_name` is equal. + + .. versionadded:: 20.0 + .. versionchanged:: 20.6 + Added custom equality comparison for objects of this class. + + Args: + web_app_name (:obj:`str`, optional): Name of the Web App, if the access was granted when + the Web App was launched from a link. + + .. versionadded:: 20.3 + from_request (:obj:`bool`, optional): :obj:`True`, if the access was granted after the user + accepted an explicit request from a Web App sent by the method + `requestWriteAccess `_. + + .. versionadded:: 20.6 + from_attachment_menu (:obj:`bool`, optional): :obj:`True`, if the access was granted when + the bot was added to the attachment or side menu. + + .. versionadded:: 20.6 + + Attributes: + web_app_name (:obj:`str`): Optional. Name of the Web App, if the access was granted when + the Web App was launched from a link. + + .. versionadded:: 20.3 + from_request (:obj:`bool`): Optional. :obj:`True`, if the access was granted after the user + accepted an explicit request from a Web App. + + .. versionadded:: 20.6 + from_attachment_menu (:obj:`bool`): Optional. :obj:`True`, if the access was granted when + the bot was added to the attachment or side menu. + + .. versionadded:: 20.6 + + """ + + __slots__ = ("from_attachment_menu", "from_request", "web_app_name") + + def __init__( + self, + web_app_name: str | None = None, + from_request: bool | None = None, + from_attachment_menu: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.web_app_name: str | None = web_app_name + self.from_request: bool | None = from_request + self.from_attachment_menu: bool | None = from_attachment_menu + + self._id_attrs = (self.web_app_name,) + + self._freeze() diff --git a/src/telegram/constants.py b/src/telegram/constants.py new file mode 100644 index 00000000000..82854ef597d --- /dev/null +++ b/src/telegram/constants.py @@ -0,0 +1,3866 @@ +# python-telegram-bot - a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# by the python-telegram-bot contributors +# +# 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 several constants that are relevant for working with the Bot API. + +Unless noted otherwise, all constants in this module were extracted from the +`Telegram Bots FAQ `_ and +`Telegram Bots API `_. + +Most of the following constants are related to specific classes or topics and are grouped into +enums. If they are related to a specific class, then they are also available as attributes of +those classes. + +.. versionchanged:: 20.0 + + * Most of the constants in this module are grouped into enums. + +.. versionremoved:: 22.3 + Removed deprecated class ``StarTransactions``. Please instead use + :attr:`telegram.constants.Nanostar.VALUE`. +""" +# TODO: Remove this when https://github.com/PyCQA/pylint/issues/6887 is resolved. +# pylint: disable=invalid-enum-extension,invalid-slots + +__all__ = [ + "BOT_API_VERSION", + "BOT_API_VERSION_INFO", + "SUPPORTED_WEBHOOK_PORTS", + "ZERO_DATE", + "AccentColor", + "BackgroundFillLimit", + "BackgroundFillType", + "BackgroundTypeLimit", + "BackgroundTypeType", + "BotCommandLimit", + "BotCommandScopeType", + "BotDescriptionLimit", + "BotNameLimit", + "BulkRequestLimit", + "BusinessLimit", + "CallbackQueryLimit", + "ChatAction", + "ChatBoostSources", + "ChatID", + "ChatInviteLinkLimit", + "ChatLimit", + "ChatMemberStatus", + "ChatPhotoSize", + "ChatSubscriptionLimit", + "ChatType", + "ContactLimit", + "CustomEmojiStickerLimit", + "DiceEmoji", + "DiceLimit", + "FileSizeLimit", + "FloodLimit", + "ForumIconColor", + "ForumTopicLimit", + "GiftLimit", + "GiveawayLimit", + "InlineKeyboardButtonLimit", + "InlineKeyboardMarkupLimit", + "InlineQueryLimit", + "InlineQueryResultLimit", + "InlineQueryResultType", + "InlineQueryResultsButtonLimit", + "InputChecklistLimit", + "InputMediaType", + "InputPaidMediaType", + "InputProfilePhotoType", + "InputStoryContentLimit", + "InputStoryContentType", + "InvoiceLimit", + "KeyboardButtonRequestUsersLimit", + "LocationLimit", + "MaskPosition", + "MediaGroupLimit", + "MenuButtonType", + "MessageAttachmentType", + "MessageEntityType", + "MessageLimit", + "MessageOriginType", + "MessageType", + "Nanostar", + "NanostarLimit", + "OwnedGiftType", + "PaidMediaType", + "ParseMode", + "PollLimit", + "PollType", + "PollingLimit", + "PremiumSubscription", + "ProfileAccentColor", + "ReactionEmoji", + "ReactionType", + "ReplyLimit", + "RevenueWithdrawalStateType", + "StarTransactionsLimit", + "StickerFormat", + "StickerLimit", + "StickerSetLimit", + "StickerType", + "StoryAreaPositionLimit", + "StoryAreaTypeLimit", + "StoryAreaTypeType", + "StoryLimit", + "SuggestedPost", + "SuggestedPostInfoState", + "SuggestedPostRefunded", + "TransactionPartnerType", + "TransactionPartnerUser", + "UniqueGiftInfoOrigin", + "UpdateType", + "UserProfilePhotosLimit", + "VerifyLimit", + "WebhookLimit", +] + +import datetime as dtm +import sys +from enum import Enum +from typing import Final, NamedTuple + +from telegram._utils.datetime import UTC +from telegram._utils.enum import FloatEnum, IntEnum, StringEnum + + +class _BotAPIVersion(NamedTuple): + """Similar behavior to sys.version_info. + So far TG has only published X.Y releases. We can add X.Y.Z(a(S)) if needed. + """ + + major: int + minor: int + + def __repr__(self) -> str: + """Unfortunately calling super().__repr__ doesn't work with typing.NamedTuple, so we + do this manually. + """ + return f"BotAPIVersion(major={self.major}, minor={self.minor})" + + def __str__(self) -> str: + return f"{self.major}.{self.minor}" + + +class _AccentColor(NamedTuple): + """A helper class for (profile) accent colors. Since TG doesn't define a class for this and + the behavior is quite different for the different accent colors, we don't make this a public + class. This gives us more flexibility to change the implementation if necessary for future + versions. + """ + + identifier: int + name: str | None = None + light_colors: tuple[int, ...] = () + dark_colors: tuple[int, ...] = () + + +#: :class:`typing.NamedTuple`: A tuple containing the two components of the version number: +# ``major`` and ``minor``. Both values are integers. +#: The components can also be accessed by name, so ``BOT_API_VERSION_INFO[0]`` is equivalent +#: to ``BOT_API_VERSION_INFO.major`` and so on. Also available as +#: :data:`telegram.__bot_api_version_info__`. +#: +#: .. versionadded:: 20.0 +BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=9, minor=3) +#: :obj:`str`: Telegram Bot API +#: version supported by this version of `python-telegram-bot`. Also available as +#: :data:`telegram.__bot_api_version__`. +#: +#: .. versionadded:: 13.4 +BOT_API_VERSION: Final[str] = str(BOT_API_VERSION_INFO) + +# constants above this line are tested + +#: list[:obj:`int`]: Ports supported by +#: :paramref:`telegram.Bot.set_webhook.url`. +SUPPORTED_WEBHOOK_PORTS: Final[list[int]] = [443, 80, 88, 8443] + +#: :obj:`datetime.datetime`, value of unix 0. +#: This date literal is used in :class:`telegram.InaccessibleMessage` +# and :class:`telegram.ChecklistTask`. +#: +#: .. versionadded:: 20.8 +ZERO_DATE: Final[dtm.datetime] = dtm.datetime(1970, 1, 1, tzinfo=UTC) + + +class AccentColor(Enum): + """This enum contains the available accent colors for + :class:`telegram.ChatFullInfo.accent_color_id`. + The members of this enum are named tuples with the following attributes: + + - ``identifier`` (:obj:`int`): The identifier of the accent color. + - ``name`` (:obj:`str`): Optional. The name of the accent color. + - ``light_colors`` (tuple[:obj:`str`]): Optional. The light colors of the accent color as HEX + value. + - ``dark_colors`` (tuple[:obj:`str`]): Optional. The dark colors of the accent color as HEX + value. + + Since Telegram gives no exact specification for the accent colors, future accent colors might + have a different data type. + + .. versionadded:: 20.8 + """ + + __slots__ = () + + COLOR_000 = _AccentColor(identifier=0, name="red") + """Accent color 0. This color can be customized by app themes.""" + COLOR_001 = _AccentColor(identifier=1, name="orange") + """Accent color 1. This color can be customized by app themes.""" + COLOR_002 = _AccentColor(identifier=2, name="purple/violet") + """Accent color 2. This color can be customized by app themes.""" + COLOR_003 = _AccentColor(identifier=3, name="green") + """Accent color 3. This color can be customized by app themes.""" + COLOR_004 = _AccentColor(identifier=4, name="cyan") + """Accent color 4. This color can be customized by app themes.""" + COLOR_005 = _AccentColor(identifier=5, name="blue") + """Accent color 5. This color can be customized by app themes.""" + COLOR_006 = _AccentColor(identifier=6, name="pink") + """Accent color 6. This color can be customized by app themes.""" + COLOR_007 = _AccentColor( + identifier=7, light_colors=(0xE15052, 0xF9AE63), dark_colors=(0xFF9380, 0x992F37) + ) + """Accent color 7. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_008 = _AccentColor( + identifier=8, light_colors=(0xE0802B, 0xFAC534), dark_colors=(0xECB04E, 0xC35714) + ) + """Accent color 8. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_009 = _AccentColor( + identifier=9, light_colors=(0xA05FF3, 0xF48FFF), dark_colors=(0xC697FF, 0x5E31C8) + ) + """Accent color 9. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_010 = _AccentColor( + identifier=10, light_colors=(0x27A910, 0xA7DC57), dark_colors=(0xA7EB6E, 0x167E2D) + ) + """Accent color 10. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_011 = _AccentColor( + identifier=11, light_colors=(0x27ACCE, 0x82E8D6), dark_colors=(0x40D8D0, 0x045C7F) + ) + """Accent color 11. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + + COLOR_012 = _AccentColor( + identifier=12, light_colors=(0x3391D4, 0x7DD3F0), dark_colors=(0x52BFFF, 0x0B5494) + ) + """Accent color 12. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_013 = _AccentColor( + identifier=13, light_colors=(0xDD4371, 0xFFBE9F), dark_colors=(0xFF86A6, 0x8E366E) + ) + """Accent color 13. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_014 = _AccentColor( + identifier=14, + light_colors=(0x247BED, 0xF04856, 0xFFFFFF), + dark_colors=(0x3FA2FE, 0xE5424F, 0xFFFFFF), + ) + """Accent color 14. This contains three light colors + + .. raw:: html + +
+
+
+
+
+

+ + and three dark colors + + .. raw:: html + +
+
+
+
+
+

+ """ + + COLOR_015 = _AccentColor( + identifier=15, + light_colors=(0xD67722, 0x1EA011, 0xFFFFFF), + dark_colors=(0xFF905E, 0x32A527, 0xFFFFFF), + ) + """Accent color 15. This contains three light colors + + .. raw:: html + +
+
+
+
+
+

+ + and three dark colors + + .. raw:: html + +
+
+
+
+
+

+ """ + + COLOR_016 = _AccentColor( + identifier=16, + light_colors=(0x179E42, 0xE84A3F, 0xFFFFFF), + dark_colors=(0x66D364, 0xD5444F, 0xFFFFFF), + ) + """Accent color 16. This contains three light colors + + .. raw:: html + +
+
+
+
+
+

+ + and three dark colors + + .. raw:: html + +
+
+
+
+
+

+ """ + + COLOR_017 = _AccentColor( + identifier=17, + light_colors=(0x2894AF, 0x6FC456, 0xFFFFFF), + dark_colors=(0x22BCE2, 0x3DA240, 0xFFFFFF), + ) + """Accent color 17. This contains three light colors + + .. raw:: html + +
+
+
+
+
+

+ + and three dark colors + + .. raw:: html + +
+
+
+
+
+

+ """ + + COLOR_018 = _AccentColor( + identifier=18, + light_colors=(0x0C9AB3, 0xFFAD95, 0xFFE6B5), + dark_colors=(0x22BCE2, 0xFF9778, 0xFFDA6B), + ) + """Accent color 18. This contains three light colors + + .. raw:: html + +
+
+
+
+
+

+ + and three dark colors + + .. raw:: html + +
+
+
+
+
+

+ """ + + COLOR_019 = _AccentColor( + identifier=19, + light_colors=(0x7757D6, 0xF79610, 0xFFDE8E), + dark_colors=(0x9791FF, 0xF2731D, 0xFFDB59), + ) + """Accent color 19. This contains three light colors + + .. raw:: html + +
+
+
+
+
+

+ + and three dark colors + + .. raw:: html + +
+
+
+
+
+

+ """ + + COLOR_020 = _AccentColor( + identifier=20, + light_colors=(0x1585CF, 0xF2AB1D, 0xFFFFFF), + dark_colors=(0x3DA6EB, 0xEEA51D, 0xFFFFFF), + ) + """Accent color 20. This contains three light colors + + .. raw:: html + +
+
+
+
+
+

+ + and three dark colors + + .. raw:: html + +
+
+
+
+
+

+ """ + + +class BackgroundTypeType(StringEnum): + """This enum contains the available types of :class:`telegram.BackgroundType`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 21.2 + """ + + __slots__ = () + + FILL = "fill" + """:obj:`str`: A :class:`telegram.BackgroundType` with fill background.""" + WALLPAPER = "wallpaper" + """:obj:`str`: A :class:`telegram.BackgroundType` with wallpaper background.""" + PATTERN = "pattern" + """:obj:`str`: A :class:`telegram.BackgroundType` with pattern background.""" + CHAT_THEME = "chat_theme" + """:obj:`str`: A :class:`telegram.BackgroundType` with chat_theme background.""" + + +class BackgroundFillType(StringEnum): + """This enum contains the available types of :class:`telegram.BackgroundFill`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 21.2 + """ + + __slots__ = () + + SOLID = "solid" + """:obj:`str`: A :class:`telegram.BackgroundFill` with solid fill.""" + GRADIENT = "gradient" + """:obj:`str`: A :class:`telegram.BackgroundFill` with gradient fill.""" + FREEFORM_GRADIENT = "freeform_gradient" + """:obj:`str`: A :class:`telegram.BackgroundFill` with freeform_gradient fill.""" + + +class BotCommandLimit(IntEnum): + """This enum contains limitations for :class:`telegram.BotCommand` and + :meth:`telegram.Bot.set_my_commands`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_COMMAND = 1 + """:obj:`int`: Minimum value allowed for :paramref:`~telegram.BotCommand.command` parameter of + :class:`telegram.BotCommand`. + """ + MAX_COMMAND = 32 + """:obj:`int`: Maximum value allowed for :paramref:`~telegram.BotCommand.command` parameter of + :class:`telegram.BotCommand`. + """ + MIN_DESCRIPTION = 1 + """:obj:`int`: Minimum value allowed for :paramref:`~telegram.BotCommand.description` + parameter of :class:`telegram.BotCommand`. + """ + MAX_DESCRIPTION = 256 + """:obj:`int`: Maximum value allowed for :paramref:`~telegram.BotCommand.description` + parameter of :class:`telegram.BotCommand`. + """ + MAX_COMMAND_NUMBER = 100 + """:obj:`int`: Maximum number of bot commands passed in a :obj:`list` to the + :paramref:`~telegram.Bot.set_my_commands.commands` + parameter of :meth:`telegram.Bot.set_my_commands`. + """ + + +class BotCommandScopeType(StringEnum): + """This enum contains the available types of :class:`telegram.BotCommandScope`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + DEFAULT = "default" + """:obj:`str`: The type of :class:`telegram.BotCommandScopeDefault`.""" + ALL_PRIVATE_CHATS = "all_private_chats" + """:obj:`str`: The type of :class:`telegram.BotCommandScopeAllPrivateChats`.""" + ALL_GROUP_CHATS = "all_group_chats" + """:obj:`str`: The type of :class:`telegram.BotCommandScopeAllGroupChats`.""" + ALL_CHAT_ADMINISTRATORS = "all_chat_administrators" + """:obj:`str`: The type of :class:`telegram.BotCommandScopeAllChatAdministrators`.""" + CHAT = "chat" + """:obj:`str`: The type of :class:`telegram.BotCommandScopeChat`.""" + CHAT_ADMINISTRATORS = "chat_administrators" + """:obj:`str`: The type of :class:`telegram.BotCommandScopeChatAdministrators`.""" + CHAT_MEMBER = "chat_member" + """:obj:`str`: The type of :class:`telegram.BotCommandScopeChatMember`.""" + + +class BotDescriptionLimit(IntEnum): + """This enum contains limitations for the methods :meth:`telegram.Bot.set_my_description` and + :meth:`telegram.Bot.set_my_short_description`. The enum members of this enumeration are + instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.2 + """ + + __slots__ = () + + MAX_DESCRIPTION_LENGTH = 512 + """:obj:`int`: Maximum length for the parameter + :paramref:`~telegram.Bot.set_my_description.description` of + :meth:`telegram.Bot.set_my_description` + """ + MAX_SHORT_DESCRIPTION_LENGTH = 120 + """:obj:`int`: Maximum length for the parameter + :paramref:`~telegram.Bot.set_my_short_description.short_description` of + :meth:`telegram.Bot.set_my_short_description` + """ + + +class BotNameLimit(IntEnum): + """This enum contains limitations for the methods :meth:`telegram.Bot.set_my_name`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.3 + """ + + __slots__ = () + + MAX_NAME_LENGTH = 64 + """:obj:`int`: Maximum length for the parameter :paramref:`~telegram.Bot.set_my_name.name` of + :meth:`telegram.Bot.set_my_name` + """ + + +class BulkRequestLimit(IntEnum): + """This enum contains limitations for :meth:`telegram.Bot.delete_messages`, + :meth:`telegram.Bot.forward_messages` and :meth:`telegram.Bot.copy_messages`. The enum members + of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.8 + """ + + __slots__ = () + + MIN_LIMIT = 1 + """:obj:`int`: Minimum number of messages required for bulk actions.""" + MAX_LIMIT = 100 + """:obj:`int`: Maximum number of messages required for bulk actions.""" + + +class BusinessLimit(IntEnum): + """This enum contains limitations related to handling business accounts. The enum members + of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + CHAT_ACTIVITY_TIMEOUT = int(dtm.timedelta(hours=24).total_seconds()) + """:obj:`int`: Time in seconds in which the chat must have been active for. Relevant for + :paramref:`~telegram.Bot.read_business_message.chat_id` + of :meth:`~telegram.Bot.read_business_message` and + :paramref:`~telegram.Bot.transfer_gift.new_owner_chat_id` + of :meth:`~telegram.Bot.transfer_gift`. + """ + MIN_NAME_LENGTH = 1 + """:obj:`int`: Minimum length of the name of a business account. Relevant only for + :paramref:`~telegram.Bot.set_business_account_name.first_name` of + :meth:`telegram.Bot.set_business_account_name`. + """ + MAX_NAME_LENGTH = 64 + """:obj:`int`: Maximum length of the name of a business account. Relevant for the parameters + of :meth:`telegram.Bot.set_business_account_name`. + """ + MAX_USERNAME_LENGTH = 32 + """::obj:`int`: Maximum length of the username of a business account. Relevant for + :paramref:`~telegram.Bot.set_business_account_username.username` of + :meth:`telegram.Bot.set_business_account_username`. + """ + MAX_BIO_LENGTH = 140 + """:obj:`int`: Maximum length of the bio of a business account. Relevant for + :paramref:`~telegram.Bot.set_business_account_bio.bio` of + :meth:`telegram.Bot.set_business_account_bio`. + """ + MIN_GIFT_RESULTS = 1 + """:obj:`int`: Minimum number of gifts to be returned. Relevant for + + * :paramref:`~telegram.Bot.get_business_account_gifts.limit` of + :meth:`telegram.Bot.get_business_account_gifts`. + * :paramref:`~telegram.Bot.get_chat_gifts.limit` of + :meth:`telegram.Bot.get_chat_gifts`. + * :paramref:`~telegram.Bot.get_user_gifts.limit` of + :meth:`telegram.Bot.get_user_gifts`. + """ + MAX_GIFT_RESULTS = 100 + """:obj:`int`: Maximum number of gifts to be returned. Relevant for + + * :paramref:`~telegram.Bot.get_business_account_gifts.limit` of + :meth:`telegram.Bot.get_business_account_gifts`. + * :paramref:`~telegram.Bot.get_chat_gifts.limit` of + :meth:`telegram.Bot.get_chat_gifts`. + * :paramref:`~telegram.Bot.get_user_gifts.limit` of + :meth:`telegram.Bot.get_user_gifts`. + """ + MIN_STAR_COUNT = 1 + """:obj:`int`: Minimum number of Telegram Stars to be transfered. Relevant for + :paramref:`~telegram.Bot.transfer_business_account_stars.star_count` of + :meth:`telegram.Bot.transfer_business_account_stars`. + """ + MAX_STAR_COUNT = 10000 + """:obj:`int`: Maximum number of Telegram Stars to be transfered. Relevant for + :paramref:`~telegram.Bot.transfer_business_account_stars.star_count` of + :meth:`telegram.Bot.transfer_business_account_stars`. + """ + + +class CallbackQueryLimit(IntEnum): + """This enum contains limitations for :class:`telegram.CallbackQuery`/ + :meth:`telegram.Bot.answer_callback_query`. The enum members of this enumeration are instances + of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + ANSWER_CALLBACK_QUERY_TEXT_LENGTH = 200 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the + :paramref:`~telegram.Bot.answer_callback_query.text` parameter of + :meth:`telegram.Bot.answer_callback_query`.""" + + +class ChatAction(StringEnum): + """This enum contains the available chat actions for :meth:`telegram.Bot.send_chat_action`. + The enum members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + CHOOSE_STICKER = "choose_sticker" + """:obj:`str`: Chat action indicating that the bot is selecting a sticker.""" + FIND_LOCATION = "find_location" + """:obj:`str`: Chat action indicating that the bot is selecting a location.""" + RECORD_VOICE = "record_voice" + """:obj:`str`: Chat action indicating that the bot is recording a voice message.""" + RECORD_VIDEO = "record_video" + """:obj:`str`: Chat action indicating that the bot is recording a video.""" + RECORD_VIDEO_NOTE = "record_video_note" + """:obj:`str`: Chat action indicating that the bot is recording a video note.""" + TYPING = "typing" + """:obj:`str`: A chat indicating the bot is typing.""" + UPLOAD_VOICE = "upload_voice" + """:obj:`str`: Chat action indicating that the bot is uploading a voice message.""" + UPLOAD_DOCUMENT = "upload_document" + """:obj:`str`: Chat action indicating that the bot is uploading a document.""" + UPLOAD_PHOTO = "upload_photo" + """:obj:`str`: Chat action indicating that the bot is uploading a photo.""" + UPLOAD_VIDEO = "upload_video" + """:obj:`str`: Chat action indicating that the bot is uploading a video.""" + UPLOAD_VIDEO_NOTE = "upload_video_note" + """:obj:`str`: Chat action indicating that the bot is uploading a video note.""" + + +class ChatBoostSources(StringEnum): + """This enum contains the available sources for a + :class:`Telegram chat boost `. + The enum members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.8 + """ + + __slots__ = () + + GIFT_CODE = "gift_code" + """:obj:`str`: The source of the chat boost was a Telegram Premium gift code.""" + GIVEAWAY = "giveaway" + """:obj:`str`: The source of the chat boost was a Telegram Premium giveaway.""" + PREMIUM = "premium" + """:obj:`str`: The source of the chat boost was a Telegram Premium subscription/gift.""" + + +class ChatID(IntEnum): + """This enum contains some special chat IDs. The enum + members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + ANONYMOUS_ADMIN = 1087968824 + """:obj:`int`: User ID in groups for messages sent by anonymous admins. Telegram chat: + `@GroupAnonymousBot `_. + + Note: + :attr:`telegram.Message.from_user` will contain this ID for backwards compatibility only. + It's recommended to use :attr:`telegram.Message.sender_chat` instead. + """ + SERVICE_CHAT = 777000 + """:obj:`int`: Telegram service chat, that also acts as sender of channel posts forwarded to + discussion groups. Telegram chat: `Telegram `_. + + Note: + :attr:`telegram.Message.from_user` will contain this ID for backwards compatibility only. + It's recommended to use :attr:`telegram.Message.sender_chat` instead. + """ + FAKE_CHANNEL = 136817688 + """:obj:`int`: User ID in groups when message is sent on behalf of a channel, or when a channel + votes on a poll. Telegram chat: `@Channel_Bot `_. + + Note: + * :attr:`telegram.Message.from_user` will contain this ID for backwards compatibility only. + It's recommended to use :attr:`telegram.Message.sender_chat` instead. + * :attr:`telegram.PollAnswer.user` will contain this ID for backwards compatibility only. + It's recommended to use :attr:`telegram.PollAnswer.voter_chat` instead. + """ + + +class ChatInviteLinkLimit(IntEnum): + """This enum contains limitations for :class:`telegram.ChatInviteLink`/ + :meth:`telegram.Bot.create_chat_invite_link`/:meth:`telegram.Bot.edit_chat_invite_link`. The + enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_MEMBER_LIMIT = 1 + """:obj:`int`: Minimum value allowed for the + :paramref:`~telegram.Bot.create_chat_invite_link.member_limit` parameter of + :meth:`telegram.Bot.create_chat_invite_link` and + :paramref:`~telegram.Bot.edit_chat_invite_link.member_limit` of + :meth:`telegram.Bot.edit_chat_invite_link`. + """ + MAX_MEMBER_LIMIT = 99999 + """:obj:`int`: Maximum value allowed for the + :paramref:`~telegram.Bot.create_chat_invite_link.member_limit` parameter of + :meth:`telegram.Bot.create_chat_invite_link` and + :paramref:`~telegram.Bot.edit_chat_invite_link.member_limit` of + :meth:`telegram.Bot.edit_chat_invite_link`. + """ + NAME_LENGTH = 32 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the + :paramref:`~telegram.Bot.create_chat_invite_link.name` parameter of + :meth:`telegram.Bot.create_chat_invite_link` and + :paramref:`~telegram.Bot.edit_chat_invite_link.name` of + :meth:`telegram.Bot.edit_chat_invite_link`. + """ + + +class ChatLimit(IntEnum): + """This enum contains limitations for + :meth:`telegram.Bot.set_chat_administrator_custom_title`, + :meth:`telegram.Bot.set_chat_description`, and :meth:`telegram.Bot.set_chat_title`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + CHAT_ADMINISTRATOR_CUSTOM_TITLE_LENGTH = 16 + """:obj:`int`: Maximum length of a :obj:`str` passed as the + :paramref:`~telegram.Bot.set_chat_administrator_custom_title.custom_title` parameter of + :meth:`telegram.Bot.set_chat_administrator_custom_title`. + """ + CHAT_DESCRIPTION_LENGTH = 255 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the + :paramref:`~telegram.Bot.set_chat_description.description` parameter of + :meth:`telegram.Bot.set_chat_description`. + """ + MIN_CHAT_TITLE_LENGTH = 1 + """:obj:`int`: Minimum length of a :obj:`str` passed as the + :paramref:`~telegram.Bot.set_chat_title.title` parameter of + :meth:`telegram.Bot.set_chat_title`. + """ + MAX_CHAT_TITLE_LENGTH = 128 + """:obj:`int`: Maximum length of a :obj:`str` passed as the + :paramref:`~telegram.Bot.set_chat_title.title` parameter of + :meth:`telegram.Bot.set_chat_title`. + """ + + +class ChatSubscriptionLimit(IntEnum): + """This enum contains limitations for + :paramref:`telegram.Bot.create_chat_subscription_invite_link.subscription_period` and + :paramref:`telegram.Bot.create_chat_subscription_invite_link.subscription_price`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 21.5 + """ + + __slots__ = () + + SUBSCRIPTION_PERIOD = 2592000 + """:obj:`int`: The number of seconds the subscription will be active.""" + MIN_PRICE = 1 + """:obj:`int`: Amount of stars a user pays, minimum amount the subscription can be set to.""" + MAX_PRICE = 10000 + """:obj:`int`: Amount of stars a user pays, maximum amount the subscription can be set to. + + .. versionchanged:: 22.1 + Bot API 9.0 changed the value to 10000. + """ + + +class BackgroundTypeLimit(IntEnum): + """This enum contains limitations for :class:`telegram.BackgroundTypeFill`, + :class:`telegram.BackgroundTypeWallpaper` and :class:`telegram.BackgroundTypePattern`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 21.2 + """ + + __slots__ = () + + MAX_DIMMING = 100 + """:obj:`int`: Maximum value allowed for: + + * :paramref:`~telegram.BackgroundTypeFill.dark_theme_dimming` parameter of + :class:`telegram.BackgroundTypeFill` + * :paramref:`~telegram.BackgroundTypeWallpaper.dark_theme_dimming` parameter of + :class:`telegram.BackgroundTypeWallpaper` + """ + MAX_INTENSITY = 100 + """:obj:`int`: Maximum value allowed for :paramref:`~telegram.BackgroundTypePattern.intensity` + parameter of :class:`telegram.BackgroundTypePattern` + """ + + +class BackgroundFillLimit(IntEnum): + """This enum contains limitations for :class:`telegram.BackgroundFillGradient`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 21.2 + """ + + __slots__ = () + + MAX_ROTATION_ANGLE = 359 + """:obj:`int`: Maximum value allowed for: + :paramref:`~telegram.BackgroundFillGradient.rotation_angle` parameter of + :class:`telegram.BackgroundFillGradient` + """ + + +class ChatMemberStatus(StringEnum): + """This enum contains the available states for :class:`telegram.ChatMember`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + ADMINISTRATOR = "administrator" + """:obj:`str`: A :class:`telegram.ChatMember` who is administrator of the chat.""" + OWNER = "creator" + """:obj:`str`: A :class:`telegram.ChatMember` who is the owner of the chat.""" + BANNED = "kicked" + """:obj:`str`: A :class:`telegram.ChatMember` who was banned in the chat.""" + LEFT = "left" + """:obj:`str`: A :class:`telegram.ChatMember` who has left the chat.""" + MEMBER = "member" + """:obj:`str`: A :class:`telegram.ChatMember` who is a member of the chat.""" + RESTRICTED = "restricted" + """:obj:`str`: A :class:`telegram.ChatMember` who was restricted in this chat.""" + + +class ChatPhotoSize(IntEnum): + """This enum contains limitations for :class:`telegram.ChatPhoto`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + SMALL = 160 + """:obj:`int`: Width and height of a small chat photo, ID of which is passed in + :paramref:`~telegram.ChatPhoto.small_file_id` and + :paramref:`~telegram.ChatPhoto.small_file_unique_id` parameters of + :class:`telegram.ChatPhoto`. + """ + BIG = 640 + """:obj:`int`: Width and height of a big chat photo, ID of which is passed in + :paramref:`~telegram.ChatPhoto.big_file_id` and + :paramref:`~telegram.ChatPhoto.big_file_unique_id` parameters of + :class:`telegram.ChatPhoto`. + """ + + +class ChatType(StringEnum): + """This enum contains the available types of :class:`telegram.Chat`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + SENDER = "sender" + """:obj:`str`: A :class:`telegram.Chat` that represents the chat of a :class:`telegram.User` + sending an :class:`telegram.InlineQuery`. """ + PRIVATE = "private" + """:obj:`str`: A :class:`telegram.Chat` that is private.""" + GROUP = "group" + """:obj:`str`: A :class:`telegram.Chat` that is a group.""" + SUPERGROUP = "supergroup" + """:obj:`str`: A :class:`telegram.Chat` that is a supergroup.""" + CHANNEL = "channel" + """:obj:`str`: A :class:`telegram.Chat` that is a channel.""" + + +class ContactLimit(IntEnum): + """This enum contains limitations for :class:`telegram.InlineQueryResultContact`, + :class:`telegram.InputContactMessageContent`, and :meth:`telegram.Bot.send_contact`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + VCARD = 2048 + """:obj:`int`: Maximum value allowed for: + + * :paramref:`~telegram.Bot.send_contact.vcard` parameter of :meth:`~telegram.Bot.send_contact` + * :paramref:`~telegram.InlineQueryResultContact.vcard` parameter of + :class:`~telegram.InlineQueryResultContact` + * :paramref:`~telegram.InputContactMessageContent.vcard` parameter of + :class:`~telegram.InputContactMessageContent` + """ + + +class CustomEmojiStickerLimit(IntEnum): + """This enum contains limitations for :meth:`telegram.Bot.get_custom_emoji_stickers`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + CUSTOM_EMOJI_IDENTIFIER_LIMIT = 200 + """:obj:`int`: Maximum amount of custom emoji identifiers which can be specified for the + :paramref:`~telegram.Bot.get_custom_emoji_stickers.custom_emoji_ids` parameter of + :meth:`telegram.Bot.get_custom_emoji_stickers`. + """ + + +class DiceEmoji(StringEnum): + """This enum contains the available emoji for :class:`telegram.Dice`/ + :meth:`telegram.Bot.send_dice`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + DICE = "🎲" + """:obj:`str`: A :class:`telegram.Dice` with the emoji ``🎲``.""" + DARTS = "🎯" + """:obj:`str`: A :class:`telegram.Dice` with the emoji ``🎯``.""" + BASKETBALL = "🏀" + """:obj:`str`: A :class:`telegram.Dice` with the emoji ``🏀``.""" + FOOTBALL = "⚽" + """:obj:`str`: A :class:`telegram.Dice` with the emoji ``⚽``.""" + SLOT_MACHINE = "🎰" + """:obj:`str`: A :class:`telegram.Dice` with the emoji ``🎰``.""" + BOWLING = "🎳" + """:obj:`str`: A :class:`telegram.Dice` with the emoji ``🎳``.""" + + +class DiceLimit(IntEnum): + """This enum contains limitations for :class:`telegram.Dice`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_VALUE = 1 + """:obj:`int`: Minimum value allowed for :paramref:`~telegram.Dice.value` parameter of + :class:`telegram.Dice` (any emoji). + """ + + MAX_VALUE_BASKETBALL = 5 + """:obj:`int`: Maximum value allowed for :paramref:`~telegram.Dice.value` parameter of + :class:`telegram.Dice` if :paramref:`~telegram.Dice.emoji` is + :tg-const:`telegram.constants.DiceEmoji.BASKETBALL`. + """ + MAX_VALUE_BOWLING = 6 + """:obj:`int`: Maximum value allowed for :paramref:`~telegram.Dice.value` parameter of + :class:`telegram.Dice` if :paramref:`~telegram.Dice.emoji` is + :tg-const:`telegram.constants.DiceEmoji.BOWLING`. + """ + MAX_VALUE_DARTS = 6 + """:obj:`int`: Maximum value allowed for :paramref:`~telegram.Dice.value` parameter of + :class:`telegram.Dice` if :paramref:`~telegram.Dice.emoji` is + :tg-const:`telegram.constants.DiceEmoji.DARTS`. + """ + MAX_VALUE_DICE = 6 + """:obj:`int`: Maximum value allowed for :paramref:`~telegram.Dice.value` parameter of + :class:`telegram.Dice` if :paramref:`~telegram.Dice.emoji` is + :tg-const:`telegram.constants.DiceEmoji.DICE`. + """ + MAX_VALUE_FOOTBALL = 5 + """:obj:`int`: Maximum value allowed for :paramref:`~telegram.Dice.value` parameter of + :class:`telegram.Dice` if :paramref:`~telegram.Dice.emoji` is + :tg-const:`telegram.constants.DiceEmoji.FOOTBALL`. + """ + MAX_VALUE_SLOT_MACHINE = 64 + """:obj:`int`: Maximum value allowed for :paramref:`~telegram.Dice.value` parameter of + :class:`telegram.Dice` if :paramref:`~telegram.Dice.emoji` is + :tg-const:`telegram.constants.DiceEmoji.SLOT_MACHINE`. + """ + + +class FileSizeLimit(IntEnum): + """This enum contains limitations regarding the upload and download of files. The enum + members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + FILESIZE_DOWNLOAD = int(20e6) # (20MB) + """:obj:`int`: Bots can download files of up to 20MB in size.""" + FILESIZE_UPLOAD = int(50e6) # (50MB) + """:obj:`int`: Bots can upload non-photo files of up to 50MB in size.""" + FILESIZE_UPLOAD_LOCAL_MODE = int(2e9) # (2000MB) + """:obj:`int`: Bots can upload non-photo files of up to 2000MB in size when using a local bot + API server. + """ + FILESIZE_DOWNLOAD_LOCAL_MODE = sys.maxsize + """:obj:`int`: Bots can download files without a size limit when using a local bot API server. + """ + PHOTOSIZE_UPLOAD = int(10e6) # (10MB) + """:obj:`int`: Bots can upload photo files of up to 10MB in size.""" + VOICE_NOTE_FILE_SIZE = int(1e6) # (1MB) + """:obj:`int`: File size limit for the :meth:`~telegram.Bot.send_voice` method of + :class:`telegram.Bot`. Bots can send :mimetype:`audio/ogg` files of up to 1MB in size as + a voice note. Larger voice notes (up to 20MB) will be sent as files.""" + # It seems OK to link 20MB limit to FILESIZE_DOWNLOAD rather than creating a new constant + + +class FloodLimit(IntEnum): + """This enum contains limitations regarding flood limits. The enum + members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MESSAGES_PER_SECOND_PER_CHAT = 1 + """:obj:`int`: The number of messages that can be sent per second in a particular chat. + Telegram may allow short bursts that go over this limit, but eventually you'll begin + receiving 429 errors. + """ + MESSAGES_PER_SECOND = 30 + """:obj:`int`: The number of messages that can roughly be sent in an interval of 30 seconds + across all chats. + """ + MESSAGES_PER_MINUTE_PER_GROUP = 20 + """:obj:`int`: The number of messages that can roughly be sent to a particular group within one + minute. + """ + PAID_MESSAGES_PER_SECOND = 1000 + """:obj:`int`: The number of messages that can be sent per second when paying with the bot's + Telegram Star balance. See e.g. parameter + :paramref:`~telegram.Bot.send_message.allow_paid_broadcast` of + :meth:`~telegram.Bot.send_message`. + + .. versionadded:: 21.7 + """ + + +class ForumIconColor(IntEnum): + """This enum contains the available colors for use in + :paramref:`telegram.Bot.create_forum_topic.icon_color`. The enum members of this enumeration + are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + BLUE = 0x6FB9F0 + """:obj:`int`: An icon with a color which corresponds to blue (``0x6FB9F0``). + + .. raw:: html + +
+ + """ + YELLOW = 0xFFD67E + """:obj:`int`: An icon with a color which corresponds to yellow (``0xFFD67E``). + + .. raw:: html + +
+ + """ + PURPLE = 0xCB86DB + """:obj:`int`: An icon with a color which corresponds to purple (``0xCB86DB``). + + .. raw:: html + +
+ + """ + GREEN = 0x8EEE98 + """:obj:`int`: An icon with a color which corresponds to green (``0x8EEE98``). + + .. raw:: html + +
+ + """ + PINK = 0xFF93B2 + """:obj:`int`: An icon with a color which corresponds to pink (``0xFF93B2``). + + .. raw:: html + +
+ + """ + RED = 0xFB6F5F + """:obj:`int`: An icon with a color which corresponds to red (``0xFB6F5F``). + + .. raw:: html + +
+ + """ + + +class GiftLimit(IntEnum): + """This enum contains limitations for :meth:`~telegram.Bot.send_gift`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 21.8 + """ + + __slots__ = () + + MAX_TEXT_LENGTH = 128 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the + :paramref:`~telegram.Bot.send_gift.text` parameter of :meth:`~telegram.Bot.send_gift`. + + .. versionchanged:: 21.11 + Updated Value to 128 based on Bot API 8.3 + """ + + +class GiveawayLimit(IntEnum): + """This enum contains limitations for :class:`telegram.Giveaway` and related classes. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.8 + """ + + __slots__ = () + + MAX_WINNERS = 100 + """:obj:`int`: Maximum number of winners allowed for :class:`telegram.GiveawayWinners.winners`. + """ + + +class KeyboardButtonRequestUsersLimit(IntEnum): + """This enum contains limitations for :class:`telegram.KeyboardButtonRequestUsers`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.8 + """ + + __slots__ = () + + MIN_QUANTITY = 1 + """:obj:`int`: Minimum value allowed for + :paramref:`~telegram.KeyboardButtonRequestUsers.max_quantity` parameter of + :class:`telegram.KeyboardButtonRequestUsers`. + """ + MAX_QUANTITY = 10 + """:obj:`int`: Maximum value allowed for + :paramref:`~telegram.KeyboardButtonRequestUsers.max_quantity` parameter of + :class:`telegram.KeyboardButtonRequestUsers`. + """ + + +class InlineKeyboardButtonLimit(IntEnum): + """This enum contains limitations for :class:`telegram.InlineKeyboardButton`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_CALLBACK_DATA = 1 + """:obj:`int`: Minimum length allowed for + :paramref:`~telegram.InlineKeyboardButton.callback_data` parameter of + :class:`telegram.InlineKeyboardButton` + """ + MAX_CALLBACK_DATA = 64 + """:obj:`int`: Maximum length allowed for + :paramref:`~telegram.InlineKeyboardButton.callback_data` parameter of + :class:`telegram.InlineKeyboardButton` + """ + MIN_COPY_TEXT = 1 + """:obj:`int`: Minimum length allowed for + :paramref:`~telegram.CopyTextButton.text` parameter of :class:`telegram.CopyTextButton` + """ + MAX_COPY_TEXT = 256 + """:obj:`int`: Maximum length allowed for + :paramref:`~telegram.CopyTextButton.text` parameter of :class:`telegram.CopyTextButton` + """ + + +class InlineKeyboardMarkupLimit(IntEnum): + """This enum contains limitations for :class:`telegram.InlineKeyboardMarkup`/ + :meth:`telegram.Bot.send_message` & friends. The enum + members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + TOTAL_BUTTON_NUMBER = 100 + """:obj:`int`: Maximum number of buttons that can be attached to a message. + + Note: + This value is undocumented and might be changed by Telegram. + """ + BUTTONS_PER_ROW = 8 + """:obj:`int`: Maximum number of buttons that can be attached to a message per row. + + Note: + This value is undocumented and might be changed by Telegram. + """ + + +class InputChecklistLimit(IntEnum): + """This enum contains limitations for :class:`telegram.InputChecklist`/ + :class:`telegram.InputChecklistTask`. The enum + members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.3 + """ + + __slots__ = () + + MIN_TITLE_LENGTH = 1 + """:obj:`int`: Minimum number of characters in a :obj:`str` passed as + :paramref:`~telegram.InputChecklist.title` parameter of :class:`telegram.InputChecklist` + """ + + MAX_TITLE_LENGTH = 255 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as + :paramref:`~telegram.InputChecklist.title` parameter of :class:`telegram.InputChecklist` + """ + + MIN_TEXT_LENGTH = 1 + """:obj:`int`: Minimum number of characters in a :obj:`str` passed as + :paramref:`~telegram.InputChecklistTask.text` parameter of :class:`telegram.InputChecklistTask` + """ + + MAX_TEXT_LENGTH = 100 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as + :paramref:`~telegram.InputChecklistTask.text` parameter of :class:`telegram.InputChecklistTask` + """ + + MIN_TASK_NUMBER = 1 + """:obj:`int`: Minimum number of tasks passed as :paramref:`~telegram.InputChecklist.tasks` + parameter of :class:`telegram.InputChecklist` + """ + + MAX_TASK_NUMBER = 30 + """:obj:`int`: Maximum number of tasks passed as :paramref:`~telegram.InputChecklistTask.tasks` + parameter of :class:`telegram.InputChecklistTask` + """ + + +class InputMediaType(StringEnum): + """This enum contains the available types of :class:`telegram.InputMedia`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + ANIMATION = "animation" + """:obj:`str`: Type of :class:`telegram.InputMediaAnimation`.""" + DOCUMENT = "document" + """:obj:`str`: Type of :class:`telegram.InputMediaDocument`.""" + AUDIO = "audio" + """:obj:`str`: Type of :class:`telegram.InputMediaAudio`.""" + PHOTO = "photo" + """:obj:`str`: Type of :class:`telegram.InputMediaPhoto`.""" + VIDEO = "video" + """: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 InputProfilePhotoType(StringEnum): + """This enum contains the available types of :class:`telegram.InputProfilePhoto`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + STATIC = "static" + """:obj:`str`: Type of :class:`telegram.InputProfilePhotoStatic`.""" + ANIMATED = "animated" + """:obj:`str`: Type of :class:`telegram.InputProfilePhotoAnimated`.""" + + +class InputStoryContentLimit(StringEnum): + """This enum contains limitations for :class:`telegram.InputStoryContentPhoto`/ + :class:`telegram.InputStoryContentVideo`. The enum members of this enumeration are instances + of :class:`int` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + PHOTOSIZE_UPLOAD = FileSizeLimit.PHOTOSIZE_UPLOAD # (10MB) + """:obj:`int`: Maximum file size of the photo to be passed to + :paramref:`~telegram.InputStoryContentPhoto.photo` parameter of + :class:`telegram.InputStoryContentPhoto` in Bytes. + """ + PHOTO_WIDTH = 1080 + """:obj:`int`: Horizontal resolution of the photo to be passed to + :paramref:`~telegram.InputStoryContentPhoto.photo` parameter of + :class:`telegram.InputStoryContentPhoto`. + """ + PHOTO_HEIGHT = 1920 + """:obj:`int`: Vertical resolution of the video to be passed to + :paramref:`~telegram.InputStoryContentPhoto.photo` parameter of + :class:`telegram.InputStoryContentPhoto`. + """ + VIDEOSIZE_UPLOAD = int(30e6) # (30MB) + """:obj:`int`: Maximum file size of the video to be passed to + :paramref:`~telegram.InputStoryContentVideo.video` parameter of + :class:`telegram.InputStoryContentVideo` in Bytes. + """ + VIDEO_WIDTH = 720 + """:obj:`int`: Horizontal resolution of the video to be passed to + :paramref:`~telegram.InputStoryContentVideo.video` parameter of + :class:`telegram.InputStoryContentVideo`. + """ + VIDEO_HEIGHT = 1080 + """:obj:`int`: Vertical resolution of the video to be passed to + :paramref:`~telegram.InputStoryContentVideo.video` parameter of + :class:`telegram.InputStoryContentVideo`. + """ + MAX_VIDEO_DURATION = int(dtm.timedelta(seconds=60).total_seconds()) + """:obj:`int`: Maximum duration of the video to be passed to + :paramref:`~telegram.InputStoryContentVideo.duration` parameter of + :class:`telegram.InputStoryContentVideo`. + """ + + +class InputStoryContentType(StringEnum): + """This enum contains the available types of :class:`telegram.InputStoryContent`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + PHOTO = "photo" + """:obj:`str`: Type of :class:`telegram.InputStoryContentPhoto`.""" + VIDEO = "video" + """:obj:`str`: Type of :class:`telegram.InputStoryContentVideo`.""" + + +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 + of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + + .. versionchanged:: 22.0 + Removed deprecated attributes ``InlineQueryLimit.MIN_SWITCH_PM_TEXT_LENGTH`` and + ``InlineQueryLimit.MAX_SWITCH_PM_TEXT_LENGTH``. Please instead use + :attr:`InlineQueryResultsButtonLimit.MIN_START_PARAMETER_LENGTH` and + :attr:`InlineQueryResultsButtonLimit.MAX_START_PARAMETER_LENGTH`. + """ + + __slots__ = () + + RESULTS = 50 + """:obj:`int`: Maximum number of results that can be passed to + :meth:`telegram.Bot.answer_inline_query`.""" + MAX_OFFSET_LENGTH = 64 + """:obj:`int`: Maximum number of bytes in a :obj:`str` passed as the + :paramref:`~telegram.Bot.answer_inline_query.next_offset` parameter of + :meth:`telegram.Bot.answer_inline_query`.""" + MAX_QUERY_LENGTH = 256 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the + :paramref:`~telegram.InlineQuery.query` parameter of :class:`telegram.InlineQuery`.""" + + +class InlineQueryResultLimit(IntEnum): + """This enum contains limitations for :class:`telegram.InlineQueryResult` and its subclasses. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_ID_LENGTH = 1 + """:obj:`int`: Minimum number of bytes in a :obj:`str` passed as the + :paramref:`~telegram.InlineQueryResult.id` parameter of + :class:`telegram.InlineQueryResult` and its subclasses + """ + MAX_ID_LENGTH = 64 + """:obj:`int`: Maximum number of bytes in a :obj:`str` passed as the + :paramref:`~telegram.InlineQueryResult.id` parameter of + :class:`telegram.InlineQueryResult` and its subclasses + """ + + +class InlineQueryResultsButtonLimit(IntEnum): + """This enum contains limitations for :class:`telegram.InlineQueryResultsButton`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.3 + """ + + __slots__ = () + + MIN_START_PARAMETER_LENGTH = 1 + """:obj:`int`: Minimum number of characters in a :obj:`str` passed as the + :paramref:`~telegram.InlineQueryResultsButton.start_parameter` parameter of + :meth:`telegram.InlineQueryResultsButton`.""" + + MAX_START_PARAMETER_LENGTH = 64 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the + :paramref:`~telegram.InlineQueryResultsButton.start_parameter` parameter of + :meth:`telegram.InlineQueryResultsButton`.""" + + +class InlineQueryResultType(StringEnum): + """This enum contains the available types of :class:`telegram.InlineQueryResult`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + AUDIO = "audio" + """:obj:`str`: Type of :class:`telegram.InlineQueryResultAudio` and + :class:`telegram.InlineQueryResultCachedAudio`. + """ + DOCUMENT = "document" + """:obj:`str`: Type of :class:`telegram.InlineQueryResultDocument` and + :class:`telegram.InlineQueryResultCachedDocument`. + """ + GIF = "gif" + """:obj:`str`: Type of :class:`telegram.InlineQueryResultGif` and + :class:`telegram.InlineQueryResultCachedGif`. + """ + MPEG4GIF = "mpeg4_gif" + """:obj:`str`: Type of :class:`telegram.InlineQueryResultMpeg4Gif` and + :class:`telegram.InlineQueryResultCachedMpeg4Gif`. + """ + PHOTO = "photo" + """:obj:`str`: Type of :class:`telegram.InlineQueryResultPhoto` and + :class:`telegram.InlineQueryResultCachedPhoto`. + """ + STICKER = "sticker" + """:obj:`str`: Type of and :class:`telegram.InlineQueryResultCachedSticker`.""" + VIDEO = "video" + """:obj:`str`: Type of :class:`telegram.InlineQueryResultVideo` and + :class:`telegram.InlineQueryResultCachedVideo`. + """ + VOICE = "voice" + """:obj:`str`: Type of :class:`telegram.InlineQueryResultVoice` and + :class:`telegram.InlineQueryResultCachedVoice`. + """ + ARTICLE = "article" + """:obj:`str`: Type of :class:`telegram.InlineQueryResultArticle`.""" + CONTACT = "contact" + """:obj:`str`: Type of :class:`telegram.InlineQueryResultContact`.""" + GAME = "game" + """:obj:`str`: Type of :class:`telegram.InlineQueryResultGame`.""" + LOCATION = "location" + """:obj:`str`: Type of :class:`telegram.InlineQueryResultLocation`.""" + VENUE = "venue" + """:obj:`str`: Type of :class:`telegram.InlineQueryResultVenue`.""" + + +class LocationLimit(IntEnum): + """This enum contains limitations for + :class:`telegram.Location`/:class:`telegram.ChatLocation`/ + :meth:`telegram.Bot.edit_message_live_location`/:meth:`telegram.Bot.send_location`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_CHAT_LOCATION_ADDRESS = 1 + """:obj:`int`: Minimum value allowed for :paramref:`~telegram.ChatLocation.address` parameter + of :class:`telegram.ChatLocation` + """ + MAX_CHAT_LOCATION_ADDRESS = 64 + """:obj:`int`: Minimum value allowed for :paramref:`~telegram.ChatLocation.address` parameter + of :class:`telegram.ChatLocation` + """ + + HORIZONTAL_ACCURACY = 1500 + """:obj:`int`: Maximum value allowed for: + + * :paramref:`~telegram.Location.horizontal_accuracy` parameter of :class:`telegram.Location` + * :paramref:`~telegram.InlineQueryResultLocation.horizontal_accuracy` parameter of + :class:`telegram.InlineQueryResultLocation` + * :paramref:`~telegram.InputLocationMessageContent.horizontal_accuracy` parameter of + :class:`telegram.InputLocationMessageContent` + * :paramref:`~telegram.Bot.edit_message_live_location.horizontal_accuracy` parameter of + :meth:`telegram.Bot.edit_message_live_location` + * :paramref:`~telegram.Bot.send_location.horizontal_accuracy` parameter of + :meth:`telegram.Bot.send_location` + """ + + MIN_HEADING = 1 + """:obj:`int`: Minimum value allowed for: + + * :paramref:`~telegram.Location.heading` parameter of :class:`telegram.Location` + * :paramref:`~telegram.InlineQueryResultLocation.heading` parameter of + :class:`telegram.InlineQueryResultLocation` + * :paramref:`~telegram.InputLocationMessageContent.heading` parameter of + :class:`telegram.InputLocationMessageContent` + * :paramref:`~telegram.Bot.edit_message_live_location.heading` parameter of + :meth:`telegram.Bot.edit_message_live_location` + * :paramref:`~telegram.Bot.send_location.heading` parameter of + :meth:`telegram.Bot.send_location` + """ + MAX_HEADING = 360 + """:obj:`int`: Maximum value allowed for: + + * :paramref:`~telegram.Location.heading` parameter of :class:`telegram.Location` + * :paramref:`~telegram.InlineQueryResultLocation.heading` parameter of + :class:`telegram.InlineQueryResultLocation` + * :paramref:`~telegram.InputLocationMessageContent.heading` parameter of + :class:`telegram.InputLocationMessageContent` + * :paramref:`~telegram.Bot.edit_message_live_location.heading` parameter of + :meth:`telegram.Bot.edit_message_live_location` + * :paramref:`~telegram.Bot.send_location.heading` parameter of + :meth:`telegram.Bot.send_location` + """ + + MIN_LIVE_PERIOD = 60 + """:obj:`int`: Minimum value allowed for: + + * :paramref:`~telegram.InlineQueryResultLocation.live_period` parameter of + :class:`telegram.InlineQueryResultLocation` + * :paramref:`~telegram.InputLocationMessageContent.live_period` parameter of + :class:`telegram.InputLocationMessageContent` + * :paramref:`~telegram.Bot.edit_message_live_location.live_period` parameter of + :meth:`telegram.Bot.edit_message_live_location` + * :paramref:`~telegram.Bot.send_location.live_period` parameter of + :meth:`telegram.Bot.send_location` + """ + MAX_LIVE_PERIOD = 86400 + """:obj:`int`: Maximum value allowed for: + + * :paramref:`~telegram.InlineQueryResultLocation.live_period` parameter of + :class:`telegram.InlineQueryResultLocation` + * :paramref:`~telegram.InputLocationMessageContent.live_period` parameter of + :class:`telegram.InputLocationMessageContent` + * :paramref:`~telegram.Bot.edit_message_live_location.live_period` parameter of + :meth:`telegram.Bot.edit_message_live_location` + * :paramref:`~telegram.Bot.send_location.live_period` parameter of + :meth:`telegram.Bot.send_location` + """ + + LIVE_PERIOD_FOREVER = int(hex(0x7FFFFFFF), 16) + """:obj:`int`: Value for live locations that can be edited indefinitely. Passed in: + + * :paramref:`~telegram.InlineQueryResultLocation.live_period` parameter of + :class:`telegram.InlineQueryResultLocation` + * :paramref:`~telegram.InputLocationMessageContent.live_period` parameter of + :class:`telegram.InputLocationMessageContent` + * :paramref:`~telegram.Bot.edit_message_live_location.live_period` parameter of + :meth:`telegram.Bot.edit_message_live_location` + * :paramref:`~telegram.Bot.send_location.live_period` parameter of + :meth:`telegram.Bot.send_location` + + .. versionadded:: 21.2 + """ + + MIN_PROXIMITY_ALERT_RADIUS = 1 + """:obj:`int`: Minimum value allowed for: + + * :paramref:`~telegram.InlineQueryResultLocation.proximity_alert_radius` parameter of + :class:`telegram.InlineQueryResultLocation` + * :paramref:`~telegram.InputLocationMessageContent.proximity_alert_radius` parameter of + :class:`telegram.InputLocationMessageContent` + * :paramref:`~telegram.Bot.edit_message_live_location.proximity_alert_radius` parameter of + :meth:`telegram.Bot.edit_message_live_location` + * :paramref:`~telegram.Bot.send_location.proximity_alert_radius` parameter of + :meth:`telegram.Bot.send_location` + """ + MAX_PROXIMITY_ALERT_RADIUS = 100000 + """:obj:`int`: Maximum value allowed for: + + * :paramref:`~telegram.InlineQueryResultLocation.proximity_alert_radius` parameter of + :class:`telegram.InlineQueryResultLocation` + * :paramref:`~telegram.InputLocationMessageContent.proximity_alert_radius` parameter of + :class:`telegram.InputLocationMessageContent` + * :paramref:`~telegram.Bot.edit_message_live_location.proximity_alert_radius` parameter of + :meth:`telegram.Bot.edit_message_live_location` + * :paramref:`~telegram.Bot.send_location.proximity_alert_radius` parameter of + :meth:`telegram.Bot.send_location` + """ + + +class MaskPosition(StringEnum): + """This enum contains the available positions for :class:`telegram.MaskPosition`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + FOREHEAD = "forehead" + """:obj:`str`: Mask position for a sticker on the forehead.""" + EYES = "eyes" + """:obj:`str`: Mask position for a sticker on the eyes.""" + MOUTH = "mouth" + """:obj:`str`: Mask position for a sticker on the mouth.""" + CHIN = "chin" + """:obj:`str`: Mask position for a sticker on the chin.""" + + +class MediaGroupLimit(IntEnum): + """This enum contains limitations for :meth:`telegram.Bot.send_media_group`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_MEDIA_LENGTH = 2 + """:obj:`int`: Minimum length of a :obj:`list` passed as the + :paramref:`~telegram.Bot.send_media_group.media` parameter of + :meth:`telegram.Bot.send_media_group`. + """ + MAX_MEDIA_LENGTH = 10 + """:obj:`int`: Maximum length of a :obj:`list` passed as the + :paramref:`~telegram.Bot.send_media_group.media` parameter of + :meth:`telegram.Bot.send_media_group`. + """ + + +class MenuButtonType(StringEnum): + """This enum contains the available types of :class:`telegram.MenuButton`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + COMMANDS = "commands" + """:obj:`str`: The type of :class:`telegram.MenuButtonCommands`.""" + WEB_APP = "web_app" + """:obj:`str`: The type of :class:`telegram.MenuButtonWebApp`.""" + DEFAULT = "default" + """:obj:`str`: The type of :class:`telegram.MenuButtonDefault`.""" + + +class MessageAttachmentType(StringEnum): + """This enum contains the available types of :class:`telegram.Message` that can be seen + as attachment. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + # Make sure that all constants here are also listed in the MessageType Enum! + # (Enums are not extendable) + + ANIMATION = "animation" + """:obj:`str`: Messages with :attr:`telegram.Message.animation`.""" + AUDIO = "audio" + """:obj:`str`: Messages with :attr:`telegram.Message.audio`.""" + CONTACT = "contact" + """:obj:`str`: Messages with :attr:`telegram.Message.contact`.""" + DICE = "dice" + """:obj:`str`: Messages with :attr:`telegram.Message.dice`.""" + DOCUMENT = "document" + """:obj:`str`: Messages with :attr:`telegram.Message.document`.""" + GAME = "game" + """:obj:`str`: Messages with :attr:`telegram.Message.game`.""" + INVOICE = "invoice" + """: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" + """:obj:`str`: Messages with :attr:`telegram.Message.photo`.""" + POLL = "poll" + """:obj:`str`: Messages with :attr:`telegram.Message.poll`.""" + STICKER = "sticker" + """:obj:`str`: Messages with :attr:`telegram.Message.sticker`.""" + STORY = "story" + """:obj:`str`: Messages with :attr:`telegram.Message.story`.""" + SUCCESSFUL_PAYMENT = "successful_payment" + """:obj:`str`: Messages with :attr:`telegram.Message.successful_payment`.""" + VIDEO = "video" + """:obj:`str`: Messages with :attr:`telegram.Message.video`.""" + VIDEO_NOTE = "video_note" + """:obj:`str`: Messages with :attr:`telegram.Message.video_note`.""" + VOICE = "voice" + """:obj:`str`: Messages with :attr:`telegram.Message.voice`.""" + VENUE = "venue" + """:obj:`str`: Messages with :attr:`telegram.Message.venue`.""" + + +class MessageEntityType(StringEnum): + """This enum contains the available types of :class:`telegram.MessageEntity`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + BLOCKQUOTE = "blockquote" + """:obj:`str`: Message entities representing a block quotation. + + .. versionadded:: 20.8 + """ + BOLD = "bold" + """:obj:`str`: Message entities representing bold text.""" + BOT_COMMAND = "bot_command" + """:obj:`str`: Message entities representing a bot command.""" + CASHTAG = "cashtag" + """:obj:`str`: Message entities representing a cashtag.""" + CODE = "code" + """:obj:`str`: Message entities representing monowidth string.""" + CUSTOM_EMOJI = "custom_emoji" + """:obj:`str`: Message entities representing inline custom emoji stickers. + + .. versionadded:: 20.0 + """ + EMAIL = "email" + """:obj:`str`: Message entities representing a email.""" + EXPANDABLE_BLOCKQUOTE = "expandable_blockquote" + """:obj:`str`: Message entities representing collapsed-by-default block quotation. + + .. versionadded:: 21.3 + """ + HASHTAG = "hashtag" + """:obj:`str`: Message entities representing a hashtag.""" + ITALIC = "italic" + """:obj:`str`: Message entities representing italic text.""" + MENTION = "mention" + """:obj:`str`: Message entities representing a mention.""" + PHONE_NUMBER = "phone_number" + """:obj:`str`: Message entities representing a phone number.""" + PRE = "pre" + """:obj:`str`: Message entities representing monowidth block.""" + SPOILER = "spoiler" + """:obj:`str`: Message entities representing spoiler text.""" + STRIKETHROUGH = "strikethrough" + """:obj:`str`: Message entities representing strikethrough text.""" + TEXT_LINK = "text_link" + """:obj:`str`: Message entities representing clickable text URLs.""" + TEXT_MENTION = "text_mention" + """:obj:`str`: Message entities representing text mention for users without usernames.""" + UNDERLINE = "underline" + """:obj:`str`: Message entities representing underline text.""" + URL = "url" + """:obj:`str`: Message entities representing a url.""" + + +class MessageLimit(IntEnum): + """This enum contains limitations for :class:`telegram.Message`/ + :class:`telegram.InputTextMessageContent`/ + :meth:`telegram.Bot.send_message` & friends. The enum + members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + # TODO add links to params? + MAX_TEXT_LENGTH = 4096 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as: + + * :paramref:`~telegram.Game.text` parameter of :class:`telegram.Game` + * :paramref:`~telegram.Message.text` parameter of :class:`telegram.Message` + * :paramref:`~telegram.InputTextMessageContent.message_text` parameter of + :class:`telegram.InputTextMessageContent` + * :paramref:`~telegram.Bot.send_message.text` parameter of :meth:`telegram.Bot.send_message` + * :paramref:`~telegram.Bot.edit_message_text.text` parameter of + :meth:`telegram.Bot.edit_message_text` + * :paramref:`~telegram.Bot.send_message_draft.text` parameter of + :meth:`telegram.Bot.send_message_draft` + """ + CAPTION_LENGTH = 1024 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as: + + * :paramref:`~telegram.Message.caption` parameter of :class:`telegram.Message` + * :paramref:`~telegram.InputMedia.caption` parameter of :class:`telegram.InputMedia` + and its subclasses + * ``caption`` parameter of subclasses of :class:`telegram.InlineQueryResult` + * ``caption`` parameter of :meth:`telegram.Bot.send_photo`, :meth:`telegram.Bot.send_audio`, + :meth:`telegram.Bot.send_document`, :meth:`telegram.Bot.send_video`, + :meth:`telegram.Bot.send_animation`, :meth:`telegram.Bot.send_voice`, + :meth:`telegram.Bot.edit_message_caption`, :meth:`telegram.Bot.copy_message` + """ + # constants above this line are tested + MIN_TEXT_LENGTH = 1 + """:obj:`int`: Minimum number of characters in a :obj:`str` passed as: + + * :paramref:`~telegram.InputTextMessageContent.message_text` parameter of + :class:`telegram.InputTextMessageContent`. + * :paramref:`~telegram.Bot.edit_message_text.text` parameter of + :meth:`telegram.Bot.edit_message_text`. + * :paramref:`~telegram.Bot.send_message_draft.text` parameter of + :meth:`telegram.Bot.send_message_draft`. + """ + DEEP_LINK_LENGTH = 64 + """:obj:`int`: Maximum number of characters for a deep link.""" + # TODO this constant is not used anywhere + MESSAGE_ENTITIES = 100 + """:obj:`int`: Maximum number of entities that can be displayed in a message. Further entities + will simply be ignored by Telegram. + + Note: + This value is undocumented and might be changed by Telegram. + """ + + +class MessageOriginType(StringEnum): + """This enum contains the available types of :class:`telegram.MessageOrigin`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.8 + """ + + __slots__ = () + + USER = "user" + """:obj:`str`: A :class:`telegram.MessageOrigin` who is sent by an user.""" + HIDDEN_USER = "hidden_user" + """:obj:`str`: A :class:`telegram.MessageOrigin` who is sent by a hidden user.""" + CHAT = "chat" + """:obj:`str`: A :class:`telegram.MessageOrigin` who is sent by a chat.""" + CHANNEL = "channel" + """:obj:`str`: A :class:`telegram.MessageOrigin` who is sent by a channel.""" + + +class MessageType(StringEnum): + """This enum contains the available types of :class:`telegram.Message`. Here, a "type" means + a kind of message that is visually distinct from other kinds of messages in the Telegram app. + In particular, auxiliary attributes that can be present for multiple types of messages are + not considered in this enumeration. + + The enum members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + # Make sure that all attachment type constants are also listed in the + # MessageAttachmentType Enum! (Enums are not extendable) + + ANIMATION = "animation" + """:obj:`str`: Messages with :attr:`telegram.Message.animation`.""" + AUDIO = "audio" + """:obj:`str`: Messages with :attr:`telegram.Message.audio`.""" + BOOST_ADDED = "boost_added" + """:obj:`str`: Messages with :attr:`telegram.Message.boost_added`. + + .. versionadded:: 21.0 + """ + BUSINESS_CONNECTION_ID = "business_connection_id" + """:obj:`str`: Messages with :attr:`telegram.Message.business_connection_id`. + + .. versionadded:: 21.1 + """ + CHANNEL_CHAT_CREATED = "channel_chat_created" + """:obj:`str`: Messages with :attr:`telegram.Message.channel_chat_created`.""" + CHAT_SHARED = "chat_shared" + """:obj:`str`: Messages with :attr:`telegram.Message.chat_shared`. + + .. versionadded:: 20.8 + """ + CHAT_BACKGROUND_SET = "chat_background_set" + """:obj:`str`: Messages with :attr:`telegram.Message.chat_background_set`. + + .. versionadded:: 21.2 + """ + CHECKLIST = "checklist" + """:obj:`str`: Messages with :attr:`telegram.Message.checklist`. + + .. versionadded:: 22.3 + """ + CHECKLIST_TASKS_ADDED = "checklist_tasks_added" + """:obj:`str`: Messages with :attr:`telegram.Message.checklist_tasks_added`. + + .. versionadded:: 22.3 + """ + CHECKLIST_TASKS_DONE = "checklist_tasks_done" + """:obj:`str`: Messages with :attr:`telegram.Message.checklist_tasks_done`. + + .. versionadded:: 22.3 + """ + CONNECTED_WEBSITE = "connected_website" + """:obj:`str`: Messages with :attr:`telegram.Message.connected_website`.""" + CONTACT = "contact" + """:obj:`str`: Messages with :attr:`telegram.Message.contact`.""" + DELETE_CHAT_PHOTO = "delete_chat_photo" + """:obj:`str`: Messages with :attr:`telegram.Message.delete_chat_photo`.""" + DICE = "dice" + """:obj:`str`: Messages with :attr:`telegram.Message.dice`.""" + DIRECT_MESSAGE_PRICE_CHANGED = "direct_message_price_changed" + """:obj:`str`: Messages with :attr:`telegram.Message.direct_message_price_changed`. + + .. versionadded:: 22.3 + """ + DOCUMENT = "document" + """:obj:`str`: Messages with :attr:`telegram.Message.document`.""" + EFFECT_ID = "effect_id" + """:obj:`str`: Messages with :attr:`telegram.Message.effect_id`. + + .. versionadded:: 21.3""" + FORUM_TOPIC_CREATED = "forum_topic_created" + """:obj:`str`: Messages with :attr:`telegram.Message.forum_topic_created`. + + .. versionadded:: 20.8 + """ + FORUM_TOPIC_CLOSED = "forum_topic_closed" + """:obj:`str`: Messages with :attr:`telegram.Message.forum_topic_closed`. + + .. versionadded:: 20.8 + """ + FORUM_TOPIC_EDITED = "forum_topic_edited" + """:obj:`str`: Messages with :attr:`telegram.Message.forum_topic_edited`. + + .. versionadded:: 20.8 + """ + FORUM_TOPIC_REOPENED = "forum_topic_reopened" + """:obj:`str`: Messages with :attr:`telegram.Message.forum_topic_reopened`. + + .. versionadded:: 20.8 + """ + GAME = "game" + """:obj:`str`: Messages with :attr:`telegram.Message.game`.""" + GENERAL_FORUM_TOPIC_HIDDEN = "general_forum_topic_hidden" + """:obj:`str`: Messages with :attr:`telegram.Message.general_forum_topic_hidden`. + + .. versionadded:: 20.8 + """ + GENERAL_FORUM_TOPIC_UNHIDDEN = "general_forum_topic_unhidden" + """:obj:`str`: Messages with :attr:`telegram.Message.general_forum_topic_unhidden`. + + .. versionadded:: 20.8 + """ + GIFT = "gift" + """:obj:`str`: Messages with :attr:`telegram.Message.gift`. + + .. versionadded:: 22.1 + """ + GIFT_UPGRADE_SENT = "gift_upgrade_sent" + """:obj:`str`: Messages with :attr:`telegram.Message.gift_upgrade_sent`. + + .. versionadded:: 22.6 + """ + GIVEAWAY = "giveaway" + """:obj:`str`: Messages with :attr:`telegram.Message.giveaway`. + + .. versionadded:: 20.8 + """ + GIVEAWAY_CREATED = "giveaway_created" + """:obj:`str`: Messages with :attr:`telegram.Message.giveaway_created`. + + .. versionadded:: 20.8 + """ + GIVEAWAY_WINNERS = "giveaway_winners" + """:obj:`str`: Messages with :attr:`telegram.Message.giveaway_winners`. + + .. versionadded:: 20.8 + """ + GIVEAWAY_COMPLETED = "giveaway_completed" + """:obj:`str`: Messages with :attr:`telegram.Message.giveaway_completed`. + + .. versionadded:: 20.8 + """ + GROUP_CHAT_CREATED = "group_chat_created" + """:obj:`str`: Messages with :attr:`telegram.Message.group_chat_created`.""" + INVOICE = "invoice" + """:obj:`str`: Messages with :attr:`telegram.Message.invoice`.""" + LEFT_CHAT_MEMBER = "left_chat_member" + """:obj:`str`: Messages with :attr:`telegram.Message.left_chat_member`.""" + LOCATION = "location" + """:obj:`str`: Messages with :attr:`telegram.Message.location`.""" + MESSAGE_AUTO_DELETE_TIMER_CHANGED = "message_auto_delete_timer_changed" + """:obj:`str`: Messages with :attr:`telegram.Message.message_auto_delete_timer_changed`.""" + MIGRATE_TO_CHAT_ID = "migrate_to_chat_id" + """:obj:`str`: Messages with :attr:`telegram.Message.migrate_to_chat_id`.""" + NEW_CHAT_MEMBERS = "new_chat_members" + """:obj:`str`: Messages with :attr:`telegram.Message.new_chat_members`.""" + NEW_CHAT_TITLE = "new_chat_title" + """: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 + """ + PAID_MESSAGE_PRICE_CHANGED = "paid_message_price_changed" + """:obj:`str`: Messages with :attr:`telegram.Message.paid_message_price_changed`. + + .. versionadded:: v22.2 + """ + SUGGESTED_POST_APPROVAL_FAILED = "suggested_post_approval_failed" + """:obj:`str`: Messages with :attr:`telegram.Message.suggested_post_approval_failed`. + + .. versionadded:: 22.4 + """ + SUGGESTED_POST_APPROVED = "suggested_post_approved" + """:obj:`str`: Messages with :attr:`telegram.Message.suggested_post_approved`. + + .. versionadded:: 22.4 + """ + SUGGESTED_POST_DECLINED = "suggested_post_declined" + """:obj:`str`: Messages with :attr:`telegram.Message.suggested_post_declined`. + + .. versionadded:: 22.4 + """ + SUGGESTED_POST_INFO = "suggested_post_info" + """:obj:`str`: Messages with :attr:`telegram.Message.suggested_post_info`. + + .. versionadded:: 22.4 + """ + SUGGESTED_POST_PAID = "suggested_post_paid" + """:obj:`str`: Messages with :attr:`telegram.Message.suggested_post_paid`. + + .. versionadded:: 22.4 + """ + SUGGESTED_POST_REFUNDED = "suggested_post_refunded" + """:obj:`str`: Messages with :attr:`telegram.Message.suggested_post_refunded`. + + .. versionadded:: 22.4 + """ + PASSPORT_DATA = "passport_data" + """:obj:`str`: Messages with :attr:`telegram.Message.passport_data`.""" + PHOTO = "photo" + """:obj:`str`: Messages with :attr:`telegram.Message.photo`.""" + PINNED_MESSAGE = "pinned_message" + """:obj:`str`: Messages with :attr:`telegram.Message.pinned_message`.""" + POLL = "poll" + """: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`. + + .. versionadded:: 21.0 + """ + SENDER_BOOST_COUNT = "sender_boost_count" + """:obj:`str`: Messages with :attr:`telegram.Message.sender_boost_count`. + + .. versionadded:: 21.0 + """ + SENDER_BUSINESS_BOT = "sender_business_bot" + """:obj:`str`: Messages with :attr:`telegram.Message.sender_business_bot`. + + .. versionadded:: 21.1 + """ + STICKER = "sticker" + """:obj:`str`: Messages with :attr:`telegram.Message.sticker`.""" + STORY = "story" + """:obj:`str`: Messages with :attr:`telegram.Message.story`.""" + SUPERGROUP_CHAT_CREATED = "supergroup_chat_created" + """:obj:`str`: Messages with :attr:`telegram.Message.supergroup_chat_created`.""" + SUCCESSFUL_PAYMENT = "successful_payment" + """:obj:`str`: Messages with :attr:`telegram.Message.successful_payment`.""" + TEXT = "text" + """:obj:`str`: Messages with :attr:`telegram.Message.text`.""" + UNIQUE_GIFT = "unique_gift" + """:obj:`str`: Messages with :attr:`telegram.Message.unique_gift`. + + .. versionadded:: 22.1 + """ + USERS_SHARED = "users_shared" + """:obj:`str`: Messages with :attr:`telegram.Message.users_shared`. + + .. versionadded:: 20.8 + """ + VENUE = "venue" + """:obj:`str`: Messages with :attr:`telegram.Message.venue`.""" + VIDEO = "video" + """:obj:`str`: Messages with :attr:`telegram.Message.video`.""" + VIDEO_CHAT_SCHEDULED = "video_chat_scheduled" + """:obj:`str`: Messages with :attr:`telegram.Message.video_chat_scheduled`.""" + VIDEO_CHAT_STARTED = "video_chat_started" + """:obj:`str`: Messages with :attr:`telegram.Message.video_chat_started`.""" + VIDEO_CHAT_ENDED = "video_chat_ended" + """:obj:`str`: Messages with :attr:`telegram.Message.video_chat_ended`.""" + VIDEO_CHAT_PARTICIPANTS_INVITED = "video_chat_participants_invited" + """:obj:`str`: Messages with :attr:`telegram.Message.video_chat_participants_invited`.""" + VIDEO_NOTE = "video_note" + """:obj:`str`: Messages with :attr:`telegram.Message.video_note`.""" + VOICE = "voice" + """:obj:`str`: Messages with :attr:`telegram.Message.voice`.""" + WEB_APP_DATA = "web_app_data" + """:obj:`str`: Messages with :attr:`telegram.Message.web_app_data`. + + .. versionadded:: 20.8 + """ + WRITE_ACCESS_ALLOWED = "write_access_allowed" + """:obj:`str`: Messages with :attr:`telegram.Message.write_access_allowed`. + + .. versionadded:: 20.8 + """ + + +class Nanostar(FloatEnum): + """This enum contains constants for ``nanostar_amount`` parameter of + :class:`telegram.StarAmount`, :class:`telegram.StarTransaction` + and :class:`telegram.AffiliateInfo`. + The enum members of this enumeration are instances of :class:`float` and can be treated as + such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + VALUE = 1 / 1000000000 + """:obj:`float`: The value of one nanostar as used in + :paramref:`telegram.StarTransaction.nanostar_amount` + parameter of :class:`telegram.StarTransaction`, + :paramref:`telegram.StarAmount.nanostar_amount` parameter of :class:`telegram.StarAmount` + and :paramref:`telegram.AffiliateInfo.nanostar_amount` + parameter of :class:`telegram.AffiliateInfo` + """ + + +class NanostarLimit(IntEnum): + """This enum contains limitations for ``nanostar_amount`` parameter of + :class:`telegram.AffiliateInfo`, :class:`telegram.StarTransaction` + and :class:`telegram.StarAmount`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + MIN_AMOUNT = -999999999 + """:obj:`int`: Minimum value allowed for :paramref:`~telegram.AffiliateInfo.nanostar_amount` + parameter of :class:`telegram.AffiliateInfo` + and :paramref:`~telegram.StarAmount.nanostar_amount` + parameter of :class:`telegram.StarAmount`. + """ + MAX_AMOUNT = 999999999 + """:obj:`int`: Maximum value allowed for :paramref:`~telegram.StarTransaction.nanostar_amount` + parameter of :class:`telegram.StarTransaction`, + :paramref:`~telegram.AffiliateInfo.nanostar_amount` parameter of + :class:`telegram.AffiliateInfo` and :paramref:`~telegram.StarAmount.nanostar_amount` + parameter of :class:`telegram.StarAmount`. + """ + + +class OwnedGiftType(StringEnum): + """This enum contains the available types of :class:`telegram.OwnedGift`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + REGULAR = "regular" + """:obj:`str`: a regular owned gift.""" + UNIQUE = "unique" + """:obj:`str`: a unique owned gift.""" + + +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. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_LIMIT = 1 + """:obj:`int`: Minimum value allowed for the :paramref:`~telegram.Bot.get_updates.limit` + parameter of :meth:`telegram.Bot.get_updates`. + """ + MAX_LIMIT = 100 + """:obj:`int`: Maximum value allowed for the :paramref:`~telegram.Bot.get_updates.limit` + parameter of :meth:`telegram.Bot.get_updates`. + """ + + +class PremiumSubscription(IntEnum): + """This enum contains limitations for :meth:`~telegram.Bot.gift_premium_subscription`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + MAX_TEXT_LENGTH = 128 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the + :paramref:`~telegram.Bot.gift_premium_subscription.text` + parameter of :meth:`~telegram.Bot.gift_premium_subscription`. + """ + MONTH_COUNT_THREE = 3 + """:obj:`int`: Possible value for + :paramref:`~telegram.Bot.gift_premium_subscription.month_count` parameter + of :meth:`~telegram.Bot.gift_premium_subscription`; number of months the Premium + subscription will be active for. + """ + MONTH_COUNT_SIX = 6 + """:obj:`int`: Possible value for + :paramref:`~telegram.Bot.gift_premium_subscription.month_count` parameter + of :meth:`~telegram.Bot.gift_premium_subscription`; number of months the Premium + subscription will be active for. + """ + MONTH_COUNT_TWELVE = 12 + """:obj:`int`: Possible value for + :paramref:`~telegram.Bot.gift_premium_subscription.month_count` parameter + of :meth:`~telegram.Bot.gift_premium_subscription`; number of months the Premium + subscription will be active for. + """ + STARS_THREE_MONTHS = 1000 + """:obj:`int`: Number of Telegram Stars to pay for a Premium subscription of + :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_THREE` months period. + Relevant for :paramref:`~telegram.Bot.gift_premium_subscription.star_count` parameter + of :meth:`~telegram.Bot.gift_premium_subscription`. + """ + STARS_SIX_MONTHS = 1500 + """:obj:`int`: Number of Telegram Stars to pay for a Premium subscription of + :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_SIX` months period. + Relevant for :paramref:`~telegram.Bot.gift_premium_subscription.star_count` parameter + of :meth:`~telegram.Bot.gift_premium_subscription`. + """ + STARS_TWELVE_MONTHS = 2500 + """:obj:`int`: Number of Telegram Stars to pay for a Premium subscription of + :tg-const:`telegram.constants.PremiumSubscription.MONTH_COUNT_TWELVE` months period. + Relevant for :paramref:`~telegram.Bot.gift_premium_subscription.star_count` parameter + of :meth:`~telegram.Bot.gift_premium_subscription`. + """ + + +class ProfileAccentColor(Enum): + """This enum contains the available accent colors for + :class:`telegram.ChatFullInfo.profile_accent_color_id`. + The members of this enum are named tuples with the following attributes: + + - ``identifier`` (:obj:`int`): The identifier of the accent color. + - ``name`` (:obj:`str`): Optional. The name of the accent color. + - ``light_colors`` (tuple[:obj:`str`]): Optional. The light colors of the accent color as HEX + value. + - ``dark_colors`` (tuple[:obj:`str`]): Optional. The dark colors of the accent color as HEX + value. + + Since Telegram gives no exact specification for the accent colors, future accent colors might + have a different data type. + + .. versionadded:: 20.8 + """ + + __slots__ = () + + COLOR_000 = _AccentColor(identifier=0, light_colors=(0xBA5650,), dark_colors=(0x9C4540,)) + """Accent color 0. This contains one light color + + .. raw:: html + +
+
+ + and one dark color + + .. raw:: html + +
+
+ """ + COLOR_001 = _AccentColor(identifier=1, light_colors=(0xC27C3E,), dark_colors=(0x945E2C,)) + """Accent color 1. This contains one light color + + .. raw:: html + +
+
+ + and one dark color + + .. raw:: html + +
+
+ """ + COLOR_002 = _AccentColor(identifier=2, light_colors=(0x956AC8,), dark_colors=(0x715099,)) + """Accent color 2. This contains one light color + + .. raw:: html + +
+
+ + and one dark color + + .. raw:: html + +
+
+ """ + COLOR_003 = _AccentColor(identifier=3, light_colors=(0x49A355,), dark_colors=(0x33713B,)) + """Accent color 3. This contains one light color + + .. raw:: html + +
+
+ + and one dark color + + .. raw:: html + +
+
+ """ + COLOR_004 = _AccentColor(identifier=4, light_colors=(0x3E97AD,), dark_colors=(0x387E87,)) + """Accent color 4. This contains one light color + + .. raw:: html + +
+
+ + and one dark color + + .. raw:: html + +
+
+ """ + COLOR_005 = _AccentColor(identifier=5, light_colors=(0x5A8FBB,), dark_colors=(0x477194,)) + """Accent color 5. This contains one light color + + .. raw:: html + +
+
+ + and one dark color + + .. raw:: html + +
+
+ """ + COLOR_006 = _AccentColor(identifier=6, light_colors=(0xB85378,), dark_colors=(0x944763,)) + """Accent color 6. This contains one light color + + .. raw:: html + +
+
+ + and one dark color + + .. raw:: html + +
+
+ """ + COLOR_007 = _AccentColor(identifier=7, light_colors=(0x7F8B95,), dark_colors=(0x435261,)) + """Accent color 7. This contains one light color + + .. raw:: html + +
+
+ + and one dark color + + .. raw:: html + +
+
+ """ + COLOR_008 = _AccentColor( + identifier=8, light_colors=(0xC9565D, 0xD97C57), dark_colors=(0x994343, 0xAC583E) + ) + """Accent color 8. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_009 = _AccentColor( + identifier=9, light_colors=(0xCF7244, 0xCC9433), dark_colors=(0x8F552F, 0xA17232) + ) + """Accent color 9. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_010 = _AccentColor( + identifier=10, light_colors=(0x9662D4, 0xB966B6), dark_colors=(0x634691, 0x9250A2) + ) + """Accent color 10. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_011 = _AccentColor( + identifier=11, light_colors=(0x3D9755, 0x89A650), dark_colors=(0x296A43, 0x5F8F44) + ) + """Accent color 11. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_012 = _AccentColor( + identifier=12, light_colors=(0x3D95BA, 0x50AD98), dark_colors=(0x306C7C, 0x3E987E) + ) + """Accent color 12. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_013 = _AccentColor( + identifier=13, light_colors=(0x538BC2, 0x4DA8BD), dark_colors=(0x38618C, 0x458BA1) + ) + """Accent color 13. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_014 = _AccentColor( + identifier=14, light_colors=(0xB04F74, 0xD1666D), dark_colors=(0x884160, 0xA65259) + ) + """Accent color 14. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + COLOR_015 = _AccentColor( + identifier=15, light_colors=(0x637482, 0x7B8A97), dark_colors=(0x53606E, 0x384654) + ) + """Accent color 15. This contains two light colors + + .. raw:: html + +
+
+
+

+ + and two dark colors + + .. raw:: html + +
+
+
+

+ """ + + +class ReplyLimit(IntEnum): + """This enum contains limitations for :class:`telegram.ForceReply` + and :class:`telegram.ReplyKeyboardMarkup`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_INPUT_FIELD_PLACEHOLDER = 1 + """:obj:`int`: Minimum value allowed for + :paramref:`~telegram.ForceReply.input_field_placeholder` parameter of + :class:`telegram.ForceReply` and + :paramref:`~telegram.ReplyKeyboardMarkup.input_field_placeholder` parameter of + :class:`telegram.ReplyKeyboardMarkup` + """ + MAX_INPUT_FIELD_PLACEHOLDER = 64 + """:obj:`int`: Maximum value allowed for + :paramref:`~telegram.ForceReply.input_field_placeholder` parameter of + :class:`telegram.ForceReply` and + :paramref:`~telegram.ReplyKeyboardMarkup.input_field_placeholder` parameter of + :class:`telegram.ReplyKeyboardMarkup` + """ + + +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` and + :class:`telegram.StarTransaction`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 21.4 + + .. versionremoved:: 22.3 + Removed deprecated attributes ``StarTransactionsLimit.NANOSTAR_MIN_AMOUNT`` + and ``StarTransactionsLimit.NANOSTAR_MAX_AMOUNT``. Please instead use + :attr:`telegram.constants.NanostarLimit.MIN_AMOUNT` + and :attr:`telegram.constants.NanostarLimit.MAX_AMOUNT`. + """ + + __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. + + .. versionadded:: 20.2 + """ + + __slots__ = () + + STATIC = "static" + """:obj:`str`: Static sticker.""" + ANIMATED = "animated" + """:obj:`str`: Animated sticker.""" + VIDEO = "video" + """:obj:`str`: Video sticker.""" + + +class StickerLimit(IntEnum): + """This enum contains limitations for various sticker methods, such as + :meth:`telegram.Bot.create_new_sticker_set`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_NAME_AND_TITLE = 1 + """:obj:`int`: Minimum number of characters in a :obj:`str` passed as the + :paramref:`~telegram.Bot.create_new_sticker_set.name` parameter or the + :paramref:`~telegram.Bot.create_new_sticker_set.title` parameter of + :meth:`telegram.Bot.create_new_sticker_set`. + """ + MAX_NAME_AND_TITLE = 64 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the + :paramref:`~telegram.Bot.create_new_sticker_set.name` parameter or the + :paramref:`~telegram.Bot.create_new_sticker_set.title` parameter of + :meth:`telegram.Bot.create_new_sticker_set`. + """ + MIN_STICKER_EMOJI = 1 + """:obj:`int`: Minimum number of emojis associated with a sticker, passed as the + :paramref:`~telegram.Bot.setStickerEmojiList.emoji_list` parameter of + :meth:`telegram.Bot.set_sticker_emoji_list`. + + .. versionadded:: 20.2 + """ + MAX_STICKER_EMOJI = 20 + """:obj:`int`: Maximum number of emojis associated with a sticker, passed as the + :paramref:`~telegram.Bot.setStickerEmojiList.emoji_list` parameter of + :meth:`telegram.Bot.set_sticker_emoji_list`. + + .. versionadded:: 20.2 + """ + MAX_SEARCH_KEYWORDS = 20 + """:obj:`int`: Maximum number of search keywords for a sticker, passed as the + :paramref:`~telegram.Bot.set_sticker_keywords.keywords` parameter of + :meth:`telegram.Bot.set_sticker_keywords`. + + .. versionadded:: 20.2 + """ + MAX_KEYWORD_LENGTH = 64 + """:obj:`int`: Maximum number of characters in a search keyword for a sticker, for each item in + :paramref:`~telegram.Bot.set_sticker_keywords.keywords` sequence of + :meth:`telegram.Bot.set_sticker_keywords`. + + .. versionadded:: 20.2 + """ + + +class StickerSetLimit(IntEnum): + """This enum contains limitations for various sticker set methods, such as + :meth:`telegram.Bot.create_new_sticker_set` and :meth:`telegram.Bot.add_sticker_to_set`. + + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.2 + """ + + __slots__ = () + + MIN_INITIAL_STICKERS = 1 + """:obj:`int`: Minimum number of stickers needed to create a sticker set, passed as the + :paramref:`~telegram.Bot.create_new_sticker_set.stickers` parameter of + :meth:`telegram.Bot.create_new_sticker_set`. + """ + MAX_INITIAL_STICKERS = 50 + """:obj:`int`: Maximum number of stickers allowed while creating a sticker set, passed as the + :paramref:`~telegram.Bot.create_new_sticker_set.stickers` parameter of + :meth:`telegram.Bot.create_new_sticker_set`. + """ + MAX_EMOJI_STICKERS = 200 + """:obj:`int`: Maximum number of stickers allowed in an emoji sticker set, as given in + :meth:`telegram.Bot.add_sticker_to_set`. + """ + MAX_ANIMATED_STICKERS = 50 + """:obj:`int`: Maximum number of stickers allowed in an animated or video sticker set, as given + in :meth:`telegram.Bot.add_sticker_to_set`. + + .. deprecated:: 21.1 + The animated sticker limit is now 120, the same as :attr:`MAX_STATIC_STICKERS`. + """ + MAX_STATIC_STICKERS = 120 + """:obj:`int`: Maximum number of stickers allowed in a static sticker set, as given in + :meth:`telegram.Bot.add_sticker_to_set`. + """ + MAX_STATIC_THUMBNAIL_SIZE = 128 + """:obj:`int`: Maximum size of the thumbnail if it is a ``.WEBP`` or ``.PNG`` in kilobytes, + as given in :meth:`telegram.Bot.set_sticker_set_thumbnail`.""" + MAX_ANIMATED_THUMBNAIL_SIZE = 32 + """:obj:`int`: Maximum size of the thumbnail if it is a ``.TGS`` or ``.WEBM`` in kilobytes, + as given in :meth:`telegram.Bot.set_sticker_set_thumbnail`.""" + STATIC_THUMB_DIMENSIONS = 100 + """:obj:`int`: Exact height and width of the thumbnail if it is a ``.WEBP`` or ``.PNG`` in + pixels, as given in :meth:`telegram.Bot.set_sticker_set_thumbnail`.""" + + +class StickerType(StringEnum): + """This enum contains the available types of :class:`telegram.Sticker`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + REGULAR = "regular" + """:obj:`str`: Regular sticker.""" + MASK = "mask" + """:obj:`str`: Mask sticker.""" + CUSTOM_EMOJI = "custom_emoji" + """:obj:`str`: Custom emoji sticker.""" + + +class StoryAreaPositionLimit(IntEnum): + """This enum contains limitations for :class:`telegram.StoryAreaPosition`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + MAX_ROTATION_ANGLE = 360 + """:obj:`int`: Maximum value allowed for: + :paramref:`~telegram.StoryAreaPosition.rotation_angle` parameter of + :class:`telegram.StoryAreaPosition` + """ + + +class StoryAreaTypeLimit(IntEnum): + """This enum contains limitations for subclasses of :class:`telegram.StoryAreaType`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + MAX_LOCATION_AREAS = 10 + """:obj:`int`: Maximum number of location areas that a story can have. + """ + MAX_SUGGESTED_REACTION_AREAS = 5 + """:obj:`int`: Maximum number of suggested reaction areas that a story can have. + """ + MAX_LINK_AREAS = 3 + """:obj:`int`: Maximum number of link areas that a story can have. + """ + MAX_WEATHER_AREAS = 3 + """:obj:`int`: Maximum number of weather areas that a story can have. + """ + MAX_UNIQUE_GIFT_AREAS = 1 + """:obj:`int`: Maximum number of unique gift areas that a story can have. + """ + + +class StoryAreaTypeType(StringEnum): + """This enum contains the available types of :class:`telegram.StoryAreaType`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + LOCATION = "location" + """:obj:`str`: Type of :class:`telegram.StoryAreaTypeLocation`.""" + SUGGESTED_REACTION = "suggested_reaction" + """:obj:`str`: Type of :class:`telegram.StoryAreaTypeSuggestedReaction`.""" + LINK = "link" + """:obj:`str`: Type of :class:`telegram.StoryAreaTypeLink`.""" + WEATHER = "weather" + """:obj:`str`: Type of :class:`telegram.StoryAreaTypeWeather`.""" + UNIQUE_GIFT = "unique_gift" + """:obj:`str`: Type of :class:`telegram.StoryAreaTypeUniqueGift`.""" + + +class StoryLimit(IntEnum): + """This enum contains limitations for :meth:`~telegram.Bot.post_story` and + :meth:`~telegram.Bot.edit_story`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + CAPTION_LENGTH = 2048 + """:obj:`int`: Maximum number of characters in :paramref:`telegram.Bot.post_story.caption` + parameter of :meth:`telegram.Bot.post_story` and :paramref:`telegram.Bot.edit_story.caption` of + :meth:`telegram.Bot.edit_story`. + """ + ACTIVITY_SIX_HOURS = 6 * 3600 + """:obj:`int`: Possible value for :paramref:`~telegram.Bot.post_story.active_period`` parameter + of :meth:`telegram.Bot.post_story`.""" + ACTIVITY_TWELVE_HOURS = 12 * 3600 + """:obj:`int`: Possible value for :paramref:`~telegram.Bot.post_story.active_period`` parameter + of :meth:`telegram.Bot.post_story`.""" + ACTIVITY_ONE_DAY = 86400 + """:obj:`int`: Possible value for :paramref:`~telegram.Bot.post_story.active_period`` parameter + of :meth:`telegram.Bot.post_story`.""" + ACTIVITY_TWO_DAYS = 2 * 86400 + """:obj:`int`: Possible value for :paramref:`~telegram.Bot.post_story.active_period`` parameter + of :meth:`telegram.Bot.post_story`.""" + + +class SuggestedPost(IntEnum): + """This enum contains limitations for :class:`telegram.SuggestedPostPrice`\ +/:class:`telegram.SuggestedPostParameters`/:meth:`telegram.Bot.decline_suggested_post`. The enum + members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.4 + """ + + __slots__ = () + + MIN_PRICE_STARS = 5 + """:obj:`int`: Minimum number of Telegram Stars in + :paramref:`~telegram.SuggestedPostPrice.amount` + parameter of :class:`telegram.SuggestedPostPrice`. + """ + MAX_PRICE_STARS = 100_000 + """:obj:`int`: Maximum number of Telegram Stars in + :paramref:`~telegram.SuggestedPostPrice.amount` + parameter of :class:`telegram.SuggestedPostPrice`. + """ + MIN_PRICE_NANOTONCOINS = 10_000_000 + """:obj:`int`: Minimum number of nanotoncoins in + :paramref:`~telegram.SuggestedPostPrice.amount` + parameter of :class:`telegram.SuggestedPostPrice`. + """ + MAX_PRICE_NANOTONCOINS = 10_000_000_000_000 + """:obj:`int`: Maximum number of nanotoncoins in + :paramref:`~telegram.SuggestedPostPrice.amount` + parameter of :class:`telegram.SuggestedPostPrice`. + """ + MIN_SEND_DATE = 300 + """:obj:`int`: Minimum number of seconds in the future for + the :paramref:`~telegram.SuggestedPostParameters.send_date` parameter of + :class:`telegram.SuggestedPostParameters`.""" + MAX_SEND_DATE = 2_678_400 + """:obj:`int`: Maximum number of seconds in the future for + the :paramref:`~telegram.SuggestedPostParameters.send_date` parameter of + :class:`telegram.SuggestedPostParameters`.""" + MAX_COMMENT_LENGTH = 128 + """:obj:`int`: Maximum number of characters in the + :paramref:`telegram.Bot.decline_suggested_post.comment` parameter. + """ + + +class SuggestedPostRefunded(StringEnum): + """This enum contains available refund reasons for :class:`telegram.SuggestedPostRefunded`. + The enum members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 22.4 + """ + + __slots__ = () + + POST_DELETED = "post_deleted" + """:obj:`str`: The post was deleted within 24 hours of being posted or removed from + scheduled messages without being posted.""" + PAYMENT_REFUNDED = "payment_refunded" + """:obj:`str`: The payer refunded their payment.""" + + +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__ = () + + AFFILIATE_PROGRAM = "affiliate_program" + """:obj:`str`: Transaction with Affiliate Program. + + .. versionadded:: 21.9 + """ + CHAT = "chat" + """:obj:`str`: Transaction with a chat. + + .. versionadded:: 21.11 + """ + FRAGMENT = "fragment" + """:obj:`str`: Withdrawal transaction with Fragment.""" + OTHER = "other" + """:obj:`str`: Transaction with unknown source or recipient.""" + TELEGRAM_ADS = "telegram_ads" + """:obj:`str`: Transaction with Telegram Ads.""" + TELEGRAM_API = "telegram_api" + """:obj:`str`: Transaction with with payment for + `paid broadcasting `_. + + .. versionadded:: 21.7 + """ + USER = "user" + """:obj:`str`: Transaction with a user.""" + + +class TransactionPartnerUser(StringEnum): + """This enum contains constants for :class:`telegram.TransactionPartnerUser`. + The enum members of this enumeration are instances of :class:`str` and can be treated as + such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + INVOICE_PAYMENT = "invoice_payment" + """:obj:`str`: Possible value for + :paramref:`telegram.TransactionPartnerUser.transaction_type`. + """ + PAID_MEDIA_PAYMENT = "paid_media_payment" + """:obj:`str`: Possible value for + :paramref:`telegram.TransactionPartnerUser.transaction_type`. + """ + GIFT_PURCHASE = "gift_purchase" + """:obj:`str`: Possible value for + :paramref:`telegram.TransactionPartnerUser.transaction_type`. + """ + PREMIUM_PURCHASE = "premium_purchase" + """:obj:`str`: Possible value for + :paramref:`telegram.TransactionPartnerUser.transaction_type`. + """ + BUSINESS_ACCOUNT_TRANSFER = "business_account_transfer" + """:obj:`str`: Possible value for + :paramref:`telegram.TransactionPartnerUser.transaction_type`. + """ + + +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. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MARKDOWN = "Markdown" + """:obj:`str`: Markdown parse mode. + + Note: + :attr:`MARKDOWN` is a legacy mode, retained by Telegram for backward compatibility. + You should use :attr:`MARKDOWN_V2` instead. + """ + MARKDOWN_V2 = "MarkdownV2" + """:obj:`str`: Markdown parse mode version 2.""" + HTML = "HTML" + """:obj:`str`: HTML parse mode.""" + + +class PollLimit(IntEnum): + """This enum contains limitations for :class:`telegram.Poll`/:class:`telegram.PollOption`/ + :meth:`telegram.Bot.send_poll`. The enum + members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_QUESTION_LENGTH = 1 + """:obj:`int`: Minimum value allowed for the :paramref:`~telegram.Poll.question` + parameter of :class:`telegram.Poll` and the :paramref:`~telegram.Bot.send_poll.question` + parameter of :meth:`telegram.Bot.send_poll`. + """ + MAX_QUESTION_LENGTH = 300 + """:obj:`int`: Maximum value allowed for the :paramref:`~telegram.Poll.question` + parameter of :class:`telegram.Poll` and the :paramref:`~telegram.Bot.send_poll.question` + parameter of :meth:`telegram.Bot.send_poll`. + """ + MIN_OPTION_LENGTH = 1 + """:obj:`int`: Minimum length of each :obj:`str` passed in a :obj:`list` + to the :paramref:`~telegram.Bot.send_poll.options` parameter of + :meth:`telegram.Bot.send_poll`. + """ + MAX_OPTION_LENGTH = 100 + """:obj:`int`: Maximum length of each :obj:`str` passed in a :obj:`list` + to the :paramref:`~telegram.Bot.send_poll.options` parameter of + :meth:`telegram.Bot.send_poll`. + """ + MIN_OPTION_NUMBER = 2 + """:obj:`int`: Minimum number of strings passed in a :obj:`list` + to the :paramref:`~telegram.Bot.send_poll.options` parameter of + :meth:`telegram.Bot.send_poll`. + """ + MAX_OPTION_NUMBER = 12 + """:obj:`int`: Maximum number of strings passed in a :obj:`list` + to the :paramref:`~telegram.Bot.send_poll.options` parameter of + :meth:`telegram.Bot.send_poll`. + + .. versionchanged:: 22.3 + This value was changed from ``10`` to ``12`` in accordance to Bot API 9.1. + """ + MAX_EXPLANATION_LENGTH = 200 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the + :paramref:`~telegram.Poll.explanation` parameter of :class:`telegram.Poll` and the + :paramref:`~telegram.Bot.send_poll.explanation` parameter of :meth:`telegram.Bot.send_poll`. + """ + MAX_EXPLANATION_LINE_FEEDS = 2 + """:obj:`int`: Maximum number of line feeds in a :obj:`str` passed as the + :paramref:`~telegram.Bot.send_poll.explanation` parameter of :meth:`telegram.Bot.send_poll` + after entities parsing. + """ + MIN_OPEN_PERIOD = 5 + """:obj:`int`: Minimum value allowed for the + :paramref:`~telegram.Bot.send_poll.open_period` parameter of :meth:`telegram.Bot.send_poll`. + Also used in the :paramref:`~telegram.Bot.send_poll.close_date` parameter of + :meth:`telegram.Bot.send_poll`. + """ + MAX_OPEN_PERIOD = 600 + """:obj:`int`: Maximum value allowed for the + :paramref:`~telegram.Bot.send_poll.open_period` parameter of :meth:`telegram.Bot.send_poll`. + Also used in the :paramref:`~telegram.Bot.send_poll.close_date` parameter of + :meth:`telegram.Bot.send_poll`. + """ + + +class PollType(StringEnum): + """This enum contains the available types for :class:`telegram.Poll`/ + :meth:`telegram.Bot.send_poll`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + REGULAR = "regular" + """:obj:`str`: regular polls.""" + QUIZ = "quiz" + """:obj:`str`: quiz polls.""" + + +class UniqueGiftInfoOrigin(StringEnum): + """This enum contains the available origins for :class:`telegram.UniqueGiftInfo`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 22.1 + """ + + __slots__ = () + + GIFTED_UPGRADE = "gifted_upgrade" + """:obj:`str` upgrades purchased after the gift was sent + + .. versionadded:: 22.6 + """ + OFFER = "OFFER" + """:obj:`str` gift bought or sold through gift purchase offers + + .. versionadded:: 22.6 + """ + RESALE = "resale" + """:obj:`str` gift bought from other users + + .. versionadded:: 22.3 + """ + TRANSFER = "transfer" + """:obj:`str` gift transfered""" + UPGRADE = "upgrade" + """:obj:`str` gift upgraded""" + + +class UpdateType(StringEnum): + """This enum contains the available types of :class:`telegram.Update`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MESSAGE = "message" + """:obj:`str`: Updates with :attr:`telegram.Update.message`.""" + EDITED_MESSAGE = "edited_message" + """:obj:`str`: Updates with :attr:`telegram.Update.edited_message`.""" + CHANNEL_POST = "channel_post" + """:obj:`str`: Updates with :attr:`telegram.Update.channel_post`.""" + EDITED_CHANNEL_POST = "edited_channel_post" + """:obj:`str`: Updates with :attr:`telegram.Update.edited_channel_post`.""" + INLINE_QUERY = "inline_query" + """:obj:`str`: Updates with :attr:`telegram.Update.inline_query`.""" + CHOSEN_INLINE_RESULT = "chosen_inline_result" + """:obj:`str`: Updates with :attr:`telegram.Update.chosen_inline_result`.""" + CALLBACK_QUERY = "callback_query" + """:obj:`str`: Updates with :attr:`telegram.Update.callback_query`.""" + SHIPPING_QUERY = "shipping_query" + """:obj:`str`: Updates with :attr:`telegram.Update.shipping_query`.""" + PRE_CHECKOUT_QUERY = "pre_checkout_query" + """:obj:`str`: Updates with :attr:`telegram.Update.pre_checkout_query`.""" + POLL = "poll" + """:obj:`str`: Updates with :attr:`telegram.Update.poll`.""" + POLL_ANSWER = "poll_answer" + """:obj:`str`: Updates with :attr:`telegram.Update.poll_answer`.""" + MY_CHAT_MEMBER = "my_chat_member" + """:obj:`str`: Updates with :attr:`telegram.Update.my_chat_member`.""" + CHAT_MEMBER = "chat_member" + """:obj:`str`: Updates with :attr:`telegram.Update.chat_member`.""" + CHAT_JOIN_REQUEST = "chat_join_request" + """:obj:`str`: Updates with :attr:`telegram.Update.chat_join_request`.""" + CHAT_BOOST = "chat_boost" + """:obj:`str`: Updates with :attr:`telegram.Update.chat_boost`. + + .. versionadded:: 20.8 + """ + REMOVED_CHAT_BOOST = "removed_chat_boost" + """:obj:`str`: Updates with :attr:`telegram.Update.removed_chat_boost`. + + .. versionadded:: 20.8 + """ + MESSAGE_REACTION = "message_reaction" + """:obj:`str`: Updates with :attr:`telegram.Update.message_reaction`. + + .. versionadded:: 20.8 + """ + MESSAGE_REACTION_COUNT = "message_reaction_count" + """:obj:`str`: Updates with :attr:`telegram.Update.message_reaction_count`. + + .. versionadded:: 20.8 + """ + BUSINESS_CONNECTION = "business_connection" + """:obj:`str`: Updates with :attr:`telegram.Update.business_connection`. + + .. versionadded:: 21.1 + """ + BUSINESS_MESSAGE = "business_message" + """:obj:`str`: Updates with :attr:`telegram.Update.business_message`. + + .. versionadded:: 21.1 + """ + EDITED_BUSINESS_MESSAGE = "edited_business_message" + """:obj:`str`: Updates with :attr:`telegram.Update.edited_business_message`. + + .. versionadded:: 21.1 + """ + DELETED_BUSINESS_MESSAGES = "deleted_business_messages" + """:obj:`str`: Updates with :attr:`telegram.Update.deleted_business_messages`. + + .. versionadded:: 21.1 + """ + PURCHASED_PAID_MEDIA = "purchased_paid_media" + """:obj:`str`: Updates with :attr:`telegram.Update.purchased_paid_media`. + + .. versionadded:: 21.6 + """ + + +class InvoiceLimit(IntEnum): + """This enum contains limitations for :class:`telegram.InputInvoiceMessageContent`, + :meth:`telegram.Bot.send_invoice`, and :meth:`telegram.Bot.create_invoice_link`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_TITLE_LENGTH = 1 + """:obj:`int`: Minimum number of characters in a :obj:`str` passed as: + + * :paramref:`~telegram.InputInvoiceMessageContent.title` parameter of + :class:`telegram.InputInvoiceMessageContent` + * :paramref:`~telegram.Bot.send_invoice.title` parameter of + :meth:`telegram.Bot.send_invoice`. + * :paramref:`~telegram.Bot.create_invoice_link.title` parameter of + :meth:`telegram.Bot.create_invoice_link`. + """ + MAX_TITLE_LENGTH = 32 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as: + + * :paramref:`~telegram.InputInvoiceMessageContent.title` parameter of + :class:`telegram.InputInvoiceMessageContent` + * :paramref:`~telegram.Bot.send_invoice.title` parameter of + :meth:`telegram.Bot.send_invoice`. + * :paramref:`~telegram.Bot.create_invoice_link.title` parameter of + :meth:`telegram.Bot.create_invoice_link`. + """ + MIN_DESCRIPTION_LENGTH = 1 + """:obj:`int`: Minimum number of characters in a :obj:`str` passed as: + + * :paramref:`~telegram.InputInvoiceMessageContent.description` parameter of + :class:`telegram.InputInvoiceMessageContent` + * :paramref:`~telegram.Bot.send_invoice.description` parameter of + :meth:`telegram.Bot.send_invoice`. + * :paramref:`~telegram.Bot.create_invoice_link.description` parameter of + :meth:`telegram.Bot.create_invoice_link`. + """ + MAX_DESCRIPTION_LENGTH = 255 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as: + + * :paramref:`~telegram.InputInvoiceMessageContent.description` parameter of + :class:`telegram.InputInvoiceMessageContent` + * :paramref:`~telegram.Bot.send_invoice.description` parameter of + :meth:`telegram.Bot.send_invoice`. + * :paramref:`~telegram.Bot.create_invoice_link.description` parameter of + :meth:`telegram.Bot.create_invoice_link`. + """ + MIN_PAYLOAD_LENGTH = 1 + """:obj:`int`: Minimum amount of bytes in a :obj:`str` passed as: + + * :paramref:`~telegram.InputInvoiceMessageContent.payload` parameter of + :class:`telegram.InputInvoiceMessageContent` + * :paramref:`~telegram.Bot.send_invoice.payload` parameter of + :meth:`telegram.Bot.send_invoice`. + * :paramref:`~telegram.Bot.create_invoice_link.payload` parameter of + :meth:`telegram.Bot.create_invoice_link`. + """ + MAX_PAYLOAD_LENGTH = 128 + """:obj:`int`: Maximum amount of bytes in a :obj:`str` passed as: + + * :paramref:`~telegram.InputInvoiceMessageContent.payload` parameter of + :class:`telegram.InputInvoiceMessageContent` + * :paramref:`~telegram.Bot.send_invoice.payload` parameter of + :meth:`telegram.Bot.send_invoice`. + * :paramref:`~telegram.Bot.create_invoice_link.payload` parameter of + :meth:`telegram.Bot.create_invoice_link`. + * :paramref:`~telegram.Bot.send_paid_media.payload` parameter of + :meth:`telegram.Bot.send_paid_media`. + """ + MAX_TIP_AMOUNTS = 4 + """:obj:`int`: Maximum length of a :obj:`Sequence` passed as: + + * :paramref:`~telegram.Bot.send_invoice.suggested_tip_amounts` parameter of + :meth:`telegram.Bot.send_invoice`. + * :paramref:`~telegram.Bot.create_invoice_link.suggested_tip_amounts` parameter of + :meth:`telegram.Bot.create_invoice_link`. + """ + MIN_STAR_COUNT = 1 + """:obj:`int`: Minimum amount of starts that must be paid to buy access to a paid media + passed as :paramref:`~telegram.Bot.send_paid_media.star_count` parameter of + :meth:`telegram.Bot.send_paid_media`. + + .. versionadded:: 21.6 + """ + MAX_STAR_COUNT = 25000 + """:obj:`int`: Maximum amount of starts that must be paid to buy access to a paid media + passed as :paramref:`~telegram.Bot.send_paid_media.star_count` parameter of + :meth:`telegram.Bot.send_paid_media`. + + .. versionadded:: 21.6 + .. versionchanged:: 22.1 + Bot API 9.0 changed the value to 10000. + .. versionchanged:: 22.6 + Bot API 9.3 changed the value to 25000. + """ + SUBSCRIPTION_PERIOD = dtm.timedelta(days=30).total_seconds() + """:obj:`int`: The period of time for which the subscription is active before + the next payment, passed as :paramref:`~telegram.Bot.create_invoice_link.subscription_period` + parameter of :meth:`telegram.Bot.create_invoice_link`. + + .. versionadded:: 21.8 + """ + SUBSCRIPTION_MAX_PRICE = 10000 + """:obj:`int`: The maximum price of a subscription created wtih + :meth:`telegram.Bot.create_invoice_link`. + + .. versionadded:: 21.9 + .. versionchanged:: 22.1 + Bot API 9.0 changed the value to 10000. + """ + + +class UserProfilePhotosLimit(IntEnum): + """This enum contains limitations for :paramref:`telegram.Bot.get_user_profile_photos.limit`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_LIMIT = 1 + """:obj:`int`: Minimum value allowed for + :paramref:`~telegram.Bot.get_user_profile_photos.limit` parameter of + :meth:`telegram.Bot.get_user_profile_photos`. + """ + MAX_LIMIT = 100 + """:obj:`int`: Maximum value allowed for + :paramref:`~telegram.Bot.get_user_profile_photos.limit` parameter of + :meth:`telegram.Bot.get_user_profile_photos`. + """ + + +class WebhookLimit(IntEnum): + """This enum contains limitations for :paramref:`telegram.Bot.set_webhook.max_connections` and + :paramref:`telegram.Bot.set_webhook.secret_token`. The enum members of this enumeration are + instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_CONNECTIONS_LIMIT = 1 + """:obj:`int`: Minimum value allowed for the + :paramref:`~telegram.Bot.set_webhook.max_connections` parameter of + :meth:`telegram.Bot.set_webhook`. + """ + MAX_CONNECTIONS_LIMIT = 100 + """:obj:`int`: Maximum value allowed for the + :paramref:`~telegram.Bot.set_webhook.max_connections` parameter of + :meth:`telegram.Bot.set_webhook`. + """ + MIN_SECRET_TOKEN_LENGTH = 1 + """:obj:`int`: Minimum length of the secret token for the + :paramref:`~telegram.Bot.set_webhook.secret_token` parameter of + :meth:`telegram.Bot.set_webhook`. + """ + MAX_SECRET_TOKEN_LENGTH = 256 + """:obj:`int`: Maximum length of the secret token for the + :paramref:`~telegram.Bot.set_webhook.secret_token` parameter of + :meth:`telegram.Bot.set_webhook`. + """ + + +class ForumTopicLimit(IntEnum): + """This enum contains limitations for :paramref:`telegram.Bot.create_forum_topic.name` and + :paramref:`telegram.Bot.edit_forum_topic.name`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 20.0 + """ + + __slots__ = () + + MIN_NAME_LENGTH = 1 + """:obj:`int`: Minimum length of a :obj:`str` passed as: + + * :paramref:`~telegram.Bot.create_forum_topic.name` parameter of + :meth:`telegram.Bot.create_forum_topic` + * :paramref:`~telegram.Bot.edit_forum_topic.name` parameter of + :meth:`telegram.Bot.edit_forum_topic` + * :paramref:`~telegram.Bot.edit_general_forum_topic.name` parameter of + :meth:`telegram.Bot.edit_general_forum_topic` + """ + MAX_NAME_LENGTH = 128 + """:obj:`int`: Maximum length of a :obj:`str` passed as: + + * :paramref:`~telegram.Bot.create_forum_topic.name` parameter of + :meth:`telegram.Bot.create_forum_topic` + * :paramref:`~telegram.Bot.edit_forum_topic.name` parameter of + :meth:`telegram.Bot.edit_forum_topic` + * :paramref:`~telegram.Bot.edit_general_forum_topic.name` parameter of + :meth:`telegram.Bot.edit_general_forum_topic` + """ + + +class SuggestedPostInfoState(StringEnum): + """This enum contains the available states of :attr:`telegram.SuggestedPostInfo.state`. + The enum members of this enumeration are instances + of :class:`str` and can be treated as such. + + .. versionadded:: 22.4 + """ + + __slots__ = () + + PENDING = "pending" + """:obj:`str`: Suggested post is pending.""" + APPROVED = "approved" + """:obj:`str`: Suggested post was approved.""" + DECLINED = "declined" + """:obj:`str`: Suggested post was declined. + """ + + +class ReactionType(StringEnum): + """This enum contains the available types of :class:`telegram.ReactionType`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.8 + """ + + __slots__ = () + + EMOJI = "emoji" + """:obj:`str`: A :class:`telegram.ReactionType` with a normal emoji.""" + CUSTOM_EMOJI = "custom_emoji" + """:obj:`str`: A :class:`telegram.ReactionType` with a custom emoji.""" + PAID = "paid" + """:obj:`str`: A :class:`telegram.ReactionType` with a paid reaction. + + .. versionadded:: 21.5 + """ + + +class ReactionEmoji(StringEnum): + """This enum contains the available emojis of :class:`telegram.ReactionTypeEmoji`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 20.8 + """ + + __slots__ = () + + THUMBS_UP = "👍" + """:obj:`str`: Thumbs Up""" + THUMBS_DOWN = "👎" + """:obj:`str`: Thumbs Down""" + RED_HEART = "❤" + """:obj:`str`: Red Heart""" + FIRE = "🔥" + """:obj:`str`: Fire""" + SMILING_FACE_WITH_HEARTS = "🥰" + """:obj:`str`: Smiling Face with Hearts""" + CLAPPING_HANDS = "👏" + """:obj:`str`: Clapping Hands""" + GRINNING_FACE_WITH_SMILING_EYES = "😁" + """:obj:`str`: Grinning face with smiling eyes""" + THINKING_FACE = "🤔" + """:obj:`str`: Thinking face""" + SHOCKED_FACE_WITH_EXPLODING_HEAD = "🤯" + """:obj:`str`: Shocked face with exploding head""" + FACE_SCREAMING_IN_FEAR = "😱" + """:obj:`str`: Face screaming in fear""" + SERIOUS_FACE_WITH_SYMBOLS_COVERING_MOUTH = "🤬" + """:obj:`str`: Serious face with symbols covering mouth""" + CRYING_FACE = "😢" + """:obj:`str`: Crying face""" + PARTY_POPPER = "🎉" + """:obj:`str`: Party popper""" + GRINNING_FACE_WITH_STAR_EYES = "🤩" + """:obj:`str`: Grinning face with star eyes""" + FACE_WITH_OPEN_MOUTH_VOMITING = "🤮" + """:obj:`str`: Face with open mouth vomiting""" + PILE_OF_POO = "💩" + """:obj:`str`: Pile of poo""" + PERSON_WITH_FOLDED_HANDS = "🙏" + """:obj:`str`: Person with folded hands""" + OK_HAND_SIGN = "👌" + """:obj:`str`: Ok hand sign""" + DOVE_OF_PEACE = "🕊" + """:obj:`str`: Dove of peace""" + CLOWN_FACE = "🤡" + """:obj:`str`: Clown face""" + YAWNING_FACE = "🥱" + """:obj:`str`: Yawning face""" + FACE_WITH_UNEVEN_EYES_AND_WAVY_MOUTH = "🥴" + """:obj:`str`: Face with uneven eyes and wavy mouth""" + SMILING_FACE_WITH_HEART_SHAPED_EYES = "😍" + """:obj:`str`: Smiling face with heart-shaped eyes""" + SPOUTING_WHALE = "🐳" + """:obj:`str`: Spouting whale""" + HEART_ON_FIRE = "❤️‍🔥" + """:obj:`str`: Heart on fire""" + NEW_MOON_WITH_FACE = "🌚" + """:obj:`str`: New moon with face""" + HOT_DOG = "🌭" + """:obj:`str`: Hot dog""" + HUNDRED_POINTS_SYMBOL = "💯" + """:obj:`str`: Hundred points symbol""" + ROLLING_ON_THE_FLOOR_LAUGHING = "🤣" + """:obj:`str`: Rolling on the floor laughing""" + HIGH_VOLTAGE_SIGN = "⚡" + """:obj:`str`: High voltage sign""" + BANANA = "🍌" + """:obj:`str`: Banana""" + TROPHY = "🏆" + """:obj:`str`: Trophy""" + BROKEN_HEART = "💔" + """:obj:`str`: Broken heart""" + FACE_WITH_ONE_EYEBROW_RAISED = "🤨" + """:obj:`str`: Face with one eyebrow raised""" + NEUTRAL_FACE = "😐" + """:obj:`str`: Neutral face""" + STRAWBERRY = "🍓" + """:obj:`str`: Strawberry""" + BOTTLE_WITH_POPPING_CORK = "🍾" + """:obj:`str`: Bottle with popping cork""" + KISS_MARK = "💋" + """:obj:`str`: Kiss mark""" + REVERSED_HAND_WITH_MIDDLE_FINGER_EXTENDED = "🖕" + """:obj:`str`: Reversed hand with middle finger extended""" + SMILING_FACE_WITH_HORNS = "😈" + """:obj:`str`: Smiling face with horns""" + SLEEPING_FACE = "😴" + """:obj:`str`: Sleeping face""" + LOUDLY_CRYING_FACE = "😭" + """:obj:`str`: Loudly crying face""" + NERD_FACE = "🤓" + """:obj:`str`: Nerd face""" + GHOST = "👻" + """:obj:`str`: Ghost""" + MAN_TECHNOLOGIST = "👨‍💻" + """:obj:`str`: Man Technologist""" + EYES = "👀" + """:obj:`str`: Eyes""" + JACK_O_LANTERN = "🎃" + """:obj:`str`: Jack-o-lantern""" + SEE_NO_EVIL_MONKEY = "🙈" + """:obj:`str`: See-no-evil monkey""" + SMILING_FACE_WITH_HALO = "😇" + """:obj:`str`: Smiling face with halo""" + FEARFUL_FACE = "😨" + """:obj:`str`: Fearful face""" + HANDSHAKE = "🤝" + """:obj:`str`: Handshake""" + WRITING_HAND = "✍" + """:obj:`str`: Writing hand""" + HUGGING_FACE = "🤗" + """:obj:`str`: Hugging face""" + SALUTING_FACE = "🫡" + """:obj:`str`: Saluting face""" + FATHER_CHRISTMAS = "🎅" + """:obj:`str`: Father christmas""" + CHRISTMAS_TREE = "🎄" + """:obj:`str`: Christmas tree""" + SNOWMAN = "☃" + """:obj:`str`: Snowman""" + NAIL_POLISH = "💅" + """:obj:`str`: Nail polish""" + GRINNING_FACE_WITH_ONE_LARGE_AND_ONE_SMALL_EYE = "🤪" + """:obj:`str`: Grinning face with one large and one small eye""" + MOYAI = "🗿" + """:obj:`str`: Moyai""" + SQUARED_COOL = "🆒" + """:obj:`str`: Squared cool""" + HEART_WITH_ARROW = "💘" + """:obj:`str`: Heart with arrow""" + HEAR_NO_EVIL_MONKEY = "🙉" + """:obj:`str`: Hear-no-evil monkey""" + UNICORN_FACE = "🦄" + """:obj:`str`: Unicorn face""" + FACE_THROWING_A_KISS = "😘" + """:obj:`str`: Face throwing a kiss""" + PILL = "💊" + """:obj:`str`: Pill""" + SPEAK_NO_EVIL_MONKEY = "🙊" + """:obj:`str`: Speak-no-evil monkey""" + SMILING_FACE_WITH_SUNGLASSES = "😎" + """:obj:`str`: Smiling face with sunglasses""" + ALIEN_MONSTER = "👾" + """:obj:`str`: Alien monster""" + MAN_SHRUGGING = "🤷‍♂️" + """:obj:`str`: Man Shrugging""" + SHRUG = "🤷" + """:obj:`str`: Shrug""" + WOMAN_SHRUGGING = "🤷‍♀️" + """:obj:`str`: Woman Shrugging""" + POUTING_FACE = "😡" + """:obj:`str`: Pouting face""" + + +class VerifyLimit(IntEnum): + """This enum contains limitations for :meth:`~telegram.Bot.verify_chat` and + :meth:`~telegram.Bot.verify_user`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 21.10 + """ + + __slots__ = () + + MAX_TEXT_LENGTH = 70 + """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the + :paramref:`~telegram.Bot.verify_chat.custom_description` or + :paramref:`~telegram.Bot.verify_user.custom_description` parameter. + """ diff --git a/telegram/error.py b/src/telegram/error.py similarity index 59% rename from telegram/error.py rename to src/telegram/error.py index 2334b005aa7..014bf8631b3 100644 --- a/telegram/error.py +++ b/src/telegram/error.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -22,10 +22,17 @@ Replaced ``Unauthorized`` by :class:`Forbidden`. """ +import datetime as dtm + +from telegram._utils.argumentparsing import to_timedelta +from telegram._utils.datetime import get_timedelta_value +from telegram._utils.types import TimePeriod + __all__ = ( "BadRequest", "ChatMigrated", "Conflict", + "EndPointNotFound", "Forbidden", "InvalidToken", "NetworkError", @@ -35,21 +42,6 @@ "TimedOut", ) -from typing import Optional, Tuple, Union - - -def _lstrip_str(in_s: str, lstr: str) -> str: - """ - Args: - in_s (:obj:`str`): in string - lstr (:obj:`str`): substr to strip from left side - - Returns: - :obj:`str`: The stripped string. - - """ - return in_s[len(lstr) :] if in_s.startswith(lstr) else in_s - class TelegramError(Exception): """ @@ -69,21 +61,39 @@ class TelegramError(Exception): def __init__(self, message: str): super().__init__() - msg = _lstrip_str(message, "Error: ") - msg = _lstrip_str(msg, "[Error]: ") - msg = _lstrip_str(msg, "Bad Request: ") + msg = message.removeprefix("Error: ") + msg = msg.removeprefix("[Error]: ") + msg = msg.removeprefix("Bad Request: ") if msg != message: # api_error - capitalize the msg... msg = msg.capitalize() self.message: str = msg def __str__(self) -> str: + """Gives the string representation of exceptions message. + + Returns: + :obj:`str` + """ return self.message def __repr__(self) -> str: + """Gives an unambiguous string representation of the exception. + + Returns: + :obj:`str` + """ return f"{self.__class__.__name__}('{self.message}')" - def __reduce__(self) -> Tuple[type, Tuple[str]]: + def __reduce__(self) -> tuple[type, tuple[str]]: + """Defines how to serialize the exception for pickle. + + .. seealso:: + :py:meth:`object.__reduce__`, :mod:`pickle`. + + Returns: + :obj:`tuple` + """ return self.__class__, (self.message,) @@ -111,15 +121,35 @@ class InvalidToken(TelegramError): __slots__ = () - def __init__(self, message: Optional[str] = None) -> None: + def __init__(self, message: str | None = None) -> None: super().__init__("Invalid token" if message is None else message) +class EndPointNotFound(TelegramError): + """Raised when the requested endpoint is not found. Only relevant for + :meth:`telegram.Bot.do_api_request`. + + .. versionadded:: 20.8 + """ + + __slots__ = () + + class NetworkError(TelegramError): """Base class for exceptions due to networking errors. + Tip: + This exception (and its subclasses) usually originates from the networking backend + used by :class:`~telegram.request.HTTPXRequest`, or a custom implementation of + :class:`~telegram.request.BaseRequest`. In this case, the original exception can be + accessed via the ``__cause__`` + `attribute `_. + Examples: :any:`Raw API Bot ` + + .. seealso:: + :wiki:`Handling network errors ` """ __slots__ = () @@ -134,6 +164,9 @@ class BadRequest(NetworkError): class TimedOut(NetworkError): """Raised when a request took too long to finish. + .. seealso:: + :wiki:`Handling network errors ` + Args: message (:obj:`str`, optional): Any additional information about the exception. @@ -142,7 +175,7 @@ class TimedOut(NetworkError): __slots__ = () - def __init__(self, message: Optional[str] = None) -> None: + def __init__(self, message: str | None = None) -> None: super().__init__(message or "Timed out") @@ -167,7 +200,7 @@ def __init__(self, new_chat_id: int): super().__init__(f"Group migrated to supergroup. New chat id: {new_chat_id}") self.new_chat_id: int = new_chat_id - def __reduce__(self) -> Tuple[type, Tuple[int]]: # type: ignore[override] + def __reduce__(self) -> tuple[type, tuple[int]]: # type: ignore[override] return self.__class__, (self.new_chat_id,) @@ -179,21 +212,42 @@ class RetryAfter(TelegramError): :attr:`retry_after` is now an integer to comply with the Bot API. Args: - retry_after (:obj:`int`): Time in seconds, after which the bot can retry the request. + retry_after (:obj:`int` | :class:`datetime.timedelta`): Time in seconds, after which the + bot can retry the request. + + .. versionchanged:: v22.2 + |time-period-input| Attributes: - retry_after (:obj:`int`): Time in seconds, after which the bot can retry the request. + retry_after (:obj:`int` | :class:`datetime.timedelta`): Time in seconds, after which the + bot can retry the request. + + .. deprecated:: v22.2 + |time-period-int-deprecated| """ - __slots__ = ("retry_after",) + __slots__ = ("_retry_after",) + + def __init__(self, retry_after: TimePeriod): + self._retry_after: dtm.timedelta = to_timedelta(retry_after) + + if isinstance(self.retry_after, int): + super().__init__(f"Flood control exceeded. Retry in {self.retry_after} seconds") + else: + super().__init__(f"Flood control exceeded. Retry in {self.retry_after!s}") - def __init__(self, retry_after: int): - super().__init__(f"Flood control exceeded. Retry in {retry_after} seconds") - self.retry_after: int = retry_after + @property + def retry_after(self) -> int | dtm.timedelta: # noqa: D102 + # Diableing D102 because docstring for `retry_after` is present at the class's level + return get_timedelta_value( # type: ignore[return-value] + self._retry_after, attribute="retry_after" + ) - def __reduce__(self) -> Tuple[type, Tuple[float]]: # type: ignore[override] - return self.__class__, (self.retry_after,) + def __reduce__(self) -> tuple[type, tuple[float]]: # type: ignore[override] + # Until support for `int` time periods is lifted, leave pickle behaviour the same + # tag: deprecated: v22.2 + return self.__class__, (int(self._retry_after.total_seconds()),) class Conflict(TelegramError): @@ -201,7 +255,7 @@ class Conflict(TelegramError): __slots__ = () - def __reduce__(self) -> Tuple[type, Tuple[str]]: + def __reduce__(self) -> tuple[type, tuple[str]]: return self.__class__, (self.message,) @@ -215,9 +269,9 @@ class PassportDecryptionError(TelegramError): __slots__ = ("_msg",) - def __init__(self, message: Union[str, Exception]): + def __init__(self, message: str | Exception): super().__init__(f"PassportDecryptionError: {message}") self._msg = str(message) - def __reduce__(self) -> Tuple[type, Tuple[str]]: + def __reduce__(self) -> tuple[type, tuple[str]]: return self.__class__, (self._msg,) diff --git a/telegram/ext/__init__.py b/src/telegram/ext/__init__.py similarity index 61% rename from telegram/ext/__init__.py rename to src/telegram/ext/__init__.py index a6abdb974e9..4c7fa9f5a8c 100644 --- a/telegram/ext/__init__.py +++ b/src/telegram/ext/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -27,9 +27,12 @@ "BasePersistence", "BaseRateLimiter", "BaseUpdateProcessor", + "BusinessConnectionHandler", + "BusinessMessagesDeletedHandler", "CallbackContext", "CallbackDataCache", "CallbackQueryHandler", + "ChatBoostHandler", "ChatJoinRequestHandler", "ChatMemberHandler", "ChosenInlineResultHandler", @@ -39,12 +42,13 @@ "Defaults", "DictPersistence", "ExtBot", - "filters", "InlineQueryHandler", "InvalidCallbackData", "Job", "JobQueue", "MessageHandler", + "MessageReactionHandler", + "PaidMediaPurchasedHandler", "PersistenceInput", "PicklePersistence", "PollAnswerHandler", @@ -57,6 +61,7 @@ "StringRegexHandler", "TypeHandler", "Updater", + "filters", ) from . import filters @@ -68,27 +73,32 @@ from ._baseupdateprocessor import BaseUpdateProcessor, SimpleUpdateProcessor from ._callbackcontext import CallbackContext from ._callbackdatacache import CallbackDataCache, InvalidCallbackData -from ._callbackqueryhandler import CallbackQueryHandler -from ._chatjoinrequesthandler import ChatJoinRequestHandler -from ._chatmemberhandler import ChatMemberHandler -from ._choseninlineresulthandler import ChosenInlineResultHandler -from ._commandhandler import CommandHandler from ._contexttypes import ContextTypes -from ._conversationhandler import ConversationHandler from ._defaults import Defaults from ._dictpersistence import DictPersistence from ._extbot import ExtBot -from ._handler import BaseHandler -from ._inlinequeryhandler import InlineQueryHandler +from ._handlers.basehandler import BaseHandler +from ._handlers.businessconnectionhandler import BusinessConnectionHandler +from ._handlers.businessmessagesdeletedhandler import BusinessMessagesDeletedHandler +from ._handlers.callbackqueryhandler import CallbackQueryHandler +from ._handlers.chatboosthandler import ChatBoostHandler +from ._handlers.chatjoinrequesthandler import ChatJoinRequestHandler +from ._handlers.chatmemberhandler import ChatMemberHandler +from ._handlers.choseninlineresulthandler import ChosenInlineResultHandler +from ._handlers.commandhandler import CommandHandler +from ._handlers.conversationhandler import ConversationHandler +from ._handlers.inlinequeryhandler import InlineQueryHandler +from ._handlers.messagehandler import MessageHandler +from ._handlers.messagereactionhandler import MessageReactionHandler +from ._handlers.paidmediapurchasedhandler import PaidMediaPurchasedHandler +from ._handlers.pollanswerhandler import PollAnswerHandler +from ._handlers.pollhandler import PollHandler +from ._handlers.precheckoutqueryhandler import PreCheckoutQueryHandler +from ._handlers.prefixhandler import PrefixHandler +from ._handlers.shippingqueryhandler import ShippingQueryHandler +from ._handlers.stringcommandhandler import StringCommandHandler +from ._handlers.stringregexhandler import StringRegexHandler +from ._handlers.typehandler import TypeHandler from ._jobqueue import Job, JobQueue -from ._messagehandler import MessageHandler from ._picklepersistence import PicklePersistence -from ._pollanswerhandler import PollAnswerHandler -from ._pollhandler import PollHandler -from ._precheckoutqueryhandler import PreCheckoutQueryHandler -from ._prefixhandler import PrefixHandler -from ._shippingqueryhandler import ShippingQueryHandler -from ._stringcommandhandler import StringCommandHandler -from ._stringregexhandler import StringRegexHandler -from ._typehandler import TypeHandler from ._updater import Updater diff --git a/telegram/ext/_aioratelimiter.py b/src/telegram/ext/_aioratelimiter.py similarity index 70% rename from telegram/ext/_aioratelimiter.py rename to src/telegram/ext/_aioratelimiter.py index b211ed8f77d..05e7a153c01 100644 --- a/telegram/ext/_aioratelimiter.py +++ b/src/telegram/ext/_aioratelimiter.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,10 +19,11 @@ """This module contains an implementation of the BaseRateLimiter class based on the aiolimiter library. """ + import asyncio import contextlib -import sys -from typing import Any, AsyncIterator, Callable, Coroutine, Dict, List, Optional, Union +from collections.abc import Callable, Coroutine +from typing import Any try: from aiolimiter import AsyncLimiter @@ -31,6 +32,7 @@ except ImportError: AIO_LIMITER_AVAILABLE = False +from telegram import constants from telegram._utils.logging import get_logger from telegram._utils.types import JSONDict from telegram.error import RetryAfter @@ -39,13 +41,7 @@ # Useful for something like: # async with group_limiter if group else null_context(): # so we don't have to differentiate between "I'm using a context manager" and "I'm not" -if sys.version_info >= (3, 10): - null_context = contextlib.nullcontext # pylint: disable=invalid-name -else: - - @contextlib.asynccontextmanager - async def null_context() -> AsyncIterator[None]: - yield None +null_context = contextlib.nullcontext # pylint: disable=invalid-name _LOGGER = get_logger(__name__, class_name="AIORateLimiter") @@ -62,7 +58,7 @@ class AIORateLimiter(BaseRateLimiter[int]): .. code-block:: bash - pip install python-telegram-bot[rate-limiter] + pip install "python-telegram-bot[rate-limiter]" The rate limiting is applied by combining two levels of throttling and :meth:`process_request` roughly boils down to:: @@ -85,7 +81,21 @@ class AIORateLimiter(BaseRateLimiter[int]): * A :exc:`~telegram.error.RetryAfter` exception will halt *all* requests for :attr:`~telegram.error.RetryAfter.retry_after` + 0.1 seconds. This may be stricter than necessary in some cases, e.g. the bot may hit a rate limit in one group but might still - be allowed to send messages in another group. + be allowed to send messages in another group or with + :paramref:`~telegram.Bot.send_message.allow_paid_broadcast` set to :obj:`True`. + + Tip: + With `Bot API 7.1 `_ + (PTB v27.1), Telegram introduced the parameter + :paramref:`~telegram.Bot.send_message.allow_paid_broadcast`. + This allows bots to send up to + :tg-const:`telegram.constants.FloodLimit.PAID_MESSAGES_PER_SECOND` messages per second by + paying a fee in Telegram Stars. + + .. versionchanged:: 21.11 + This class automatically takes the + :paramref:`~telegram.Bot.send_message.allow_paid_broadcast` parameter into account and + throttles the requests accordingly. Note: This class is to be understood as minimal effort reference implementation. @@ -100,16 +110,17 @@ class AIORateLimiter(BaseRateLimiter[int]): Args: overall_max_rate (:obj:`float`): The maximum number of requests allowed for the entire bot per :paramref:`overall_time_period`. When set to 0, no rate limiting will be applied. - Defaults to ``30``. + Defaults to :tg-const:`telegram.constants.FloodLimit.MESSAGES_PER_SECOND`. overall_time_period (:obj:`float`): The time period (in seconds) during which the :paramref:`overall_max_rate` is enforced. When set to 0, no rate limiting will be - applied. Defaults to 1. + applied. Defaults to ``1``. group_max_rate (:obj:`float`): The maximum number of requests allowed for requests related to groups and channels per :paramref:`group_time_period`. When set to 0, no rate - limiting will be applied. Defaults to 20. + limiting will be applied. Defaults to + :tg-const:`telegram.constants.FloodLimit.MESSAGES_PER_MINUTE_PER_GROUP`. group_time_period (:obj:`float`): The time period (in seconds) during which the :paramref:`group_max_rate` is enforced. When set to 0, no rate limiting will be - applied. Defaults to 60. + applied. Defaults to ``60``. max_retries (:obj:`int`): The maximum number of retries to be made in case of a :exc:`~telegram.error.RetryAfter` exception. If set to 0, no retries will be made. Defaults to ``0``. @@ -117,6 +128,7 @@ class AIORateLimiter(BaseRateLimiter[int]): """ __slots__ = ( + "_apb_limiter", "_base_limiter", "_group_limiters", "_group_max_rate", @@ -127,19 +139,19 @@ class AIORateLimiter(BaseRateLimiter[int]): def __init__( self, - overall_max_rate: float = 30, + overall_max_rate: float = constants.FloodLimit.MESSAGES_PER_SECOND, overall_time_period: float = 1, - group_max_rate: float = 20, + group_max_rate: float = constants.FloodLimit.MESSAGES_PER_MINUTE_PER_GROUP, group_time_period: float = 60, max_retries: int = 0, ) -> None: if not AIO_LIMITER_AVAILABLE: raise RuntimeError( "To use `AIORateLimiter`, PTB must be installed via `pip install " - "python-telegram-bot[rate-limiter]`." + '"python-telegram-bot[rate-limiter]"`.' ) if overall_max_rate and overall_time_period: - self._base_limiter: Optional[AsyncLimiter] = AsyncLimiter( + self._base_limiter: AsyncLimiter | None = AsyncLimiter( max_rate=overall_max_rate, time_period=overall_time_period ) else: @@ -152,7 +164,10 @@ def __init__( self._group_max_rate = 0 self._group_time_period = 0 - self._group_limiters: Dict[Union[str, int], AsyncLimiter] = {} + self._group_limiters: dict[str | int, AsyncLimiter] = {} + self._apb_limiter: AsyncLimiter = AsyncLimiter( + max_rate=constants.FloodLimit.PAID_MESSAGES_PER_SECOND, time_period=1 + ) self._max_retries: int = max_retries self._retry_after_event = asyncio.Event() self._retry_after_event.set() @@ -163,7 +178,7 @@ async def initialize(self) -> None: async def shutdown(self) -> None: """Does nothing.""" - def _get_group_limiter(self, group_id: Union[str, int, bool]) -> "AsyncLimiter": + def _get_group_limiter(self, group_id: str | int | bool) -> "AsyncLimiter": # Remove limiters that haven't been used for so long that all their capacity is unused # We only do that if we have a lot of limiters lying around to avoid looping on every call # This is a minimal effort approach - a full-fledged cache could use a TTL approach @@ -186,33 +201,41 @@ def _get_group_limiter(self, group_id: Union[str, int, bool]) -> "AsyncLimiter": async def _run_request( self, chat: bool, - group: Union[str, int, bool], - callback: Callable[..., Coroutine[Any, Any, Union[bool, JSONDict, List[JSONDict]]]], + group: str | int | bool, + allow_paid_broadcast: bool, + callback: Callable[..., Coroutine[Any, Any, bool | JSONDict | list[JSONDict]]], args: Any, - kwargs: Dict[str, Any], - ) -> Union[bool, JSONDict, List[JSONDict]]: - base_context = self._base_limiter if (chat and self._base_limiter) else null_context() - group_context = ( - self._get_group_limiter(group) if group and self._group_max_rate else null_context() - ) - - async with group_context: # skipcq: PTC-W0062 - async with base_context: - # In case a retry_after was hit, we wait with processing the request - await self._retry_after_event.wait() + kwargs: dict[str, Any], + ) -> bool | JSONDict | list[JSONDict]: + async def inner() -> bool | JSONDict | list[JSONDict]: + # In case a retry_after was hit, we wait with processing the request + await self._retry_after_event.wait() + return await callback(*args, **kwargs) + + if allow_paid_broadcast: + async with self._apb_limiter: + return await inner() + else: + base_context = self._base_limiter if (chat and self._base_limiter) else null_context() + group_context = ( + self._get_group_limiter(group) + if group and self._group_max_rate + else null_context() + ) - return await callback(*args, **kwargs) + async with group_context, base_context: + return await inner() # mypy doesn't understand that the last run of the for loop raises an exception async def process_request( self, - callback: Callable[..., Coroutine[Any, Any, Union[bool, JSONDict, List[JSONDict]]]], + callback: Callable[..., Coroutine[Any, Any, bool | JSONDict | list[JSONDict]]], args: Any, - kwargs: Dict[str, Any], - endpoint: str, # skipcq: PYL-W0613 - data: Dict[str, Any], - rate_limit_args: Optional[int], - ) -> Union[bool, JSONDict, List[JSONDict]]: + kwargs: dict[str, Any], + endpoint: str, # noqa: ARG002 + data: dict[str, Any], + rate_limit_args: int | None, + ) -> bool | JSONDict | list[JSONDict]: """ Processes a request by applying rate limiting. @@ -226,15 +249,16 @@ async def process_request( """ max_retries = rate_limit_args or self._max_retries - group: Union[int, str, bool] = False + group: int | str | bool = False chat: bool = False chat_id = data.get("chat_id") + allow_paid_broadcast = data.get("allow_paid_broadcast", False) if chat_id is not None: chat = True # In case user passes integer chat id as string with contextlib.suppress(ValueError, TypeError): - chat_id = int(chat_id) + chat_id = int(chat_id) # type: ignore[arg-type] if (isinstance(chat_id, int) and chat_id < 0) or isinstance(chat_id, str): # string chat_id only works for channels and supergroups @@ -244,16 +268,21 @@ async def process_request( for i in range(max_retries + 1): try: return await self._run_request( - chat=chat, group=group, callback=callback, args=args, kwargs=kwargs + chat=chat, + group=group, + allow_paid_broadcast=allow_paid_broadcast, + callback=callback, + args=args, + kwargs=kwargs, ) except RetryAfter as exc: if i == max_retries: _LOGGER.exception( "Rate limit hit after maximum of %d retries", max_retries, exc_info=exc ) - raise exc + raise - sleep = exc.retry_after + 0.1 + sleep = exc._retry_after.total_seconds() + 0.1 # pylint: disable=protected-access _LOGGER.info("Rate limit hit. Retrying after %f seconds", sleep) # Make sure we don't allow other requests to be processed self._retry_after_event.clear() diff --git a/telegram/ext/_application.py b/src/telegram/ext/_application.py similarity index 70% rename from telegram/ext/_application.py rename to src/telegram/ext/_application.py index 15178d9176f..d4a216e6b27 100644 --- a/telegram/ext/_application.py +++ b/src/telegram/ext/_application.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,59 +17,53 @@ # 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 the Application class.""" + import asyncio import contextlib +import datetime as dtm import inspect import itertools import platform import signal +import sys from collections import defaultdict +from collections.abc import Awaitable, Callable, Coroutine, Generator, Mapping, Sequence from copy import deepcopy from pathlib import Path from types import MappingProxyType, TracebackType -from typing import ( - TYPE_CHECKING, - Any, - AsyncContextManager, - Awaitable, - Callable, - Coroutine, - DefaultDict, - Dict, - Generator, - Generic, - List, - Mapping, - NoReturn, - Optional, - Sequence, - Set, - Tuple, - Type, - TypeVar, - Union, -) +from typing import TYPE_CHECKING, Any, Generic, NoReturn, TypeAlias, TypeVar from telegram._update import Update -from telegram._utils.defaultvalue import DEFAULT_NONE, DEFAULT_TRUE, DefaultValue +from telegram._utils.defaultvalue import ( + DEFAULT_80, + DEFAULT_IP, + DEFAULT_NONE, + DEFAULT_TRUE, + DefaultValue, +) from telegram._utils.logging import get_logger -from telegram._utils.types import SCT, DVType, ODVInput +from telegram._utils.repr import build_repr_with_selected_attrs +from telegram._utils.types import SCT, DVType, ODVInput, TimePeriod from telegram._utils.warnings import warn from telegram.error import TelegramError from telegram.ext._basepersistence import BasePersistence -from telegram.ext._baseupdateprocessor import BaseUpdateProcessor from telegram.ext._contexttypes import ContextTypes from telegram.ext._extbot import ExtBot -from telegram.ext._handler import BaseHandler +from telegram.ext._handlers.basehandler import BaseHandler from telegram.ext._updater import Updater +from telegram.ext._utils.networkloop import network_retry_loop from telegram.ext._utils.stack import was_called_by from telegram.ext._utils.trackingdict import TrackingDict from telegram.ext._utils.types import BD, BT, CCT, CD, JQ, RT, UD, ConversationKey, HandlerCallback +from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: + from socket import socket + from telegram import Message from telegram.ext import ConversationHandler, JobQueue from telegram.ext._applicationbuilder import InitApplicationBuilder + from telegram.ext._baseupdateprocessor import BaseUpdateProcessor from telegram.ext._jobqueue import Job DEFAULT_GROUP: int = 0 @@ -78,6 +72,15 @@ _STOP_SIGNAL = object() _DEFAULT_0 = DefaultValue(0) +# Since python 3.12, the coroutine passed to create_task should not be an (async) generator. Remove +# this check when we drop support for python 3.11. +if sys.version_info >= (3, 12): + _CoroType = Awaitable[RT] +else: + _CoroType: TypeAlias = Generator["asyncio.Future[object]", None, RT] | Awaitable[RT] + +_ErrorCoroType: TypeAlias = _CoroType[RT] | None + _LOGGER = get_logger(__name__) @@ -107,12 +110,15 @@ async def conversation_callback(update, context): __slots__ = ("state",) - def __init__(self, state: Optional[object] = None) -> None: + def __init__(self, state: object | None = None) -> None: super().__init__() - self.state: Optional[object] = state + self.state: object | None = state -class Application(Generic[BT, CCT, UD, CD, BD, JQ], AsyncContextManager["Application"]): +class Application( + contextlib.AbstractAsyncContextManager["Application"], + Generic[BT, CCT, UD, CD, BD, JQ], +): """This class dispatches all kinds of updates to its registered handlers, and is the entry point to a PTB application. @@ -137,6 +143,20 @@ class Application(Generic[BT, CCT, UD, CD, BD, JQ], AsyncContextManager["Applica finally: await application.shutdown() + .. seealso:: :meth:`__aenter__` and :meth:`__aexit__`. + + This class is a :class:`~typing.Generic` class and accepts six type variables: + + 1. The type of :attr:`bot`. Must be :class:`telegram.Bot` or a subclass of that class. + 2. The type of the argument ``context`` of callback functions for (error) handlers and jobs. + Must be :class:`telegram.ext.CallbackContext` or a subclass of that class. This must be + consistent with the following types. + 3. The type of the values of :attr:`user_data`. + 4. The type of the values of :attr:`chat_data`. + 5. The type of :attr:`bot_data`. + 6. The type of :attr:`job_queue`. Must either be :class:`telegram.ext.JobQueue` or a subclass + of that or :obj:`None`. + Examples: :any:`Echo Bot ` @@ -190,12 +210,12 @@ class Application(Generic[BT, CCT, UD, CD, BD, JQ], AsyncContextManager["Applica bot_data (:obj:`dict`): A dictionary handlers can use to store data for the bot. persistence (:class:`telegram.ext.BasePersistence`): The persistence class to store data that should be persistent over restarts. - handlers (Dict[:obj:`int`, List[:class:`telegram.ext.BaseHandler`]]): A dictionary mapping + handlers (dict[:obj:`int`, list[:class:`telegram.ext.BaseHandler`]]): A dictionary mapping each handler group to the list of handlers registered to that group. .. seealso:: :meth:`add_handler`, :meth:`add_handlers`. - error_handlers (Dict[:term:`coroutine function`, :obj:`bool`]): A dictionary where the keys + error_handlers (dict[:term:`coroutine function`, :obj:`bool`]): A dictionary where the keys are error handlers and the values indicate whether they are to be run blocking. .. seealso:: @@ -217,39 +237,42 @@ 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", + ( + "__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__( @@ -257,20 +280,20 @@ def __init__( *, bot: BT, update_queue: "asyncio.Queue[object]", - updater: Optional[Updater], + updater: Updater | None, job_queue: JQ, update_processor: "BaseUpdateProcessor", - persistence: Optional[BasePersistence[UD, CD, BD]], + persistence: BasePersistence[UD, CD, BD] | None, context_types: ContextTypes[CCT, UD, CD, BD], - post_init: Optional[ - Callable[["Application[BT, CCT, UD, CD, BD, JQ]"], Coroutine[Any, Any, None]] - ], - post_shutdown: Optional[ - Callable[["Application[BT, CCT, UD, CD, BD, JQ]"], Coroutine[Any, Any, None]] - ], - post_stop: Optional[ - Callable[["Application[BT, CCT, UD, CD, BD, JQ]"], Coroutine[Any, Any, None]] - ], + post_init: ( + Callable[["Application[BT, CCT, UD, CD, BD, JQ]"], Coroutine[Any, Any, None]] | None + ), + post_shutdown: ( + Callable[["Application[BT, CCT, UD, CD, BD, JQ]"], Coroutine[Any, Any, None]] | None + ), + post_stop: ( + Callable[["Application[BT, CCT, UD, CD, BD, JQ]"], Coroutine[Any, Any, None]] | None + ), ): if not was_called_by( inspect.currentframe(), Path(__file__).parent.resolve() / "_applicationbuilder.py" @@ -281,44 +304,44 @@ def __init__( ) self.bot: BT = bot - self.update_queue: "asyncio.Queue[object]" = update_queue + self.update_queue: asyncio.Queue[object] = update_queue self.context_types: ContextTypes[CCT, UD, CD, BD] = context_types - self.updater: Optional[Updater] = updater - self.handlers: Dict[int, List[BaseHandler[Any, CCT]]] = {} - self.error_handlers: Dict[ - HandlerCallback[object, CCT, None], Union[bool, DefaultValue[bool]] + self.updater: Updater | None = updater + self.handlers: dict[int, list[BaseHandler[Any, CCT, Any]]] = {} + self.error_handlers: dict[ + HandlerCallback[object, CCT, None], bool | DefaultValue[bool] ] = {} - self.post_init: Optional[ - Callable[["Application[BT, CCT, UD, CD, BD, JQ]"], Coroutine[Any, Any, None]] - ] = post_init - self.post_shutdown: Optional[ - Callable[["Application[BT, CCT, UD, CD, BD, JQ]"], Coroutine[Any, Any, None]] - ] = post_shutdown - self.post_stop: Optional[ - Callable[["Application[BT, CCT, UD, CD, BD, JQ]"], Coroutine[Any, Any, None]] - ] = post_stop + self.post_init: ( + Callable[[Application[BT, CCT, UD, CD, BD, JQ]], Coroutine[Any, Any, None]] | None + ) = post_init + self.post_shutdown: ( + Callable[[Application[BT, CCT, UD, CD, BD, JQ]], Coroutine[Any, Any, None]] | None + ) = post_shutdown + self.post_stop: ( + Callable[[Application[BT, CCT, UD, CD, BD, JQ]], Coroutine[Any, Any, None]] | None + ) = post_stop self._update_processor = update_processor self.bot_data: BD = self.context_types.bot_data() - self._user_data: DefaultDict[int, UD] = defaultdict(self.context_types.user_data) - self._chat_data: DefaultDict[int, CD] = defaultdict(self.context_types.chat_data) + self._user_data: defaultdict[int, UD] = defaultdict(self.context_types.user_data) + self._chat_data: defaultdict[int, CD] = defaultdict(self.context_types.chat_data) # Read only mapping self.user_data: Mapping[int, UD] = MappingProxyType(self._user_data) self.chat_data: Mapping[int, CD] = MappingProxyType(self._chat_data) - self.persistence: Optional[BasePersistence[UD, CD, BD]] = None + self.persistence: BasePersistence[UD, CD, BD] | None = None if persistence and not isinstance(persistence, BasePersistence): raise TypeError("persistence must be based on telegram.ext.BasePersistence") self.persistence = persistence # Some bookkeeping for persistence logic - self._chat_ids_to_be_updated_in_persistence: Set[int] = set() - self._user_ids_to_be_updated_in_persistence: Set[int] = set() - self._chat_ids_to_be_deleted_in_persistence: Set[int] = set() - self._user_ids_to_be_deleted_in_persistence: Set[int] = set() + self._chat_ids_to_be_updated_in_persistence: set[int] = set() + self._user_ids_to_be_updated_in_persistence: set[int] = set() + self._chat_ids_to_be_deleted_in_persistence: set[int] = set() + self._user_ids_to_be_deleted_in_persistence: set[int] = set() # This attribute will hold references to the conversation dicts of all conversation # handlers so that we can extract the changed states during `update_persistence` - self._conversation_handler_conversations: Dict[ + self._conversation_handler_conversations: dict[ str, TrackingDict[ConversationKey, object] ] = {} @@ -326,17 +349,51 @@ def __init__( self._initialized = False self._running = False self._job_queue: JQ = job_queue - self.__update_fetcher_task: Optional[asyncio.Task] = None - self.__update_persistence_task: Optional[asyncio.Task] = None + self.__update_fetcher_task: asyncio.Task | None = None + self.__update_persistence_task: asyncio.Task | None = None self.__update_persistence_event = asyncio.Event() self.__update_persistence_lock = asyncio.Lock() - self.__create_task_tasks: Set[asyncio.Task] = set() # Used for awaiting tasks upon exit + self.__create_task_tasks: set[asyncio.Task] = set() # Used for awaiting tasks upon exit + self.__stop_running_marker = asyncio.Event() - def _check_initialized(self) -> None: - if not self._initialized: - raise RuntimeError( - "This Application was not initialized via `Application.initialize`!" - ) + async def __aenter__(self: _AppType) -> _AppType: + """|async_context_manager| :meth:`initializes ` the App. + + Returns: + The initialized App instance. + + Raises: + :exc:`Exception`: If an exception is raised during initialization, :meth:`shutdown` + is called in this case. + """ + try: + await self.initialize() + except Exception: + await self.shutdown() + raise + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """|async_context_manager| :meth:`shuts down ` the App.""" + # Make sure not to return `True` so that exceptions are not suppressed + # https://docs.python.org/3/reference/datamodel.html?#object.__aexit__ + await self.shutdown() + + def __repr__(self) -> str: + """Give a string representation of the application in the form ``Application[bot=...]``. + + As this class doesn't implement :meth:`object.__str__`, the default implementation + will be used, which is equivalent to :meth:`__repr__`. + + Returns: + :obj:`str` + """ + return build_repr_with_selected_attrs(self, bot=self.bot) @property def running(self) -> bool: @@ -352,7 +409,7 @@ def concurrent_updates(self) -> int: """:obj:`int`: The number of concurrent updates that will be processed in parallel. A value of ``0`` indicates updates are *not* being processed concurrently. - .. versionchanged:: NEXT.VERSION + .. versionchanged:: 20.4 This is now just a shortcut to :attr:`update_processor.max_concurrent_updates `. @@ -361,17 +418,17 @@ def concurrent_updates(self) -> int: return self._update_processor.max_concurrent_updates @property - def job_queue(self) -> Optional["JobQueue[CCT]"]: + def job_queue(self) -> "JobQueue[CCT] | None": """ :class:`telegram.ext.JobQueue`: The :class:`JobQueue` used by the :class:`telegram.ext.Application`. - .. seealso:: :wiki:`Job Queue ` + .. seealso:: :wiki:`Job Queue ` """ if self._job_queue is None: warn( "No `JobQueue` set up. To use `JobQueue`, you must install PTB via " - "`pip install python-telegram-bot[job-queue]`.", + '`pip install "python-telegram-bot[job-queue]"`.', stacklevel=2, ) return self._job_queue @@ -383,10 +440,33 @@ def update_processor(self) -> "BaseUpdateProcessor": .. seealso:: :wiki:`Concurrency` - .. versionadded:: NEXT.VERSION + .. versionadded:: 20.4 """ return self._update_processor + @staticmethod + def _raise_system_exit() -> NoReturn: + raise SystemExit + + @staticmethod + def builder() -> "InitApplicationBuilder": + """Convenience method. Returns a new :class:`telegram.ext.ApplicationBuilder`. + + .. versionadded:: 20.0 + """ + # Unfortunately this needs to be here due to cyclical imports + from telegram.ext import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + ApplicationBuilder, + ) + + return ApplicationBuilder() + + def _check_initialized(self) -> None: + if not self._initialized: + raise RuntimeError( + "This Application was not initialized via `Application.initialize`!" + ) + async def initialize(self) -> None: """Initializes the Application by initializing: @@ -420,7 +500,7 @@ async def initialize(self) -> None: # Unfortunately due to circular imports this has to be here # pylint: disable=import-outside-toplevel - from telegram.ext._conversationhandler import ConversationHandler + from telegram.ext._handlers.conversationhandler import ConversationHandler # noqa: PLC0415 # Initialize the persistent conversation handlers with the stored states for handler in itertools.chain.from_iterable(self.handlers.values()): @@ -428,6 +508,7 @@ async def initialize(self) -> None: await self._add_ch_to_persistence(handler) self._initialized = True + self.__stop_running_marker.clear() async def _add_ch_to_persistence(self, handler: "ConversationHandler") -> None: self._conversation_handler_conversations.update( @@ -473,26 +554,6 @@ async def shutdown(self) -> None: self._initialized = False - async def __aenter__(self: _AppType) -> _AppType: - """Simple context manager which initializes the App.""" - try: - await self.initialize() - return self - except Exception as exc: - await self.shutdown() - raise exc - - async def __aexit__( - self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> None: - """Shutdown the App from the context manager.""" - # Make sure not to return `True` so that exceptions are not suppressed - # https://docs.python.org/3/reference/datamodel.html?#object.__aexit__ - await self.shutdown() - async def _initialize_persistence(self) -> None: """This method basically just loads all the data by awaiting the BP methods""" if not self.persistence: @@ -522,17 +583,6 @@ async def _initialize_persistence(self) -> None: persistent_data ) - @staticmethod - def builder() -> "InitApplicationBuilder": - """Convenience method. Returns a new :class:`telegram.ext.ApplicationBuilder`. - - .. versionadded:: 20.0 - """ - # Unfortunately this needs to be here due to cyclical imports - from telegram.ext import ApplicationBuilder # pylint: disable=import-outside-toplevel - - return ApplicationBuilder() - async def start(self) -> None: """Starts @@ -568,9 +618,8 @@ async def start(self) -> None: try: if self.persistence: self.__update_persistence_task = asyncio.create_task( - self._persistence_updater() - # TODO: Add this once we drop py3.7 - # name=f'Application:{self.bot.id}:persistence_updater' + self._persistence_updater(), + name=f"Application:{self.bot.id}:persistence_updater", ) _LOGGER.debug("Loop for updating persistence started") @@ -579,15 +628,13 @@ async def start(self) -> None: _LOGGER.debug("JobQueue started") self.__update_fetcher_task = asyncio.create_task( - self._update_fetcher(), - # TODO: Add this once we drop py3.7 - # name=f'Application:{self.bot.id}:update_fetcher' + self._update_fetcher(), name=f"Application:{self.bot.id}:update_fetcher" ) _LOGGER.info("Application started") - except Exception as exc: + except Exception: self._running = False - raise exc + raise async def stop(self) -> None: """Stops the process after processing any pending updates or tasks created by @@ -616,14 +663,26 @@ async def stop(self) -> None: raise RuntimeError("This Application is not running!") self._running = False + self.__stop_running_marker.clear() _LOGGER.info("Application is stopping. This might take a moment.") # Stop listening for new updates and handle all pending ones - await self.update_queue.put(_STOP_SIGNAL) - _LOGGER.debug("Waiting for update_queue to join") - await self.update_queue.join() if self.__update_fetcher_task: - await self.__update_fetcher_task + if self.__update_fetcher_task.done(): + try: + self.__update_fetcher_task.result() + except BaseException as exc: + _LOGGER.critical( + "Fetching updates was aborted due to %r. Suppressing " + "exception to ensure graceful shutdown.", + exc, + exc_info=True, + ) + else: + await self.update_queue.put(_STOP_SIGNAL) + _LOGGER.debug("Waiting for update_queue to join") + await self.update_queue.join() + await self.__update_fetcher_task _LOGGER.debug("Application stopped fetching of updates.") if self._job_queue: @@ -643,17 +702,50 @@ async def stop(self) -> None: _LOGGER.info("Application.stop() complete") + def stop_running(self) -> None: + """This method can be used to stop the execution of :meth:`run_polling` or + :meth:`run_webhook` from within a handler, job or error callback. This allows a graceful + shutdown of the application, i.e. the methods listed in :attr:`run_polling` and + :attr:`run_webhook` will still be executed. + + This method can also be called within :meth:`post_init`. This allows for a graceful, + early shutdown of the application if some condition is met (e.g., a database connection + could not be established). + + Note: + If the application is not running and this method is not called within + :meth:`post_init`, this method does nothing. + + Warning: + This method is designed to for use in combination with :meth:`run_polling` or + :meth:`run_webhook`. Using this method in combination with a custom logic for starting + and stopping the application is not guaranteed to work as expected. Use at your own + risk. + + .. versionadded:: 20.5 + + .. versionchanged:: 21.2 + Added support for calling within :meth:`post_init`. + """ + if self.running: + # This works because `__run` is using `loop.run_forever()`. If that changes, this + # method needs to be adapted. + asyncio.get_running_loop().stop() + else: + self.__stop_running_marker.set() + if not self._initialized: + _LOGGER.debug( + "Application is not running and not initialized. `stop_running()` likely has " + "no effect." + ) + def run_polling( self, poll_interval: float = 0.0, - timeout: int = 10, - bootstrap_retries: int = -1, - read_timeout: float = 2, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - allowed_updates: Optional[List[str]] = None, - drop_pending_updates: Optional[bool] = None, + timeout: TimePeriod = dtm.timedelta(seconds=10), + bootstrap_retries: int = 0, + allowed_updates: Sequence[str] | None = None, + drop_pending_updates: bool | None = None, close_loop: bool = True, stop_signals: ODVInput[Sequence[int]] = DEFAULT_NONE, ) -> None: @@ -661,11 +753,9 @@ def run_polling( polling updates from Telegram using :meth:`telegram.ext.Updater.start_polling` and a graceful shutdown of the app on exit. - The app will shut down when :exc:`KeyboardInterrupt` or :exc:`SystemExit` is raised. - On unix, the app will also shut down on receiving the signals specified by - :paramref:`stop_signals`. + |app_run_shutdown| :paramref:`stop_signals`. - The order of execution by `run_polling` is roughly as follows: + The order of execution by :meth:`run_polling` is roughly as follows: - :meth:`initialize` - :meth:`post_init` @@ -678,40 +768,47 @@ 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 - .. seealso:: - :meth:`initialize`, :meth:`start`, :meth:`stop`, :meth:`shutdown` - :meth:`telegram.ext.Updater.start_polling`, :meth:`telegram.ext.Updater.stop`, - :meth:`run_webhook` + .. versionchanged:: + Removed the deprecated parameters ``read_timeout``, ``write_timeout``, + ``connect_timeout``, and ``pool_timeout``. Use the corresponding methods in + :class:`telegram.ext.ApplicationBuilder` instead. Args: poll_interval (:obj:`float`, optional): Time to wait between polling updates from Telegram in seconds. Default is ``0.0``. - timeout (:obj:`int`, optional): Passed to - :paramref:`telegram.Bot.get_updates.timeout`. Default is ``10`` seconds. - bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the - :class:`telegram.ext.Updater` will retry on failures on the Telegram server. + timeout (:obj:`int` | :class:`datetime.timedelta`, optional): Passed to + :paramref:`telegram.Bot.get_updates.timeout`. + Default is :obj:`timedelta(seconds=10)`. + + .. versionchanged:: v22.2 + |time-period-input| + bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase + (calling :meth:`initialize` and the boostrapping of + :meth:`telegram.ext.Updater.start_polling`) + will retry on failures on the Telegram server. - * < 0 - retry indefinitely (default) - * 0 - no retries + * < 0 - retry indefinitely + * 0 - no retries (default) * > 0 - retry up to X times - read_timeout (:obj:`float`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.read_timeout`. Defaults to ``2``. - write_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.write_timeout`. Defaults to - :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. - connect_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.connect_timeout`. Defaults to - :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. - pool_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.pool_timeout`. Defaults to - :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. + .. versionchanged:: 21.11 + The default value will be changed to from ``-1`` to ``0``. Indefinite retries + during bootstrapping are not recommended. + drop_pending_updates (:obj:`bool`, optional): Whether to clean any pending updates on Telegram servers before actually starting to poll. Default is :obj:`False`. - allowed_updates (List[:obj:`str`], optional): Passed to + allowed_updates (Sequence[:obj:`str`], optional): Passed to :meth:`telegram.Bot.get_updates`. + + .. versionchanged:: 21.9 + Accepts any :class:`collections.abc.Sequence` as input instead of just a list close_loop (:obj:`bool`, optional): If :obj:`True`, the current event loop will be closed upon shutdown. Defaults to :obj:`True`. @@ -744,42 +841,38 @@ def error_callback(exc: TelegramError) -> None: poll_interval=poll_interval, timeout=timeout, bootstrap_retries=bootstrap_retries, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, allowed_updates=allowed_updates, drop_pending_updates=drop_pending_updates, error_callback=error_callback, # if there is an error in fetching updates ), - close_loop=close_loop, stop_signals=stop_signals, + bootstrap_retries=bootstrap_retries, + close_loop=close_loop, ) def run_webhook( self, - listen: str = "127.0.0.1", - port: int = 80, + listen: DVType[str] = DEFAULT_IP, + port: DVType[int] = DEFAULT_80, url_path: str = "", - cert: Optional[Union[str, Path]] = None, - key: Optional[Union[str, Path]] = None, + cert: str | Path | None = None, + key: str | Path | None = None, bootstrap_retries: int = 0, - webhook_url: Optional[str] = None, - allowed_updates: Optional[List[str]] = None, - drop_pending_updates: Optional[bool] = None, - ip_address: Optional[str] = None, + webhook_url: str | None = None, + allowed_updates: Sequence[str] | None = None, + drop_pending_updates: bool | None = None, + ip_address: str | None = None, max_connections: int = 40, close_loop: bool = True, stop_signals: ODVInput[Sequence[int]] = DEFAULT_NONE, - secret_token: Optional[str] = None, + secret_token: str | None = None, + unix: "str | Path | socket | None" = None, ) -> None: """Convenience method that takes care of initializing and starting the app, listening for updates from Telegram using :meth:`telegram.ext.Updater.start_webhook` and a graceful shutdown of the app on exit. - The app will shut down when :exc:`KeyboardInterrupt` or :exc:`SystemExit` is raised. - On unix, the app will also shut down on receiving the signals specified by - :paramref:`stop_signals`. + |app_run_shutdown| :paramref:`stop_signals`. If :paramref:`cert` and :paramref:`key` are not provided, the webhook will be started directly on @@ -788,7 +881,7 @@ def run_webhook( ``https://listen:port/url_path``. Also calls :meth:`telegram.Bot.set_webhook` as required. - The order of execution by `run_webhook` is roughly as follows: + The order of execution by :meth:`run_webhook` is roughly as follows: - :meth:`initialize` - :meth:`post_init` @@ -807,14 +900,12 @@ def run_webhook( .. code-block:: bash - pip install python-telegram-bot[webhooks] + pip install "python-telegram-bot[webhooks]" .. include:: inclusions/application_run_tip.rst .. seealso:: - :meth:`initialize`, :meth:`start`, :meth:`stop`, :meth:`shutdown` - :meth:`telegram.ext.Updater.start_webhook`, :meth:`telegram.ext.Updater.stop`, - :meth:`run_polling`, :wiki:`Webhooks` + :wiki:`Webhooks` Args: listen (:obj:`str`, optional): IP-Address to listen on. Defaults to @@ -825,8 +916,10 @@ def run_webhook( url_path (:obj:`str`, optional): Path inside url. Defaults to `` '' `` cert (:class:`pathlib.Path` | :obj:`str`, optional): Path to the SSL certificate file. key (:class:`pathlib.Path` | :obj:`str`, optional): Path to the SSL key file. - bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the - :class:`telegram.ext.Updater` will retry on failures on the Telegram server. + bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase + (calling :meth:`initialize` and the boostrapping of + :meth:`telegram.ext.Updater.start_polling`) + will retry on failures on the Telegram server. * < 0 - retry indefinitely * 0 - no retries (default) @@ -834,8 +927,11 @@ def run_webhook( webhook_url (:obj:`str`, optional): Explicitly specify the webhook url. Useful behind NAT, reverse proxy, etc. Default is derived from :paramref:`listen`, :paramref:`port`, :paramref:`url_path`, :paramref:`cert`, and :paramref:`key`. - allowed_updates (List[:obj:`str`], optional): Passed to + allowed_updates (Sequence[:obj:`str`], optional): Passed to :meth:`telegram.Bot.set_webhook`. + + .. versionchanged:: 21.9 + Accepts any :class:`collections.abc.Sequence` as input instead of just a list drop_pending_updates (:obj:`bool`, optional): Whether to clean any pending updates on Telegram servers before actually starting to poll. Default is :obj:`False`. ip_address (:obj:`str`, optional): Passed to :meth:`telegram.Bot.set_webhook`. @@ -866,6 +962,27 @@ def run_webhook( header isn't set or it is set to a wrong token. .. versionadded:: 20.0 + unix (:class:`pathlib.Path` | :obj:`str` | :class:`socket.socket`, optional): Can be + either: + + * the path to the unix socket file as :class:`pathlib.Path` or :obj:`str`. This + will be passed to `tornado.netutil.bind_unix_socket `_ to create the socket. + If the Path does not exist, the file will be created. + + * or the socket itself. This option allows you to e.g. restrict the permissions of + the socket for improved security. Note that you need to pass the correct family, + type and socket options yourself. + + Caution: + This parameter is a replacement for the default TCP bind. Therefore, it is + mutually exclusive with :paramref:`listen` and :paramref:`port`. When using + this param, you must also run a reverse proxy to the unix socket and set the + appropriate :paramref:`webhook_url`. + + .. versionadded:: 20.8 + .. versionchanged:: 21.1 + Added support to pass a socket instance itself. """ if not self.updater: raise RuntimeError( @@ -886,25 +1003,37 @@ def run_webhook( ip_address=ip_address, max_connections=max_connections, secret_token=secret_token, + unix=unix, ), - close_loop=close_loop, stop_signals=stop_signals, + bootstrap_retries=bootstrap_retries, + close_loop=close_loop, ) - @staticmethod - def _raise_system_exit() -> NoReturn: - raise SystemExit + async def _bootstrap_initialize(self, max_retries: int) -> None: + await network_retry_loop( + action_cb=self.initialize, + description="Bootstrap Initialize Application", + max_retries=max_retries, + interval=1, + ) def __run( self, updater_coroutine: Coroutine, stop_signals: ODVInput[Sequence[int]], + bootstrap_retries: int, close_loop: bool = True, ) -> None: - # Calling get_event_loop() should still be okay even in py3.10+ as long as there is a - # running event loop or we are in the main thread, which are the intended use cases. - # See the docs of get_event_loop() and get_running_loop() for more info - loop = asyncio.get_event_loop() + # Try to get the running event loop first, and if there isn't one, create a new one. + # This handles the Python 3.14+ behavior where get_event_loop() raises RuntimeError + # when there's no current event loop in the main thread. + try: + loop = asyncio.get_event_loop() + except RuntimeError: + # No running event loop, create and set a new one + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) if stop_signals is DEFAULT_NONE and platform.system() != "Windows": stop_signals = (signal.SIGINT, signal.SIGTERM, signal.SIGABRT) @@ -917,33 +1046,36 @@ def __run( warn( f"Could not add signal handlers for the stop signals {stop_signals} due to " f"exception `{exc!r}`. If your event loop does not implement `add_signal_handler`," - f" please pass `stop_signals=None`.", + " please pass `stop_signals=None`.", stacklevel=3, ) try: - loop.run_until_complete(self.initialize()) + loop.run_until_complete(self._bootstrap_initialize(max_retries=bootstrap_retries)) if self.post_init: loop.run_until_complete(self.post_init(self)) + if self.__stop_running_marker.is_set(): + _LOGGER.info("Application received stop signal via `stop_running`. Shutting down.") + return loop.run_until_complete(updater_coroutine) # one of updater.start_webhook/polling loop.run_until_complete(self.start()) loop.run_forever() except (KeyboardInterrupt, SystemExit): - pass - except Exception as exc: - # In case the coroutine wasn't awaited, we don't need to bother the user with a warning - updater_coroutine.close() - raise exc + _LOGGER.debug("Application received stop signal. Shutting down.") finally: # We arrive here either by catching the exceptions above or if the loop gets stopped + # In case the coroutine wasn't awaited, we don't need to bother the user with a warning + updater_coroutine.close() + try: # Mypy doesn't know that we already check if updater is None if self.updater.running: # type: ignore[union-attr] loop.run_until_complete(self.updater.stop()) # type: ignore[union-attr] if self.running: loop.run_until_complete(self.stop()) - if self.post_stop: - loop.run_until_complete(self.post_stop(self)) + # post_stop should be called only if stop was called! + if self.post_stop: + loop.run_until_complete(self.post_stop(self)) loop.run_until_complete(self.shutdown()) if self.post_shutdown: loop.run_until_complete(self.post_shutdown(self)) @@ -953,8 +1085,10 @@ def __run( def create_task( self, - coroutine: Union[Generator[Optional["asyncio.Future[object]"], None, RT], Awaitable[RT]], - update: Optional[object] = None, + coroutine: _CoroType[RT], + update: object | None = None, + *, + name: str | None = None, ) -> "asyncio.Task[RT]": """Thin wrapper around :func:`asyncio.create_task` that handles exceptions raised by the :paramref:`coroutine` with :meth:`process_error`. @@ -972,30 +1106,39 @@ def create_task( .. versionchanged:: 20.2 Accepts :class:`asyncio.Future` and generator-based coroutine functions. + .. deprecated:: 20.4 + Since Python 3.12, generator-based coroutine functions are no longer accepted. update (:obj:`object`, optional): If set, will be passed to :meth:`process_error` as additional information for the error handlers. Moreover, the corresponding :attr:`chat_data` and :attr:`user_data` entries will be updated in the next run of :meth:`update_persistence` after the :paramref:`coroutine` is finished. + Keyword Args: + name (:obj:`str`, optional): The name of the task. + + .. versionadded:: 20.4 + Returns: :class:`asyncio.Task`: The created task. """ - return self.__create_task(coroutine=coroutine, update=update) + return self.__create_task(coroutine=coroutine, update=update, name=name) def __create_task( self, - coroutine: Union[Generator[Optional["asyncio.Future[object]"], None, RT], Awaitable[RT]], - update: Optional[object] = None, + coroutine: _CoroType[RT], + update: object | None = None, is_error_handler: bool = False, + name: str | None = None, ) -> "asyncio.Task[RT]": # Unfortunately, we can't know if `coroutine` runs one of the error handler functions # but by passing `is_error_handler=True` from `process_error`, we can make sure that we # get at most one recursion of the user calls `create_task` manually with an error handler # function - task: "asyncio.Task[RT]" = asyncio.create_task( + task: asyncio.Task[RT] = asyncio.create_task( self.__create_task_callback( coroutine=coroutine, update=update, is_error_handler=is_error_handler - ) + ), + name=name, ) if self.running: @@ -1019,23 +1162,28 @@ def __create_task_done_callback(self, task: asyncio.Task) -> None: async def __create_task_callback( self, - coroutine: Union[Generator[Optional["asyncio.Future[object]"], None, RT], Awaitable[RT]], - update: Optional[object] = None, + coroutine: _CoroType[RT], + update: object | None = None, is_error_handler: bool = False, ) -> RT: try: - if isinstance(coroutine, Generator): + # Generator-based coroutines are not supported in Python 3.12+ + if sys.version_info < (3, 12) and isinstance(coroutine, Generator): + warn( + PTBDeprecationWarning( + "20.4", + "Generator-based coroutines are deprecated in create_task and will not" + " work in Python 3.12+", + ), + ) return await asyncio.create_task(coroutine) - return await coroutine - except asyncio.CancelledError as cancel: - # TODO: in py3.8+, CancelledError is a subclass of BaseException, so we can drop this - # clause when we drop py3.7 - raise cancel + # If user uses generator in python 3.12+, Exception will happen and we cannot do + # anything about it. (hence the type ignore if mypy is run on python 3.12-) + return await coroutine # type: ignore[misc] except Exception as exception: if isinstance(exception, ApplicationHandlerStop): warn( - "ApplicationHandlerStop is not supported with handlers " - "running non-blocking.", + "ApplicationHandlerStop is not supported with handlers running non-blocking.", stacklevel=1, ) @@ -1054,48 +1202,48 @@ async def __create_task_callback( await self.process_error(update=update, error=exception, coroutine=coroutine) # Raise exception so that it can be set on the task and retrieved by task.exception() - raise exception + raise finally: self._mark_for_persistence_update(update=update) - async def _update_fetcher(self) -> None: + async def __update_fetcher(self) -> None: # Continuously fetch updates from the queue. Exit only once the signal object is found. while True: - try: - update = await self.update_queue.get() - - if update is _STOP_SIGNAL: - _LOGGER.debug("Dropping pending updates") - while not self.update_queue.empty(): - self.update_queue.task_done() + update = await self.update_queue.get() - # For the _STOP_SIGNAL - self.update_queue.task_done() - return + if update is _STOP_SIGNAL: + # For the _STOP_SIGNAL + self.update_queue.task_done() + return - _LOGGER.debug("Processing update %s", update) + _LOGGER.debug("Processing update %s", update) - if self._update_processor.max_concurrent_updates > 1: - # We don't await the below because it has to be run concurrently - self.create_task( - self.__process_update_wrapper(update), - update=update, - ) - else: - await self.__process_update_wrapper(update) - - except asyncio.CancelledError: - # This may happen if the application is manually run via application.start() and - # then a KeyboardInterrupt is sent. We must prevent this loop to die since - # application.stop() will wait for it's clean shutdown. - _LOGGER.warning( - "Fetching updates got a asyncio.CancelledError. Ignoring as this task may only" - "be closed via `Application.stop`." + if self._update_processor.max_concurrent_updates > 1: + # We don't await the below because it has to be run concurrently + self.create_task( + self.__process_update_wrapper(update), + update=update, + name=f"Application:{self.bot.id}:process_concurrent_update", ) + else: + await self.__process_update_wrapper(update) + + async def _update_fetcher(self) -> None: + try: + await self.__update_fetcher() + finally: + while not self.update_queue.empty(): + _LOGGER.debug("Dropping pending update: %s", self.update_queue.get_nowait()) + with contextlib.suppress(ValueError): + # Since we're shutting down here, it's not too bad if we call task_done + # on an empty queue + self.update_queue.task_done() async def __process_update_wrapper(self, update: object) -> None: - await self._update_processor.process_update(update, self.process_update(update)) - self.update_queue.task_done() + try: + await self._update_processor.process_update(update, self.process_update(update)) + finally: + self.update_queue.task_done() async def process_update(self, update: object) -> None: """Processes a single update and marks the update to be updated by the persistence later. @@ -1120,27 +1268,52 @@ async def process_update(self, update: object) -> None: context = None any_blocking = False # Flag which is set to True if any handler specifies block=True - for handlers in self.handlers.values(): + # We copy the lists to avoid issues with concurrent modification of the + # handlers (groups or handlers in groups) while iterating over it via add/remove_handler. + # Currently considered implementation detail as described in docstrings of + # add/remove_handler + # do *not* use `copy.deepcopy` here, as we don't want to deepcopy the handlers themselves + for handlers in [v.copy() for v in self.handlers.values()]: try: + # no copy needed b/c we copy above for handler in handlers: check = handler.check_update(update) # Should the handler handle this update? - if not (check is None or check is False): # if yes, - if not context: # build a context if not already built + if check is None or check is False: + continue + + if not context: # build a context if not already built + try: context = self.context_types.context.from_update(update, self) - await context.refresh_data() - coroutine: Coroutine = handler.handle_update(update, self, check, context) - - if not handler.block or ( # if handler is running with block=False, - handler.block is DEFAULT_TRUE - and isinstance(self.bot, ExtBot) - and self.bot.defaults - and not self.bot.defaults.block - ): - self.create_task(coroutine, update=update) - else: - any_blocking = True - await coroutine - break # Only a max of 1 handler per group is handled + except Exception as exc: + _LOGGER.critical( + ( + "Error while building CallbackContext for update %s. " + "Update will not be processed." + ), + update, + exc_info=exc, + ) + return + await context.refresh_data() + coroutine: Coroutine = handler.handle_update(update, self, check, context) + + if not handler.block or ( # if handler is running with block=False, + handler.block is DEFAULT_TRUE + and isinstance(self.bot, ExtBot) + and self.bot.defaults + and not self.bot.defaults.block + ): + self.create_task( + coroutine, + update=update, + name=( + f"Application:{self.bot.id}:process_update_non_blocking:{handler}" + ), + ) + else: + any_blocking = True + await coroutine + break # Only a max of 1 handler per group is handled # Stop processing with any other handler. except ApplicationHandlerStop: @@ -1159,7 +1332,7 @@ async def process_update(self, update: object) -> None: # (in __create_task_callback) self._mark_for_persistence_update(update=update) - def add_handler(self, handler: BaseHandler[Any, CCT], group: int = DEFAULT_GROUP) -> None: + def add_handler(self, handler: BaseHandler[Any, CCT, Any], group: int = DEFAULT_GROUP) -> None: """Register a handler. TL;DR: Order and priority counts. 0 or 1 handlers per group will be used. End handling of @@ -1174,11 +1347,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 @@ -1187,6 +1360,14 @@ def add_handler(self, handler: BaseHandler[Any, CCT], group: int = DEFAULT_GROUP might lead to race conditions and undesired behavior. In particular, current conversation states may be overridden by the loaded data. + Hint: + This method currently has no influence on calls to :meth:`process_update` that are + already in progress. + + .. warning:: + This behavior should currently be considered an implementation detail and not as + guaranteed behavior. + Args: handler (:class:`telegram.ext.BaseHandler`): A BaseHandler instance. group (:obj:`int`, optional): The group identifier. Default is ``0``. @@ -1194,7 +1375,7 @@ def add_handler(self, handler: BaseHandler[Any, CCT], group: int = DEFAULT_GROUP """ # Unfortunately due to circular imports this has to be here # pylint: disable=import-outside-toplevel - from telegram.ext._conversationhandler import ConversationHandler + from telegram.ext._handlers.conversationhandler import ConversationHandler # noqa: PLC0415 if not isinstance(handler, BaseHandler): raise TypeError(f"handler is not an instance of {BaseHandler.__name__}") @@ -1204,10 +1385,13 @@ def add_handler(self, handler: BaseHandler[Any, CCT], group: int = DEFAULT_GROUP if not self.persistence: raise ValueError( f"ConversationHandler {handler.name} " - f"can not be persistent if application has no persistence" + "can not be persistent if application has no persistence" ) if self._initialized: - self.create_task(self._add_ch_to_persistence(handler)) + self.create_task( + self._add_ch_to_persistence(handler), + name=f"Application:{self.bot.id}:add_handler:conversation_handler_after_init", + ) warn( "A persistent `ConversationHandler` was passed to `add_handler`, " "after `Application.initialize` was called. This is discouraged." @@ -1223,11 +1407,10 @@ def add_handler(self, handler: BaseHandler[Any, CCT], group: int = DEFAULT_GROUP def add_handlers( self, - handlers: Union[ - Union[List[BaseHandler[Any, CCT]], Tuple[BaseHandler[Any, CCT]]], - Dict[int, Union[List[BaseHandler[Any, CCT]], Tuple[BaseHandler[Any, CCT]]]], - ], - group: Union[int, DefaultValue[int]] = _DEFAULT_0, + handlers: ( + Sequence[BaseHandler[Any, CCT, Any]] | dict[int, Sequence[BaseHandler[Any, CCT, Any]]] + ), + group: int | DefaultValue[int] = _DEFAULT_0, ) -> None: """Registers multiple handlers at once. The order of the handlers in the passed sequence(s) matters. See :meth:`add_handler` for details. @@ -1235,10 +1418,15 @@ def add_handlers( .. versionadded:: 20.0 Args: - handlers (List[:class:`telegram.ext.BaseHandler`] | \ - Dict[int, List[:class:`telegram.ext.BaseHandler`]]): \ + handlers (Sequence[:class:`telegram.ext.BaseHandler`] | \ + dict[int, Sequence[:class:`telegram.ext.BaseHandler`]]): Specify a sequence of handlers *or* a dictionary where the keys are groups and values are handlers. + + .. versionchanged:: 21.7 + Accepts any :class:`collections.abc.Sequence` as input instead of just a list + or tuple. + group (:obj:`int`, optional): Specify which group the sequence of :paramref:`handlers` should be added to. Defaults to ``0``. @@ -1249,31 +1437,45 @@ def add_handlers( 1: [CallbackQueryHandler(...), CommandHandler(...)] } + Raises: + :exc:`TypeError`: If the combination of arguments is invalid. """ if isinstance(handlers, dict) and not isinstance(group, DefaultValue): - raise ValueError("The `group` argument can only be used with a sequence of handlers.") + raise TypeError("The `group` argument can only be used with a sequence of handlers.") if isinstance(handlers, dict): for handler_group, grp_handlers in handlers.items(): - if not isinstance(grp_handlers, (list, tuple)): - raise ValueError(f"Handlers for group {handler_group} must be a list or tuple") + if not isinstance(grp_handlers, Sequence): + raise TypeError( + f"Handlers for group {handler_group} must be a sequence of handlers." + ) for handler in grp_handlers: self.add_handler(handler, handler_group) - elif isinstance(handlers, (list, tuple)): + elif isinstance(handlers, Sequence): for handler in handlers: self.add_handler(handler, DefaultValue.get_value(group)) else: - raise ValueError( + raise TypeError( "The `handlers` argument must be a sequence of handlers or a " "dictionary where the keys are groups and values are sequences of handlers." ) - def remove_handler(self, handler: BaseHandler[Any, CCT], group: int = DEFAULT_GROUP) -> None: + def remove_handler( + self, handler: BaseHandler[Any, CCT, Any], group: int = DEFAULT_GROUP + ) -> None: """Remove a handler from the specified group. + Hint: + This method currently has no influence on calls to :meth:`process_update` that are + already in progress. + + .. warning:: + This behavior should currently be considered an implementation detail and not as + guaranteed behavior. + Args: handler (:class:`telegram.ext.BaseHandler`): A :class:`telegram.ext.BaseHandler` instance. @@ -1301,7 +1503,7 @@ def drop_chat_data(self, chat_id: int) -> None: chat_id (:obj:`int`): The chat id to delete. The entry will be deleted even if it is not empty. """ - self._chat_data.pop(chat_id, None) # type: ignore[arg-type] + self._chat_data.pop(chat_id, None) self._chat_ids_to_be_deleted_in_persistence.add(chat_id) def drop_user_data(self, user_id: int) -> None: @@ -1320,14 +1522,14 @@ def drop_user_data(self, user_id: int) -> None: user_id (:obj:`int`): The user id to delete. The entry will be deleted even if it is not empty. """ - self._user_data.pop(user_id, None) # type: ignore[arg-type] + self._user_data.pop(user_id, None) self._user_ids_to_be_deleted_in_persistence.add(user_id) def migrate_chat_data( self, - message: Optional["Message"] = None, - old_chat_id: Optional[int] = None, - new_chat_id: Optional[int] = None, + message: "Message | None" = None, + old_chat_id: int | None = None, + new_chat_id: int | None = None, ) -> None: """Moves the contents of :attr:`chat_data` at key :paramref:`old_chat_id` to the key :paramref:`new_chat_id`. Also marks the entries to be updated accordingly in the next run @@ -1391,7 +1593,7 @@ def migrate_chat_data( # old_chat_id is marked for deletion by drop_chat_data above def _mark_for_persistence_update( - self, *, update: Optional[object] = None, job: Optional["Job"] = None + self, *, update: object | None = None, job: "Job | None" = None ) -> None: if isinstance(update, Update): if update.effective_chat: @@ -1406,7 +1608,7 @@ def _mark_for_persistence_update( self._user_ids_to_be_updated_in_persistence.add(job.user_id) def mark_data_for_update_persistence( - self, chat_ids: Optional[SCT[int]] = None, user_ids: Optional[SCT[int]] = None + self, chat_ids: SCT[int] | None = None, user_ids: SCT[int] | None = None ) -> None: """Mark entries of :attr:`chat_data` and :attr:`user_data` to be updated on the next run of :meth:`update_persistence`. @@ -1448,9 +1650,10 @@ async def _persistence_updater(self) -> None: self.__update_persistence_event.wait(), timeout=self.persistence.update_interval, ) - return except asyncio.TimeoutError: pass + else: + return # putting this *after* the wait_for so we don't immediately update on startup as # that would make little sense @@ -1485,7 +1688,7 @@ async def __update_persistence(self) -> None: _LOGGER.debug("Starting next run of updating the persistence.") - coroutines: Set[Coroutine] = set() + coroutines: set[Coroutine] = set() # Mypy doesn't know that persistence.set_bot (see above) already checks that # self.bot is an instance of ExtBot if callback_data should be stored ... @@ -1537,7 +1740,7 @@ async def __update_persistence(self) -> None: # Unfortunately due to circular imports this has to be here # pylint: disable=import-outside-toplevel - from telegram.ext._conversationhandler import PendingState + from telegram.ext._handlers.conversationhandler import PendingState # noqa: PLC0415 for name, (key, new_state) in itertools.chain.from_iterable( zip(itertools.repeat(name), states_dict.pop_accessed_write_items()) @@ -1603,13 +1806,21 @@ def add_error_handler( Examples: :any:`Errorhandler Bot ` + Hint: + This method currently has no influence on calls to :meth:`process_error` that are + already in progress. + + .. warning:: + This behavior should currently be considered an implementation detail and not as + guaranteed behavior. + .. seealso:: :wiki:`Exceptions, Warnings and Logging ` Args: callback (:term:`coroutine function`): The callback function for this error handler. Will be called when an error is raised. Callback signature:: - async def callback(update: Optional[object], context: CallbackContext) + async def callback(update: object | None, context: CallbackContext) The error that happened will be present in :attr:`telegram.ext.CallbackContext.error`. @@ -1626,6 +1837,14 @@ async def callback(update: Optional[object], context: CallbackContext) def remove_error_handler(self, callback: HandlerCallback[object, CCT, None]) -> None: """Removes an error handler. + Hint: + This method currently has no influence on calls to :meth:`process_error` that are + already in progress. + + .. warning:: + This behavior should currently be considered an implementation detail and not as + guaranteed behavior. + Args: callback (:term:`coroutine function`): The error handler to remove. @@ -1634,12 +1853,10 @@ def remove_error_handler(self, callback: HandlerCallback[object, CCT, None]) -> async def process_error( self, - update: Optional[object], + update: object | None, error: Exception, - job: Optional["Job[CCT]"] = None, - coroutine: Optional[ - Union[Generator[Optional["asyncio.Future[object]"], None, RT], Awaitable[RT]] - ] = None, + job: "Job[CCT] | None" = None, + coroutine: _ErrorCoroType[RT] | None = None, ) -> bool: """Processes an error by passing it to all error handlers registered with :meth:`add_error_handler`. If one of the error handlers raises @@ -1669,17 +1886,31 @@ async def process_error( :class:`telegram.ext.ApplicationHandlerStop`. :obj:`False`, otherwise. """ if self.error_handlers: - for ( - callback, - block, - ) in self.error_handlers.items(): - context = self.context_types.context.from_error( - update=update, - error=error, - application=self, - job=job, - coroutine=coroutine, - ) + # We copy the list to avoid issues with concurrent modification of the + # error handlers while iterating over it via add/remove_error_handler. + # Currently considered implementation detail as described in docstrings of + # add/remove_error_handler + error_handler_items = list(self.error_handlers.items()) + for callback, block in error_handler_items: + try: + context = self.context_types.context.from_error( + update=update, + error=error, + application=self, + job=job, + coroutine=coroutine, + ) + except Exception as exc: + _LOGGER.critical( + ( + "Error while building CallbackContext for exception %s. " + "Exception will not be processed by error handlers." + ), + error, + exc_info=exc, + ) + return False + if not block or ( # If error handler has `block=False`, create a Task to run cb block is DEFAULT_TRUE and isinstance(self.bot, ExtBot) @@ -1687,7 +1918,10 @@ async def process_error( and not self.bot.defaults.block ): self.__create_task( - callback(update, context), update=update, is_error_handler=True + callback(update, context), + update=update, + is_error_handler=True, + name=f"Application:{self.bot.id}:process_error:non_blocking", ) else: try: diff --git a/telegram/ext/_applicationbuilder.py b/src/telegram/ext/_applicationbuilder.py similarity index 81% rename from telegram/ext/_applicationbuilder.py rename to src/telegram/ext/_applicationbuilder.py index cd3b7ad35c0..97e55089502 100644 --- a/telegram/ext/_applicationbuilder.py +++ b/src/telegram/ext/_applicationbuilder.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,24 +17,25 @@ # 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 the Builder classes for the telegram.ext module.""" + from asyncio import Queue +from collections.abc import Callable, Collection, Coroutine from pathlib import Path -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Coroutine, - Dict, - Generic, - Optional, - Type, - TypeVar, - Union, -) +from typing import TYPE_CHECKING, Any, Generic, TypeVar + +import httpx from telegram._bot import Bot from telegram._utils.defaultvalue import DEFAULT_FALSE, DEFAULT_NONE, DefaultValue -from telegram._utils.types import DVInput, DVType, FilePathInput, ODVInput +from telegram._utils.types import ( + BaseUrl, + DVInput, + DVType, + FilePathInput, + HTTPVersion, + ODVInput, + SocketOpt, +) from telegram.ext._application import Application from telegram.ext._baseupdateprocessor import BaseUpdateProcessor, SimpleUpdateProcessor from telegram.ext._contexttypes import ContextTypes @@ -46,6 +47,7 @@ from telegram.request._httpxrequest import HTTPXRequest if TYPE_CHECKING: + from telegram import Update from telegram.ext import BasePersistence, BaseRateLimiter, CallbackContext, Defaults from telegram.ext._utils.types import RLARGS @@ -54,7 +56,7 @@ # 'In' stands for input - used in parameters of methods below # pylint: disable=invalid-name InBT = TypeVar("InBT", bound=Bot) -InJQ = TypeVar("InJQ", bound=Union[None, JobQueue]) +InJQ = TypeVar("InJQ", bound=None | JobQueue) InCCT = TypeVar("InCCT", bound="CallbackContext") InUD = TypeVar("InUD") InCD = TypeVar("InCD") @@ -66,14 +68,17 @@ ("request", "request instance"), ("get_updates_request", "get_updates_request instance"), ("connection_pool_size", "connection_pool_size"), - ("proxy_url", "proxy_url"), + ("proxy", "proxy"), + ("socket_options", "socket_options"), ("pool_timeout", "pool_timeout"), ("connect_timeout", "connect_timeout"), ("read_timeout", "read_timeout"), ("write_timeout", "write_timeout"), + ("media_write_timeout", "media_write_timeout"), ("http_version", "http_version"), ("get_updates_connection_pool_size", "get_updates_connection_pool_size"), - ("get_updates_proxy_url", "get_updates_proxy_url"), + ("get_updates_proxy", "get_updates_proxy"), + ("get_updates_socket_options", "get_updates_socket_options"), ("get_updates_pool_timeout", "get_updates_pool_timeout"), ("get_updates_connect_timeout", "get_updates_connect_timeout"), ("get_updates_read_timeout", "get_updates_read_timeout"), @@ -118,6 +123,9 @@ class ApplicationBuilder(Generic[BT, CCT, UD, CD, BD, JQ]): .. seealso:: :wiki:`Your First Bot `, :wiki:`Builder Pattern ` + .. versionchanged:: 22.0 + Removed deprecated methods ``proxy_url`` and ``get_updates_proxy_url``. + .. _`builder pattern`: https://en.wikipedia.org/wiki/Builder_pattern """ @@ -128,20 +136,23 @@ class ApplicationBuilder(Generic[BT, CCT, UD, CD, BD, JQ]): "_base_file_url", "_base_url", "_bot", - "_update_processor", "_connect_timeout", "_connection_pool_size", "_context_types", "_defaults", "_get_updates_connect_timeout", "_get_updates_connection_pool_size", + "_get_updates_http_version", "_get_updates_pool_timeout", - "_get_updates_proxy_url", + "_get_updates_proxy", "_get_updates_read_timeout", "_get_updates_request", + "_get_updates_socket_options", "_get_updates_write_timeout", - "_get_updates_http_version", + "_http_version", "_job_queue", + "_local_mode", + "_media_write_timeout", "_persistence", "_pool_timeout", "_post_init", @@ -149,64 +160,67 @@ class ApplicationBuilder(Generic[BT, CCT, UD, CD, BD, JQ]): "_post_stop", "_private_key", "_private_key_password", - "_proxy_url", + "_proxy", "_rate_limiter", "_read_timeout", "_request", + "_socket_options", "_token", + "_update_processor", "_update_queue", "_updater", "_write_timeout", - "_local_mode", - "_http_version", ) def __init__(self: "InitApplicationBuilder"): self._token: DVType[str] = DefaultValue("") - self._base_url: DVType[str] = DefaultValue("https://api.telegram.org/bot") - self._base_file_url: DVType[str] = DefaultValue("https://api.telegram.org/file/bot") + self._base_url: DVType[BaseUrl] = DefaultValue("https://api.telegram.org/bot") + self._base_file_url: DVType[BaseUrl] = DefaultValue("https://api.telegram.org/file/bot") self._connection_pool_size: DVInput[int] = DEFAULT_NONE - self._proxy_url: DVInput[str] = DEFAULT_NONE + self._proxy: DVInput[str | httpx.Proxy | httpx.URL] = DEFAULT_NONE + self._socket_options: DVInput[Collection[SocketOpt]] = DEFAULT_NONE self._connect_timeout: ODVInput[float] = DEFAULT_NONE self._read_timeout: ODVInput[float] = DEFAULT_NONE self._write_timeout: ODVInput[float] = DEFAULT_NONE + self._media_write_timeout: ODVInput[float] = DEFAULT_NONE self._pool_timeout: ODVInput[float] = DEFAULT_NONE - self._request: DVInput["BaseRequest"] = DEFAULT_NONE + self._request: DVInput[BaseRequest] = DEFAULT_NONE self._get_updates_connection_pool_size: DVInput[int] = DEFAULT_NONE - self._get_updates_proxy_url: DVInput[str] = DEFAULT_NONE + self._get_updates_proxy: DVInput[str | httpx.Proxy | httpx.URL] = DEFAULT_NONE + self._get_updates_socket_options: DVInput[Collection[SocketOpt]] = DEFAULT_NONE self._get_updates_connect_timeout: ODVInput[float] = DEFAULT_NONE self._get_updates_read_timeout: ODVInput[float] = DEFAULT_NONE self._get_updates_write_timeout: ODVInput[float] = DEFAULT_NONE self._get_updates_pool_timeout: ODVInput[float] = DEFAULT_NONE - self._get_updates_request: DVInput["BaseRequest"] = DEFAULT_NONE + self._get_updates_request: DVInput[BaseRequest] = DEFAULT_NONE self._get_updates_http_version: DVInput[str] = DefaultValue("1.1") self._private_key: ODVInput[bytes] = DEFAULT_NONE self._private_key_password: ODVInput[bytes] = DEFAULT_NONE - self._defaults: ODVInput["Defaults"] = DEFAULT_NONE - self._arbitrary_callback_data: Union[DefaultValue[bool], int] = DEFAULT_FALSE + self._defaults: ODVInput[Defaults] = DEFAULT_NONE + self._arbitrary_callback_data: DefaultValue[bool] | int = DEFAULT_FALSE self._local_mode: DVType[bool] = DEFAULT_FALSE self._bot: DVInput[Bot] = DEFAULT_NONE - self._update_queue: DVType[Queue] = DefaultValue(Queue()) + self._update_queue: DVType[Queue[Update | object]] = DefaultValue(Queue()) try: - self._job_queue: ODVInput["JobQueue"] = DefaultValue(JobQueue()) + self._job_queue: ODVInput[JobQueue] = DefaultValue(JobQueue()) except RuntimeError as exc: if "PTB must be installed via" not in str(exc): - raise exc + raise self._job_queue = DEFAULT_NONE - self._persistence: ODVInput["BasePersistence"] = DEFAULT_NONE + self._persistence: ODVInput[BasePersistence] = DEFAULT_NONE self._context_types: DVType[ContextTypes] = DefaultValue(ContextTypes()) - self._application_class: DVType[Type[Application]] = DefaultValue(Application) - self._application_kwargs: Dict[str, object] = {} - self._update_processor: "BaseUpdateProcessor" = SimpleUpdateProcessor( + self._application_class: DVType[type[Application]] = DefaultValue(Application) + self._application_kwargs: dict[str, object] = {} + self._update_processor: BaseUpdateProcessor = SimpleUpdateProcessor( max_concurrent_updates=1 ) self._updater: ODVInput[Updater] = DEFAULT_NONE - self._post_init: Optional[Callable[[Application], Coroutine[Any, Any, None]]] = None - self._post_shutdown: Optional[Callable[[Application], Coroutine[Any, Any, None]]] = None - self._post_stop: Optional[Callable[[Application], Coroutine[Any, Any, None]]] = None - self._rate_limiter: ODVInput["BaseRateLimiter"] = DEFAULT_NONE + self._post_init: Callable[[Application], Coroutine[Any, Any, None]] | None = None + self._post_shutdown: Callable[[Application], Coroutine[Any, Any, None]] | None = None + self._post_stop: Callable[[Application], Coroutine[Any, Any, None]] | None = None + self._rate_limiter: ODVInput[BaseRateLimiter] = DEFAULT_NONE self._http_version: DVInput[str] = DefaultValue("1.1") def _build_request(self, get_updates: bool) -> BaseRequest: @@ -214,7 +228,8 @@ def _build_request(self, get_updates: bool) -> BaseRequest: if not isinstance(getattr(self, f"{prefix}request"), DefaultValue): return getattr(self, f"{prefix}request") - proxy_url = DefaultValue.get_value(getattr(self, f"{prefix}proxy_url")) + proxy = DefaultValue.get_value(getattr(self, f"{prefix}proxy")) + socket_options = DefaultValue.get_value(getattr(self, f"{prefix}socket_options")) if get_updates: connection_pool_size = ( DefaultValue.get_value(getattr(self, f"{prefix}connection_pool_size")) or 1 @@ -230,6 +245,10 @@ def _build_request(self, get_updates: bool) -> BaseRequest: "write_timeout": getattr(self, f"{prefix}write_timeout"), "pool_timeout": getattr(self, f"{prefix}pool_timeout"), } + + if not get_updates: + timeouts["media_write_timeout"] = self._media_write_timeout + # Get timeouts that were actually set- effective_timeouts = { key: value for key, value in timeouts.items() if not isinstance(value, DefaultValue) @@ -239,8 +258,9 @@ def _build_request(self, get_updates: bool) -> BaseRequest: return HTTPXRequest( connection_pool_size=connection_pool_size, - proxy_url=proxy_url, - http_version=http_version, + proxy=proxy, + http_version=http_version, # type: ignore[arg-type] + socket_options=socket_options, **effective_timeouts, ) @@ -301,9 +321,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, @@ -331,8 +349,8 @@ def build( def application_class( self: BuilderType, - application_class: Type[Application[Any, Any, Any, Any, Any, Any]], - kwargs: Optional[Dict[str, object]] = None, + application_class: type[Application[Any, Any, Any, Any, Any, Any]], + kwargs: dict[str, object] | None = None, ) -> BuilderType: """Sets a custom subclass instead of :class:`telegram.ext.Application`. The subclass's ``__init__`` should look like this @@ -346,7 +364,7 @@ def __init__(self, custom_arg_1, custom_arg_2, ..., **kwargs): Args: application_class (:obj:`type`): A subclass of :class:`telegram.ext.Application` - kwargs (Dict[:obj:`str`, :obj:`object`], optional): Keyword arguments for the + kwargs (dict[:obj:`str`, :obj:`object`], optional): Keyword arguments for the initialization. Defaults to an empty dict. Returns: @@ -370,15 +388,19 @@ def token(self: BuilderType, token: str) -> BuilderType: self._token = token return self - def base_url(self: BuilderType, base_url: str) -> BuilderType: + def base_url(self: BuilderType, base_url: BaseUrl) -> BuilderType: """Sets the base URL for :attr:`telegram.ext.Application.bot`. If not called, will default to ``'https://api.telegram.org/bot'``. .. seealso:: :paramref:`telegram.Bot.base_url`, :wiki:`Local Bot API Server `, :meth:`base_file_url` + .. versionchanged:: 21.11 + Supports callable input and string formatting. + Args: - base_url (:obj:`str`): The URL. + base_url (:obj:`str` | Callable[[:obj:`str`], :obj:`str`]): The URL or + input for the URL as accepted by :paramref:`telegram.Bot.base_url`. Returns: :class:`ApplicationBuilder`: The same builder with the updated argument. @@ -388,15 +410,19 @@ def base_url(self: BuilderType, base_url: str) -> BuilderType: self._base_url = base_url return self - def base_file_url(self: BuilderType, base_file_url: str) -> BuilderType: + def base_file_url(self: BuilderType, base_file_url: BaseUrl) -> BuilderType: """Sets the base file URL for :attr:`telegram.ext.Application.bot`. If not called, will default to ``'https://api.telegram.org/file/bot'``. .. seealso:: :paramref:`telegram.Bot.base_file_url`, :wiki:`Local Bot API Server `, :meth:`base_url` + .. versionchanged:: 21.11 + Supports callable input and string formatting. + Args: - base_file_url (:obj:`str`): The URL. + base_file_url (:obj:`str` | Callable[[:obj:`str`], :obj:`str`]): The URL or + input for the URL as accepted by :paramref:`telegram.Bot.base_file_url`. Returns: :class:`ApplicationBuilder`: The same builder with the updated argument. @@ -410,17 +436,24 @@ def _request_check(self, get_updates: bool) -> None: prefix = "get_updates_" if get_updates else "" name = prefix + "request" + timeouts = ["connect_timeout", "read_timeout", "write_timeout", "pool_timeout"] + if not get_updates: + timeouts.append("media_write_timeout") + # Code below tests if it's okay to set a Request object. Only okay if no other request args # or instances containing a Request were set previously - for attr in ("connect_timeout", "read_timeout", "write_timeout", "pool_timeout"): + for attr in timeouts: if not isinstance(getattr(self, f"_{prefix}{attr}"), DefaultValue): raise RuntimeError(_TWO_ARGS_REQ.format(name, attr)) if not isinstance(getattr(self, f"_{prefix}connection_pool_size"), DefaultValue): raise RuntimeError(_TWO_ARGS_REQ.format(name, "connection_pool_size")) - if not isinstance(getattr(self, f"_{prefix}proxy_url"), DefaultValue): - raise RuntimeError(_TWO_ARGS_REQ.format(name, "proxy_url")) + if not isinstance(getattr(self, f"_{prefix}proxy"), DefaultValue): + raise RuntimeError(_TWO_ARGS_REQ.format(name, "proxy")) + + if not isinstance(getattr(self, f"_{prefix}socket_options"), DefaultValue): + raise RuntimeError(_TWO_ARGS_REQ.format(name, "socket_options")) if not isinstance(getattr(self, f"_{prefix}http_version"), DefaultValue): raise RuntimeError(_TWO_ARGS_REQ.format(name, "http_version")) @@ -473,6 +506,8 @@ def connection_pool_size(self: BuilderType, connection_pool_size: int) -> Builde .. include:: inclusions/pool_size_tip.rst + .. seealso:: :meth:`get_updates_connection_pool_size` + Args: connection_pool_size (:obj:`int`): The size of the connection pool. @@ -483,26 +518,52 @@ def connection_pool_size(self: BuilderType, connection_pool_size: int) -> Builde self._connection_pool_size = connection_pool_size return self - def proxy_url(self: BuilderType, proxy_url: str) -> BuilderType: - """Sets the proxy for the :paramref:`~telegram.request.HTTPXRequest.proxy_url` + def proxy(self: BuilderType, proxy: str | httpx.Proxy | httpx.URL) -> BuilderType: + """Sets the proxy for the :paramref:`~telegram.request.HTTPXRequest.proxy` parameter of :attr:`telegram.Bot.request`. Defaults to :obj:`None`. + .. seealso:: :meth:`get_updates_proxy` + + .. versionadded:: 20.7 + Args: - proxy_url (:obj:`str`): The URL to the proxy server. See - :paramref:`telegram.request.HTTPXRequest.proxy_url` for more information. + proxy (:obj:`str` | ``httpx.Proxy`` | ``httpx.URL``): The URL to a proxy + server, a ``httpx.Proxy`` object or a ``httpx.URL`` object. See + :paramref:`telegram.request.HTTPXRequest.proxy` for more information. Returns: :class:`ApplicationBuilder`: The same builder with the updated argument. """ - self._request_param_check(name="proxy_url", get_updates=False) - self._proxy_url = proxy_url + self._request_param_check(name="proxy", get_updates=False) + self._proxy = proxy return self - def connect_timeout(self: BuilderType, connect_timeout: Optional[float]) -> BuilderType: + def socket_options(self: BuilderType, socket_options: Collection[SocketOpt]) -> BuilderType: + """Sets the options for the :paramref:`~telegram.request.HTTPXRequest.socket_options` + parameter of :attr:`telegram.Bot.request`. Defaults to :obj:`None`. + + .. seealso:: :meth:`get_updates_socket_options` + + .. versionadded:: 20.7 + + Args: + socket_options (Collection[:obj:`tuple`], optional): Socket options. See + :paramref:`telegram.request.HTTPXRequest.socket_options` for more information. + + Returns: + :class:`ApplicationBuilder`: The same builder with the updated argument. + """ + self._request_param_check(name="socket_options", get_updates=False) + self._socket_options = socket_options + return self + + def connect_timeout(self: BuilderType, connect_timeout: float | None) -> BuilderType: """Sets the connection attempt timeout for the :paramref:`~telegram.request.HTTPXRequest.connect_timeout` parameter of :attr:`telegram.Bot.request`. Defaults to ``5.0``. + .. seealso:: :meth:`get_updates_connect_timeout` + Args: connect_timeout (:obj:`float`): See :paramref:`telegram.request.HTTPXRequest.connect_timeout` for more information. @@ -514,11 +575,13 @@ def connect_timeout(self: BuilderType, connect_timeout: Optional[float]) -> Buil self._connect_timeout = connect_timeout return self - def read_timeout(self: BuilderType, read_timeout: Optional[float]) -> BuilderType: + def read_timeout(self: BuilderType, read_timeout: float | None) -> BuilderType: """Sets the waiting timeout for the :paramref:`~telegram.request.HTTPXRequest.read_timeout` parameter of :attr:`telegram.Bot.request`. Defaults to ``5.0``. + .. seealso:: :meth:`get_updates_read_timeout` + Args: read_timeout (:obj:`float`): See :paramref:`telegram.request.HTTPXRequest.read_timeout` for more information. @@ -530,11 +593,13 @@ def read_timeout(self: BuilderType, read_timeout: Optional[float]) -> BuilderTyp self._read_timeout = read_timeout return self - def write_timeout(self: BuilderType, write_timeout: Optional[float]) -> BuilderType: + def write_timeout(self: BuilderType, write_timeout: float | None) -> BuilderType: """Sets the write operation timeout for the :paramref:`~telegram.request.HTTPXRequest.write_timeout` parameter of :attr:`telegram.Bot.request`. Defaults to ``5.0``. + .. seealso:: :meth:`get_updates_write_timeout` + Args: write_timeout (:obj:`float`): See :paramref:`telegram.request.HTTPXRequest.write_timeout` for more information. @@ -546,13 +611,33 @@ def write_timeout(self: BuilderType, write_timeout: Optional[float]) -> BuilderT self._write_timeout = write_timeout return self - def pool_timeout(self: BuilderType, pool_timeout: Optional[float]) -> BuilderType: + def media_write_timeout(self: BuilderType, media_write_timeout: float | None) -> BuilderType: + """Sets the media write operation timeout for the + :paramref:`~telegram.request.HTTPXRequest.media_write_timeout` parameter of + :attr:`telegram.Bot.request`. Defaults to ``20``. + + .. versionadded:: 21.0 + + Args: + media_write_timeout (:obj:`float`): See + :paramref:`telegram.request.HTTPXRequest.media_write_timeout` for more information. + + Returns: + :class:`ApplicationBuilder`: The same builder with the updated argument. + """ + self._request_param_check(name="media_write_timeout", get_updates=False) + self._media_write_timeout = media_write_timeout + return self + + def pool_timeout(self: BuilderType, pool_timeout: float | None) -> BuilderType: """Sets the connection pool's connection freeing timeout for the :paramref:`~telegram.request.HTTPXRequest.pool_timeout` parameter of :attr:`telegram.Bot.request`. Defaults to ``1.0``. .. include:: inclusions/pool_size_tip.rst + .. seealso:: :meth:`get_updates_pool_timeout` + Args: pool_timeout (:obj:`float`): See :paramref:`telegram.request.HTTPXRequest.pool_timeout` for more information. @@ -564,7 +649,7 @@ def pool_timeout(self: BuilderType, pool_timeout: Optional[float]) -> BuilderTyp self._pool_timeout = pool_timeout return self - def http_version(self: BuilderType, http_version: str) -> BuilderType: + def http_version(self: BuilderType, http_version: HTTPVersion) -> BuilderType: """Sets the HTTP protocol version which is used for the :paramref:`~telegram.request.HTTPXRequest.http_version` parameter of :attr:`telegram.Bot.request`. By default, HTTP/1.1 is used. @@ -582,7 +667,7 @@ def http_version(self: BuilderType, http_version: str) -> BuilderType: .. code-block:: bash - pip install python-telegram-bot[http2] + pip install "python-telegram-bot[http2]" Keep in mind that the HTTP/1.1 implementation may be considered the `"more robust option at this time" `_. @@ -592,8 +677,11 @@ def http_version(self: BuilderType, http_version: str) -> BuilderType: Reset the default version to 1.1. Args: - http_version (:obj:`str`): Pass ``"2"`` if you'd like to use HTTP/2 for making - requests to Telegram. Defaults to ``"1.1"``, in which case HTTP/1.1 is used. + http_version (:obj:`str`): Pass ``"2"`` or ``"2.0"`` if you'd like to use HTTP/2 for + making requests to Telegram. Defaults to ``"1.1"``, in which case HTTP/1.1 is used. + + .. versionchanged:: 20.5 + Accept ``"2"`` as a valid value. Returns: :class:`ApplicationBuilder`: The same builder with the updated argument. @@ -626,6 +714,8 @@ def get_updates_connection_pool_size( :paramref:`telegram.request.HTTPXRequest.connection_pool_size` parameter which is used for the :meth:`telegram.Bot.get_updates` request. Defaults to ``1``. + .. seealso:: :meth:`connection_pool_size` + Args: get_updates_connection_pool_size (:obj:`int`): The size of the connection pool. @@ -636,28 +726,58 @@ def get_updates_connection_pool_size( self._get_updates_connection_pool_size = get_updates_connection_pool_size return self - def get_updates_proxy_url(self: BuilderType, get_updates_proxy_url: str) -> BuilderType: - """Sets the proxy for the :paramref:`telegram.request.HTTPXRequest.proxy_url` + def get_updates_proxy( + self: BuilderType, get_updates_proxy: str | httpx.Proxy | httpx.URL + ) -> BuilderType: + """Sets the proxy for the :paramref:`telegram.request.HTTPXRequest.proxy` parameter which is used for :meth:`telegram.Bot.get_updates`. Defaults to :obj:`None`. + .. seealso:: :meth:`proxy` + + .. versionadded:: 20.7 + + Args: + proxy (:obj:`str` | ``httpx.Proxy`` | ``httpx.URL``): The URL to a proxy server, + a ``httpx.Proxy`` object or a ``httpx.URL`` object. See + :paramref:`telegram.request.HTTPXRequest.proxy` for more information. + + Returns: + :class:`ApplicationBuilder`: The same builder with the updated argument. + """ + self._request_param_check(name="proxy", get_updates=True) + self._get_updates_proxy = get_updates_proxy + return self + + def get_updates_socket_options( + self: BuilderType, get_updates_socket_options: Collection[SocketOpt] + ) -> BuilderType: + """Sets the options for the :paramref:`~telegram.request.HTTPXRequest.socket_options` + parameter of :paramref:`telegram.Bot.get_updates_request`. Defaults to :obj:`None`. + + .. seealso:: :meth:`socket_options` + + .. versionadded:: 20.7 + Args: - get_updates_proxy_url (:obj:`str`): The URL to the proxy server. See - :paramref:`telegram.request.HTTPXRequest.proxy_url` for more information. + get_updates_socket_options (Collection[:obj:`tuple`], optional): Socket options. See + :paramref:`telegram.request.HTTPXRequest.socket_options` for more information. Returns: :class:`ApplicationBuilder`: The same builder with the updated argument. """ - self._request_param_check(name="proxy_url", get_updates=True) - self._get_updates_proxy_url = get_updates_proxy_url + self._request_param_check(name="socket_options", get_updates=True) + self._get_updates_socket_options = get_updates_socket_options return self def get_updates_connect_timeout( - self: BuilderType, get_updates_connect_timeout: Optional[float] + self: BuilderType, get_updates_connect_timeout: float | None ) -> BuilderType: """Sets the connection attempt timeout for the :paramref:`telegram.request.HTTPXRequest.connect_timeout` parameter which is used for the :meth:`telegram.Bot.get_updates` request. Defaults to ``5.0``. + .. seealso:: :meth:`connect_timeout` + Args: get_updates_connect_timeout (:obj:`float`): See :paramref:`telegram.request.HTTPXRequest.connect_timeout` for more information. @@ -670,12 +790,14 @@ def get_updates_connect_timeout( return self def get_updates_read_timeout( - self: BuilderType, get_updates_read_timeout: Optional[float] + self: BuilderType, get_updates_read_timeout: float | None ) -> BuilderType: """Sets the waiting timeout for the :paramref:`telegram.request.HTTPXRequest.read_timeout` parameter which is used for the :meth:`telegram.Bot.get_updates` request. Defaults to ``5.0``. + .. seealso:: :meth:`read_timeout` + Args: get_updates_read_timeout (:obj:`float`): See :paramref:`telegram.request.HTTPXRequest.read_timeout` for more information. @@ -688,12 +810,14 @@ def get_updates_read_timeout( return self def get_updates_write_timeout( - self: BuilderType, get_updates_write_timeout: Optional[float] + self: BuilderType, get_updates_write_timeout: float | None ) -> BuilderType: """Sets the write operation timeout for the :paramref:`telegram.request.HTTPXRequest.write_timeout` parameter which is used for the :meth:`telegram.Bot.get_updates` request. Defaults to ``5.0``. + .. seealso:: :meth:`write_timeout` + Args: get_updates_write_timeout (:obj:`float`): See :paramref:`telegram.request.HTTPXRequest.write_timeout` for more information. @@ -706,12 +830,14 @@ def get_updates_write_timeout( return self def get_updates_pool_timeout( - self: BuilderType, get_updates_pool_timeout: Optional[float] + self: BuilderType, get_updates_pool_timeout: float | None ) -> BuilderType: """Sets the connection pool's connection freeing timeout for the :paramref:`~telegram.request.HTTPXRequest.pool_timeout` parameter which is used for the :meth:`telegram.Bot.get_updates` request. Defaults to ``1.0``. + .. seealso:: :meth:`pool_timeout` + Args: get_updates_pool_timeout (:obj:`float`): See :paramref:`telegram.request.HTTPXRequest.pool_timeout` for more information. @@ -723,7 +849,9 @@ def get_updates_pool_timeout( self._get_updates_pool_timeout = get_updates_pool_timeout return self - def get_updates_http_version(self: BuilderType, get_updates_http_version: str) -> BuilderType: + def get_updates_http_version( + self: BuilderType, get_updates_http_version: HTTPVersion + ) -> BuilderType: """Sets the HTTP protocol version which is used for the :paramref:`~telegram.request.HTTPXRequest.http_version` parameter which is used in the :meth:`telegram.Bot.get_updates` request. By default, HTTP/1.1 is used. @@ -749,8 +877,12 @@ def get_updates_http_version(self: BuilderType, get_updates_http_version: str) - Reset the default version to 1.1. Args: - get_updates_http_version (:obj:`str`): Pass ``"2"`` if you'd like to use HTTP/2 for - making requests to Telegram. Defaults to ``"1.1"``, in which case HTTP/1.1 is used. + get_updates_http_version (:obj:`str`): Pass ``"2"`` or ``"2.0"`` if you'd like to use + HTTP/2 for making requests to Telegram. Defaults to ``"1.1"``, in which case + HTTP/1.1 is used. + + .. versionchanged:: 20.5 + Accept ``"2"`` as a valid value. Returns: :class:`ApplicationBuilder`: The same builder with the updated argument. @@ -761,8 +893,8 @@ def get_updates_http_version(self: BuilderType, get_updates_http_version: str) - def private_key( self: BuilderType, - private_key: Union[bytes, FilePathInput], - password: Optional[Union[bytes, FilePathInput]] = None, + private_key: bytes | FilePathInput, + password: bytes | FilePathInput | None = None, ) -> BuilderType: """Sets the private key and corresponding password for decryption of telegram passport data for :attr:`telegram.ext.Application.bot`. @@ -814,7 +946,7 @@ def defaults(self: BuilderType, defaults: "Defaults") -> BuilderType: return self def arbitrary_callback_data( - self: BuilderType, arbitrary_callback_data: Union[bool, int] + self: BuilderType, arbitrary_callback_data: bool | int ) -> BuilderType: """Specifies whether :attr:`telegram.ext.Application.bot` should allow arbitrary objects as callback data for :class:`telegram.InlineKeyboardButton` and how many keyboards should be @@ -827,7 +959,7 @@ def arbitrary_callback_data( .. code-block:: bash - pip install python-telegram-bot[callback-data] + pip install "python-telegram-bot[callback-data]" Examples: :any:`Arbitrary callback_data Bot ` @@ -906,7 +1038,7 @@ def update_queue(self: BuilderType, update_queue: "Queue[object]") -> BuilderTyp return self def concurrent_updates( - self: BuilderType, concurrent_updates: Union[bool, int, "BaseUpdateProcessor"] + self: BuilderType, concurrent_updates: "bool | int | BaseUpdateProcessor" ) -> BuilderType: """Specifies if and how many updates may be processed concurrently instead of one by one. If not called, updates will be processed one by one. @@ -929,7 +1061,7 @@ def concurrent_updates( :class:`telegram.ext.BaseUpdateProcessor` to use that instance for handling updates concurrently. - .. versionchanged:: NEXT.VERSION + .. versionchanged:: 20.4 Now accepts :class:`BaseUpdateProcessor` instances. Returns: @@ -963,7 +1095,7 @@ def job_queue( Examples: :any:`Timer Bot ` - .. seealso:: :wiki:`Job Queue ` + .. seealso:: :wiki:`Job Queue ` Note: * :meth:`telegram.ext.JobQueue.set_application` will be called automatically by @@ -1037,7 +1169,7 @@ def context_types( self._context_types = context_types return self # type: ignore[return-value] - def updater(self: BuilderType, updater: Optional[Updater]) -> BuilderType: + def updater(self: BuilderType, updater: Updater | None) -> BuilderType: """Sets a :class:`telegram.ext.Updater` instance for :attr:`telegram.ext.Application.updater`. The :attr:`telegram.ext.Updater.bot` and :attr:`telegram.ext.Updater.update_queue` will be used for @@ -1090,6 +1222,9 @@ async def post_init(application: Application) -> None: application = Application.builder().token("TOKEN").post_init(post_init).build() + Note: + |post_methods_note| + .. seealso:: :meth:`post_stop`, :meth:`post_shutdown` Args: @@ -1128,6 +1263,9 @@ async def post_shutdown(application: Application) -> None: .post_shutdown(post_shutdown) .build() + Note: + |post_methods_note| + .. seealso:: :meth:`post_init`, :meth:`post_stop` Args: @@ -1155,7 +1293,13 @@ def post_stop( Tip: This can be used for custom stop logic that requires to await coroutines, e.g. - sending message to a chat before shutting down the bot + sending message to a chat before shutting down the bot. + + Hint: + The callback will be called only, if :meth:`Application.stop` was indeed called + successfully. For example, if the application is stopped early by calling + :meth:`Application.stop_running` within :meth:`post_init`, then the set callback will + *not* be called. Example: .. code:: @@ -1168,6 +1312,9 @@ async def post_stop(application: Application) -> None: .post_stop(post_stop) .build() + Note: + |post_methods_note| + .. seealso:: :meth:`post_init`, :meth:`post_shutdown` Args: @@ -1207,9 +1354,9 @@ def rate_limiter( ApplicationBuilder[ # by Pylance correctly. ExtBot[None], ContextTypes.DEFAULT_TYPE, - Dict[Any, Any], - Dict[Any, Any], - Dict[Any, Any], + dict[Any, Any], + dict[Any, Any], + dict[Any, Any], JobQueue[ContextTypes.DEFAULT_TYPE], ] ) diff --git a/telegram/ext/_basepersistence.py b/src/telegram/ext/_basepersistence.py similarity index 92% rename from telegram/ext/_basepersistence.py rename to src/telegram/ext/_basepersistence.py index 85fb18f9dc3..7d5d2bcc2be 100644 --- a/telegram/ext/_basepersistence.py +++ b/src/telegram/ext/_basepersistence.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,15 +17,16 @@ # 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 the BasePersistence class.""" + from abc import ABC, abstractmethod -from typing import Dict, Generic, NamedTuple, NoReturn, Optional +from typing import Generic, NamedTuple, NoReturn from telegram._bot import Bot from telegram.ext._extbot import ExtBot from telegram.ext._utils.types import BD, CD, UD, CDCData, ConversationDict, ConversationKey -class PersistenceInput(NamedTuple): # skipcq: PYL-E0239 +class PersistenceInput(NamedTuple): """Convenience wrapper to group boolean input for the :paramref:`~BasePersistence.store_data` parameter for :class:`BasePersistence`. @@ -53,7 +54,7 @@ class PersistenceInput(NamedTuple): # skipcq: PYL-E0239 callback_data: bool = True -class BasePersistence(Generic[UD, CD, BD], ABC): +class BasePersistence(ABC, Generic[UD, CD, BD]): """Interface class for adding persistence to your bot. Subclass this object for different implementations of a persistent bot. @@ -138,14 +139,14 @@ class BasePersistence(Generic[UD, CD, BD], ABC): """ __slots__ = ( + "_update_interval", "bot", "store_data", - "_update_interval", ) def __init__( self, - store_data: Optional[PersistenceInput] = None, + store_data: PersistenceInput | None = None, update_interval: float = 60, ): self.store_data: PersistenceInput = store_data or PersistenceInput() @@ -163,7 +164,7 @@ def update_interval(self) -> float: return self._update_interval @update_interval.setter - def update_interval(self, value: object) -> NoReturn: + def update_interval(self, _: object) -> NoReturn: raise AttributeError( "You can not assign a new value to update_interval after initialization." ) @@ -184,7 +185,7 @@ def set_bot(self, bot: Bot) -> None: self.bot = bot @abstractmethod - async def get_user_data(self) -> Dict[int, UD]: + async def get_user_data(self) -> dict[int, UD]: """Will be called by :class:`telegram.ext.Application` upon creation with a persistence object. It should return the ``user_data`` if stored, or an empty :obj:`dict`. In the latter case, the dictionary should produce values @@ -198,12 +199,12 @@ async def get_user_data(self) -> Dict[int, UD]: This method may now return a :obj:`dict` instead of a :obj:`collections.defaultdict` Returns: - Dict[:obj:`int`, :obj:`dict` | :attr:`telegram.ext.ContextTypes.user_data`]: + dict[:obj:`int`, :obj:`dict` | :attr:`telegram.ext.ContextTypes.user_data`]: The restored user data. """ @abstractmethod - async def get_chat_data(self) -> Dict[int, CD]: + async def get_chat_data(self) -> dict[int, CD]: """Will be called by :class:`telegram.ext.Application` upon creation with a persistence object. It should return the ``chat_data`` if stored, or an empty :obj:`dict`. In the latter case, the dictionary should produce values @@ -217,7 +218,7 @@ async def get_chat_data(self) -> Dict[int, CD]: This method may now return a :obj:`dict` instead of a :obj:`collections.defaultdict` Returns: - Dict[:obj:`int`, :obj:`dict` | :attr:`telegram.ext.ContextTypes.chat_data`]: + dict[:obj:`int`, :obj:`dict` | :attr:`telegram.ext.ContextTypes.chat_data`]: The restored chat data. """ @@ -233,12 +234,12 @@ async def get_bot_data(self) -> BD: if :class:`telegram.ext.ContextTypes` are used. Returns: - Dict[:obj:`int`, :obj:`dict` | :attr:`telegram.ext.ContextTypes.bot_data`]: + dict[:obj:`int`, :obj:`dict` | :attr:`telegram.ext.ContextTypes.bot_data`]: The restored bot data. """ @abstractmethod - async def get_callback_data(self) -> Optional[CDCData]: + async def get_callback_data(self) -> CDCData | None: """Will be called by :class:`telegram.ext.Application` upon creation with a persistence object. If callback data was stored, it should be returned. @@ -248,8 +249,8 @@ async def get_callback_data(self) -> Optional[CDCData]: Changed this method into an :external:func:`~abc.abstractmethod`. Returns: - Tuple[List[Tuple[:obj:`str`, :obj:`float`, Dict[:obj:`str`, :class:`object`]]], - Dict[:obj:`str`, :obj:`str`]] | :obj:`None`: The restored metadata or :obj:`None`, + tuple[list[tuple[:obj:`str`, :obj:`float`, dict[:obj:`str`, :class:`object`]]], + dict[:obj:`str`, :obj:`str`]] | :obj:`None`: The restored metadata or :obj:`None`, if no data was stored. """ @@ -270,7 +271,7 @@ async def get_conversations(self, name: str) -> ConversationDict: @abstractmethod async def update_conversation( - self, name: str, key: ConversationKey, new_state: Optional[object] + self, name: str, key: ConversationKey, new_state: object | None ) -> None: """Will be called when a :class:`telegram.ext.ConversationHandler` changes states. This allows the storage of the new state in the persistence. @@ -324,8 +325,8 @@ async def update_callback_data(self, data: CDCData) -> None: Changed this method into an :external:func:`~abc.abstractmethod`. Args: - data (Tuple[List[Tuple[:obj:`str`, :obj:`float`, \ - Dict[:obj:`str`, :obj:`Any`]]], Dict[:obj:`str`, :obj:`str`]] | :obj:`None`): + data (tuple[list[tuple[:obj:`str`, :obj:`float`, \ + dict[:obj:`str`, :obj:`Any`]]], dict[:obj:`str`, :obj:`str`]] | :obj:`None`): The relevant data to restore :class:`telegram.ext.CallbackDataCache`. """ @@ -357,6 +358,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 +385,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 +412,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/_baseratelimiter.py b/src/telegram/ext/_baseratelimiter.py similarity index 90% rename from telegram/ext/_baseratelimiter.py rename to src/telegram/ext/_baseratelimiter.py index cef939cbfc4..f6fe6cb028e 100644 --- a/telegram/ext/_baseratelimiter.py +++ b/src/telegram/ext/_baseratelimiter.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,10 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains a class that allows to rate limit requests to the Bot API.""" + from abc import ABC, abstractmethod -from typing import Any, Callable, Coroutine, Dict, Generic, List, Optional, Union +from collections.abc import Callable, Coroutine +from typing import Any, Generic from telegram._utils.types import JSONDict from telegram.ext._utils.types import RLARGS @@ -56,13 +58,13 @@ async def shutdown(self) -> None: @abstractmethod async def process_request( self, - callback: Callable[..., Coroutine[Any, Any, Union[bool, JSONDict, List[JSONDict]]]], + callback: Callable[..., Coroutine[Any, Any, bool | JSONDict | list[JSONDict]]], args: Any, - kwargs: Dict[str, Any], + kwargs: dict[str, Any], endpoint: str, - data: Dict[str, Any], - rate_limit_args: Optional[RLARGS], - ) -> Union[bool, JSONDict, List[JSONDict]]: + data: dict[str, Any], + rate_limit_args: RLARGS | None, + ) -> bool | JSONDict | list[JSONDict]: """ Process a request. Must be implemented by a subclass. @@ -107,13 +109,13 @@ async def process_request( Args: callback (Callable[..., :term:`coroutine`]): The coroutine function that must be called to make the request. - args (Tuple[:obj:`object`]): The positional arguments for the :paramref:`callback` + args (tuple[:obj:`object`]): The positional arguments for the :paramref:`callback` function. - kwargs (Dict[:obj:`str`, :obj:`object`]): The keyword arguments for the + kwargs (dict[:obj:`str`, :obj:`object`]): The keyword arguments for the :paramref:`callback` function. endpoint (:obj:`str`): The endpoint that the request is made for, e.g. ``"sendMessage"``. - data (Dict[:obj:`str`, :obj:`object`]): The parameters that were passed to the method + data (dict[:obj:`str`, :obj:`object`]): The parameters that were passed to the method of :class:`~telegram.ext.ExtBot`. Any ``api_kwargs`` are included in this and any :paramref:`~telegram.ext.ExtBot.defaults` are already applied. @@ -136,6 +138,6 @@ async def process_request( the request. Returns: - :obj:`bool` | Dict[:obj:`str`, :obj:`object`] | :obj:`None`: The result of the + :obj:`bool` | dict[:obj:`str`, :obj:`object`] | :obj:`None`: The result of the callback function. """ diff --git a/telegram/ext/_baseupdateprocessor.py b/src/telegram/ext/_baseupdateprocessor.py similarity index 69% rename from telegram/ext/_baseupdateprocessor.py rename to src/telegram/ext/_baseupdateprocessor.py index a3b59d4fb92..1d231d94114 100644 --- a/telegram/ext/_baseupdateprocessor.py +++ b/src/telegram/ext/_baseupdateprocessor.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,19 +17,46 @@ # 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 the BaseProcessor class.""" + from abc import ABC, abstractmethod -from asyncio import BoundedSemaphore +from contextlib import AbstractAsyncContextManager from types import TracebackType -from typing import Any, Awaitable, Optional, Type +from typing import TYPE_CHECKING, Any, TypeVar, final + +from telegram.ext._utils.asyncio import TrackedBoundedSemaphore + +if TYPE_CHECKING: + from collections.abc import Awaitable +_BUPT = TypeVar("_BUPT", bound="BaseUpdateProcessor") -class BaseUpdateProcessor(ABC): + +class BaseUpdateProcessor(AbstractAsyncContextManager["BaseUpdateProcessor"], ABC): """An abstract base class for update processors. You can use this class to implement your own update processor. + Instances of this class can be used as asyncio context managers, where + + .. code:: python + + async with processor: + # code + + is roughly equivalent to + + .. code:: python + + try: + await processor.initialize() + # code + finally: + await processor.shutdown() + + .. seealso:: :meth:`__aenter__` and :meth:`__aexit__`. + .. seealso:: :wiki:`Concurrency` - .. versionadded:: NEXT.VERSION + .. versionadded:: 20.4 Args: max_concurrent_updates (:obj:`int`): The maximum number of updates to be processed @@ -46,13 +73,51 @@ def __init__(self, max_concurrent_updates: int): self._max_concurrent_updates = max_concurrent_updates if self.max_concurrent_updates < 1: raise ValueError("`max_concurrent_updates` must be a positive integer!") - self._semaphore = BoundedSemaphore(self.max_concurrent_updates) + self._semaphore = TrackedBoundedSemaphore(self.max_concurrent_updates) + + async def __aenter__(self: _BUPT) -> _BUPT: + """|async_context_manager| :meth:`initializes ` the Processor. + + Returns: + The initialized Processor instance. + + Raises: + :exc:`Exception`: If an exception is raised during initialization, :meth:`shutdown` + is called in this case. + """ + try: + await self.initialize() + except Exception: + await self.shutdown() + raise + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """|async_context_manager| :meth:`shuts down ` the Processor.""" + await self.shutdown() @property def max_concurrent_updates(self) -> int: """:obj:`int`: The maximum number of updates that can be processed concurrently.""" return self._max_concurrent_updates + @property + def current_concurrent_updates(self) -> int: + """:obj:`int`: The number of updates currently being processed. + + Caution: + This value is a snapshot of the current number of updates being processed. It may + change immediately after being read. + + .. versionadded:: 21.11 + """ + return self.max_concurrent_updates - self._semaphore.current_value + @abstractmethod async def do_process_update( self, @@ -88,6 +153,7 @@ async def shutdown(self) -> None: :meth:`initialize` """ + @final async def process_update( self, update: object, @@ -104,38 +170,20 @@ async def process_update( async with self._semaphore: await self.do_process_update(update, coroutine) - async def __aenter__(self) -> "BaseUpdateProcessor": - """Simple context manager which initializes the Processor.""" - try: - await self.initialize() - return self - except Exception as exc: - await self.shutdown() - raise exc - - async def __aexit__( - self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> None: - """Shutdown the Processor from the context manager.""" - await self.shutdown() - class SimpleUpdateProcessor(BaseUpdateProcessor): """Instance of :class:`telegram.ext.BaseUpdateProcessor` that immediately awaits the coroutine, i.e. does not apply any additional processing. This is used by default when :attr:`telegram.ext.ApplicationBuilder.concurrent_updates` is :obj:`int`. - .. versionadded:: NEXT.VERSION + .. versionadded:: 20.4 """ __slots__ = () async def do_process_update( self, - update: object, + update: object, # noqa: ARG002 coroutine: "Awaitable[Any]", ) -> None: """Immediately awaits the coroutine, i.e. does not apply any additional processing. diff --git a/telegram/ext/_callbackcontext.py b/src/telegram/ext/_callbackcontext.py similarity index 86% rename from telegram/ext/_callbackcontext.py rename to src/telegram/ext/_callbackcontext.py index 1fde626d6b9..2689729c15c 100644 --- a/telegram/ext/_callbackcontext.py +++ b/src/telegram/ext/_callbackcontext.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,20 +17,10 @@ # 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 the CallbackContext class.""" -from typing import ( - TYPE_CHECKING, - Any, - Awaitable, - Dict, - Generator, - Generic, - List, - Match, - NoReturn, - Optional, - Type, - Union, -) + +from collections.abc import Awaitable, Generator +from re import Match +from typing import TYPE_CHECKING, Any, Generic, NoReturn, TypeVar from telegram._callbackquery import CallbackQuery from telegram._update import Update @@ -49,6 +39,9 @@ "/wiki/Storing-bot%2C-user-and-chat-related-data" ) +# something like poor mans "typing.Self" for py<3.11 +ST = TypeVar("ST", bound="CallbackContext[Any, Any, Any, Any]") + class CallbackContext(Generic[BT, UD, CD, BD]): """ @@ -84,7 +77,7 @@ class CallbackContext(Generic[BT, UD, CD, BD]): * :any:`Custom Webhook Bot ` .. seealso:: :attr:`telegram.ext.ContextTypes.DEFAULT_TYPE`, - :wiki:`Job Queue ` + :wiki:`Job Queue ` Args: application (:class:`telegram.ext.Application`): The application associated with this @@ -101,11 +94,11 @@ class CallbackContext(Generic[BT, UD, CD, BD]): coroutine (:term:`awaitable`): Optional. Only present in error handlers if the error was caused by an awaitable run with :meth:`Application.create_task` or a handler callback with :attr:`block=False `. - matches (List[:meth:`re.Match `]): Optional. If the associated update + matches (list[:meth:`re.Match `]): Optional. If the associated update originated from a :class:`filters.Regex`, this will contain a list of match objects for every pattern where ``re.search(pattern, string)`` returned a match. Note that filters short circuit, so combined regex filters will not always be evaluated. - args (List[:obj:`str`]): Optional. Arguments passed to a command if the associated update + args (list[:obj:`str`]): Optional. Arguments passed to a command if the associated update is handled by :class:`telegram.ext.CommandHandler`, :class:`telegram.ext.PrefixHandler` or :class:`telegram.ext.StringCommandHandler`. It contains a list of the words in the text after the command, using any whitespace string as a delimiter. @@ -121,38 +114,36 @@ class CallbackContext(Generic[BT, UD, CD, BD]): """ __slots__ = ( + "__dict__", "_application", "_chat_id", "_user_id", "args", - "matches", + "coroutine", "error", "job", - "coroutine", - "__dict__", + "matches", ) def __init__( - self: "CCT", - application: "Application[BT, CCT, UD, CD, BD, Any]", - chat_id: Optional[int] = None, - user_id: Optional[int] = None, + self: ST, + application: "Application[BT, ST, UD, CD, BD, Any]", + chat_id: int | None = None, + user_id: int | None = None, ): - self._application: Application[BT, CCT, UD, CD, BD, Any] = application - self._chat_id: Optional[int] = chat_id - self._user_id: Optional[int] = user_id - self.args: Optional[List[str]] = None - self.matches: Optional[List[Match[str]]] = None - self.error: Optional[Exception] = None - self.job: Optional["Job[CCT]"] = None - self.coroutine: Optional[ - Union[Generator[Optional["Future[object]"], None, Any], Awaitable[Any]] - ] = None + self._application: Application[BT, ST, UD, CD, BD, Any] = application + self._chat_id: int | None = chat_id + self._user_id: int | None = user_id + self.args: list[str] | None = None + self.matches: list[Match[str]] | None = None + self.error: Exception | None = None + self.job: Job[Any] | None = None + self.coroutine: Generator[Future[object] | None, None, Any] | Awaitable[Any] | None = None @property - def application(self) -> "Application[BT, CCT, UD, CD, BD, Any]": + def application(self) -> "Application[BT, ST, UD, CD, BD, Any]": """:class:`telegram.ext.Application`: The application associated with this context.""" - return self._application + return self._application # type: ignore[return-value] @property def bot_data(self) -> BD: @@ -165,13 +156,13 @@ def bot_data(self) -> BD: return self.application.bot_data @bot_data.setter - def bot_data(self, value: object) -> NoReturn: + def bot_data(self, _: object) -> NoReturn: raise AttributeError( f"You can not assign a new value to bot_data, see {_STORING_DATA_WIKI}" ) @property - def chat_data(self) -> Optional[CD]: + def chat_data(self) -> CD | None: """:obj:`ContextTypes.chat_data`: Optional. An object that can be used to keep any data in. For each update from the same chat id it will be the same :obj:`ContextTypes.chat_data`. Defaults to :obj:`dict`. @@ -192,13 +183,13 @@ def chat_data(self) -> Optional[CD]: return None @chat_data.setter - def chat_data(self, value: object) -> NoReturn: + def chat_data(self, _: object) -> NoReturn: raise AttributeError( f"You can not assign a new value to chat_data, see {_STORING_DATA_WIKI}" ) @property - def user_data(self) -> Optional[UD]: + def user_data(self) -> UD | None: """:obj:`ContextTypes.user_data`: Optional. An object that can be used to keep any data in. For each update from the same user it will be the same :obj:`ContextTypes.user_data`. Defaults to :obj:`dict`. @@ -214,7 +205,7 @@ def user_data(self) -> Optional[UD]: return None @user_data.setter - def user_data(self, value: object) -> NoReturn: + def user_data(self, _: object) -> NoReturn: raise AttributeError( f"You can not assign a new value to user_data, see {_STORING_DATA_WIKI}" ) @@ -236,11 +227,13 @@ async def refresh_data(self) -> None: await self.application.persistence.refresh_bot_data(self.bot_data) if self.application.persistence.store_data.chat_data and self._chat_id is not None: await self.application.persistence.refresh_chat_data( - chat_id=self._chat_id, chat_data=self.chat_data # type: ignore[arg-type] + chat_id=self._chat_id, + chat_data=self.chat_data, # type: ignore[arg-type] ) if self.application.persistence.store_data.user_data and self._user_id is not None: await self.application.persistence.refresh_user_data( - user_id=self._user_id, user_data=self.user_data # type: ignore[arg-type] + user_id=self._user_id, + user_data=self.user_data, # type: ignore[arg-type] ) def drop_callback_data(self, callback_query: CallbackQuery) -> None: @@ -270,18 +263,18 @@ def drop_callback_data(self, callback_query: CallbackQuery) -> None: ) self.bot.callback_data_cache.drop_data(callback_query) else: - raise RuntimeError("telegram.Bot does not allow for arbitrary callback data.") + raise RuntimeError( # noqa: TRY004 + "telegram.Bot does not allow for arbitrary callback data." + ) @classmethod def from_error( - cls: Type["CCT"], + cls: type["CCT"], update: object, error: Exception, application: "Application[BT, CCT, UD, CD, BD, Any]", - job: Optional["Job[Any]"] = None, - coroutine: Optional[ - Union[Generator[Optional["Future[object]"], None, Any], Awaitable[Any]] - ] = None, + job: "Job[Any] | None" = None, + coroutine: Generator["Future[object] | None", None, Any] | Awaitable[Any] | None = None, ) -> "CCT": """ Constructs an instance of :class:`telegram.ext.CallbackContext` to be passed to the error @@ -327,7 +320,7 @@ def from_error( @classmethod def from_update( - cls: Type["CCT"], + cls: type["CCT"], update: object, application: "Application[Any, CCT, Any, Any, Any, Any]", ) -> "CCT": @@ -357,7 +350,7 @@ def from_update( @classmethod def from_job( - cls: Type["CCT"], + cls: type["CCT"], job: "Job[CCT]", application: "Application[Any, CCT, Any, Any, Any, Any]", ) -> "CCT": @@ -379,11 +372,11 @@ def from_job( self.job = job return self - def update(self, data: Dict[str, object]) -> None: + def update(self, data: dict[str, object]) -> None: """Updates ``self.__slots__`` with the passed data. Args: - data (Dict[:obj:`str`, :obj:`object`]): The data. + data (dict[:obj:`str`, :obj:`object`]): The data. """ for key, value in data.items(): setattr(self, key, value) @@ -394,17 +387,17 @@ def bot(self) -> BT: return self._application.bot @property - def job_queue(self) -> Optional["JobQueue[CCT]"]: + def job_queue(self) -> "JobQueue[ST] | None": """ :class:`telegram.ext.JobQueue`: The :class:`JobQueue` used by the :class:`telegram.ext.Application`. - .. seealso:: :wiki:`Job Queue ` + .. seealso:: :wiki:`Job Queue ` """ if self._application._job_queue is None: # pylint: disable=protected-access warn( "No `JobQueue` set up. To use `JobQueue`, you must install PTB via " - "`pip install python-telegram-bot[job-queue]`.", + '`pip install "python-telegram-bot[job-queue]"`.', stacklevel=2, ) return self._application._job_queue # pylint: disable=protected-access @@ -420,7 +413,7 @@ def update_queue(self) -> "Queue[object]": return self._application.update_queue @property - def match(self) -> Optional[Match[str]]: + def match(self) -> Match[str] | None: """ :meth:`re.Match `: The first match from :attr:`matches`. Useful if you are only filtering using a single regex filter. diff --git a/telegram/ext/_callbackdatacache.py b/src/telegram/ext/_callbackdatacache.py similarity index 85% rename from telegram/ext/_callbackdatacache.py rename to src/telegram/ext/_callbackdatacache.py index 42d32da24b3..fa43faf7dde 100644 --- a/telegram/ext/_callbackdatacache.py +++ b/src/telegram/ext/_callbackdatacache.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,9 +17,11 @@ # 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 the CallbackDataCache class.""" + +import datetime as dtm import time -from datetime import datetime -from typing import TYPE_CHECKING, Any, Dict, MutableMapping, Optional, Tuple, Union, cast +from collections.abc import MutableMapping +from typing import TYPE_CHECKING, Any, cast from uuid import uuid4 try: @@ -30,6 +32,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 @@ -61,25 +65,31 @@ class InvalidCallbackData(TelegramError): __slots__ = ("callback_data",) - def __init__(self, callback_data: Optional[str] = None) -> None: + def __init__(self, callback_data: str | None = None) -> None: super().__init__( "The object belonging to this callback_data was deleted or the callback_data was " "manipulated." ) - self.callback_data: Optional[str] = callback_data + self.callback_data: str | None = callback_data + + def __reduce__(self) -> tuple[type, tuple[str | None]]: # type: ignore[override] + """Defines how to serialize the exception for pickle. See + :py:meth:`object.__reduce__` for more info. - def __reduce__(self) -> Tuple[type, Tuple[Optional[str]]]: # type: ignore[override] + Returns: + :obj:`tuple` + """ return self.__class__, (self.callback_data,) class _KeyboardData: - __slots__ = ("keyboard_uuid", "button_data", "access_time") + __slots__ = ("access_time", "button_data", "keyboard_uuid") def __init__( self, keyboard_uuid: str, - access_time: Optional[float] = None, - button_data: Optional[Dict[str, object]] = None, + access_time: float | None = None, + button_data: dict[str, object] | None = None, ): self.keyboard_uuid = keyboard_uuid self.button_data = button_data or {} @@ -89,7 +99,7 @@ def update_access_time(self) -> None: """Updates the access time with the current time.""" self.access_time = time.time() - def to_tuple(self) -> Tuple[str, float, Dict[str, object]]: + def to_tuple(self) -> tuple[str, float, dict[str, object]]: """Gives a tuple representation consisting of the keyboard uuid, the access time and the button data. """ @@ -113,7 +123,7 @@ class CallbackDataCache: .. code-block:: bash - pip install python-telegram-bot[callback-data] + pip install "python-telegram-bot[callback-data]" Examples: :any:`Arbitrary Callback Data Bot ` @@ -125,15 +135,15 @@ class CallbackDataCache: .. versionchanged:: 20.0 To use this class, PTB must be installed via - ``pip install python-telegram-bot[callback-data]``. + ``pip install "python-telegram-bot[callback-data]"``. Args: bot (:class:`telegram.ext.ExtBot`): The bot this cache is for. maxsize (:obj:`int`, optional): Maximum number of items in each of the internal mappings. Defaults to ``1024``. - persistent_data (Tuple[List[Tuple[:obj:`str`, :obj:`float`, \ - Dict[:obj:`str`, :class:`object`]]], Dict[:obj:`str`, :obj:`str`]], optional): \ + persistent_data (tuple[list[tuple[:obj:`str`, :obj:`float`, \ + dict[:obj:`str`, :class:`object`]]], dict[:obj:`str`, :obj:`str`]], optional): \ Data to initialize the cache with, as returned by \ :meth:`telegram.ext.BasePersistence.get_callback_data`. @@ -142,18 +152,18 @@ class CallbackDataCache: """ - __slots__ = ("bot", "_maxsize", "_keyboard_data", "_callback_queries") + __slots__ = ("_callback_queries", "_keyboard_data", "_maxsize", "bot") def __init__( self, bot: "ExtBot[Any]", maxsize: int = 1024, - persistent_data: Optional[CDCData] = None, + persistent_data: CDCData | None = None, ): if not CACHE_TOOLS_AVAILABLE: raise RuntimeError( "To use `CallbackDataCache`, PTB must be installed via `pip install " - "python-telegram-bot[callback-data]`." + '"python-telegram-bot[callback-data]"`.' ) self.bot: ExtBot[Any] = bot @@ -173,8 +183,8 @@ def load_persistence_data(self, persistent_data: CDCData) -> None: .. versionadded:: 20.0 Args: - persistent_data (Tuple[List[Tuple[:obj:`str`, :obj:`float`, \ - Dict[:obj:`str`, :class:`object`]]], Dict[:obj:`str`, :obj:`str`]], optional): \ + persistent_data (tuple[list[tuple[:obj:`str`, :obj:`float`, \ + dict[:obj:`str`, :class:`object`]]], dict[:obj:`str`, :obj:`str`]], optional): \ Data to load, as returned by \ :meth:`telegram.ext.BasePersistence.get_callback_data`. """ @@ -197,8 +207,8 @@ def maxsize(self) -> int: @property def persistence_data(self) -> CDCData: - """Tuple[List[Tuple[:obj:`str`, :obj:`float`, Dict[:obj:`str`, :class:`object`]]], - Dict[:obj:`str`, :obj:`str`]]: The data that needs to be persisted to allow + """tuple[list[tuple[:obj:`str`, :obj:`float`, dict[:obj:`str`, :class:`object`]]], + dict[:obj:`str`, :obj:`str`]]: The data that needs to be persisted to allow caching callback data across bot reboots. """ # While building a list/dict from the LRUCaches has linear runtime (in the number of @@ -227,14 +237,16 @@ def process_keyboard(self, reply_markup: InlineKeyboardMarkup) -> InlineKeyboard # Built a new nested list of buttons by replacing the callback data if needed buttons = [ [ - # We create a new button instead of replacing callback_data in case the - # same object is used elsewhere - InlineKeyboardButton( - btn.text, - callback_data=self.__put_button(btn.callback_data, keyboard_data), + ( + # We create a new button instead of replacing callback_data in case the + # same object is used elsewhere + InlineKeyboardButton( + btn.text, + callback_data=self.__put_button(btn.callback_data, keyboard_data), + ) + if btn.callback_data + else btn ) - if btn.callback_data - else btn for btn in column ] for column in reply_markup.inline_keyboard @@ -259,7 +271,7 @@ def __put_button(callback_data: object, keyboard_data: _KeyboardData) -> str: def __get_keyboard_uuid_and_button_data( self, callback_data: str - ) -> Union[Tuple[str, object], Tuple[None, InvalidCallbackData]]: + ) -> tuple[str, object] | tuple[None, InvalidCallbackData]: keyboard, button = self.extract_uuids(callback_data) try: # we get the values before calling update() in case KeyErrors are raised @@ -268,12 +280,12 @@ def __get_keyboard_uuid_and_button_data( button_data = keyboard_data.button_data[button] # Update the timestamp for the LRU keyboard_data.update_access_time() - return keyboard, button_data except KeyError: return None, InvalidCallbackData(callback_data) + return keyboard, button_data @staticmethod - def extract_uuids(callback_data: str) -> Tuple[str, str]: + def extract_uuids(callback_data: str) -> tuple[str, str]: """Extracts the keyboard uuid and the button uuid from the given :paramref:`callback_data`. Args: @@ -312,7 +324,7 @@ def process_message(self, message: Message) -> None: """ self.__process_message(message) - def __process_message(self, message: Message) -> Optional[str]: + def __process_message(self, message: Message) -> str | None: """As documented in process_message, but returns the uuid of the attached keyboard, if any, which is relevant for process_callback_query. @@ -322,7 +334,7 @@ def __process_message(self, message: Message) -> Optional[str]: return None if message.via_bot: - sender: Optional[User] = message.via_bot + sender: User | None = message.via_bot elif message.from_user: sender = message.from_user else: @@ -336,7 +348,7 @@ def __process_message(self, message: Message) -> Optional[str]: for row in message.reply_markup.inline_keyboard: for button in row: if button.callback_data: - button_data = cast(str, button.callback_data) + button_data = cast("str", button.callback_data) keyboard_id, callback_data = self.__get_keyboard_uuid_and_button_data( button_data ) @@ -387,14 +399,14 @@ def process_callback_query(self, callback_query: CallbackQuery) -> None: # Get the cached callback data for the inline keyboard attached to the # CallbackQuery. - if callback_query.message: + if isinstance(callback_query.message, Message): self.__process_message(callback_query.message) - for message in ( + for maybe_message in ( callback_query.message.pinned_message, callback_query.message.reply_to_message, ): - if message: - self.__process_message(message) + if isinstance(maybe_message, Message): + self.__process_message(maybe_message) def drop_data(self, callback_query: CallbackQuery) -> None: """Deletes the data for the specified callback query. @@ -417,20 +429,16 @@ 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: + def clear_callback_data(self, time_cutoff: float | dtm.datetime | None = None) -> None: """Clears the stored callback data. Args: time_cutoff (:obj:`float` | :obj:`datetime.datetime`, optional): Pass a UNIX timestamp or a :obj:`datetime.datetime` to clear only entries which are older. - For timezone naive :obj:`datetime.datetime` objects, the default timezone of the - bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is - used. + |tz-naive-dtms| """ self.__clear(self._keyboard_data, time_cutoff=time_cutoff) @@ -440,13 +448,13 @@ def clear_callback_queries(self) -> None: self.__clear(self._callback_queries) def __clear( - self, mapping: MutableMapping, time_cutoff: Optional[Union[float, datetime]] = None + self, mapping: MutableMapping, time_cutoff: float | dtm.datetime | None = None ) -> None: if not time_cutoff: mapping.clear() return - if isinstance(time_cutoff, datetime): + if isinstance(time_cutoff, dtm.datetime): effective_cutoff = to_float_timestamp( time_cutoff, tzinfo=self.bot.defaults.tzinfo if self.bot.defaults else None ) diff --git a/telegram/ext/_contexttypes.py b/src/telegram/ext/_contexttypes.py similarity index 76% rename from telegram/ext/_contexttypes.py rename to src/telegram/ext/_contexttypes.py index e46ea4e8f13..bd975b6bdda 100644 --- a/telegram/ext/_contexttypes.py +++ b/src/telegram/ext/_contexttypes.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,13 +17,14 @@ # 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 the auxiliary class ContextTypes.""" -from typing import Any, Dict, Generic, Type, overload + +from typing import Any, Generic, overload from telegram.ext._callbackcontext import CallbackContext from telegram.ext._extbot import ExtBot from telegram.ext._utils.types import BD, CCT, CD, UD -ADict = Dict[Any, Any] +ADict = dict[Any, Any] class ContextTypes(Generic[CCT, UD, CD, BD]): @@ -72,7 +73,7 @@ async def callback(update: Update, context: ContextTypes.DEFAULT_TYPE): .. versionadded: 20.0 """ - __slots__ = ("_context", "_bot_data", "_chat_data", "_user_data") + __slots__ = ("_bot_data", "_chat_data", "_context", "_user_data") # overload signatures generated with # https://gist.github.com/Bibo-Joshi/399382cda537fb01bd86b13c3d03a956 @@ -80,131 +81,115 @@ async def callback(update: Update, context: ContextTypes.DEFAULT_TYPE): @overload def __init__( self: "ContextTypes[CallbackContext[ExtBot[Any], ADict, ADict, ADict], ADict, ADict, ADict]", # pylint: disable=line-too-long # noqa: E501 - ): - ... + ): ... @overload - def __init__(self: "ContextTypes[CCT, ADict, ADict, ADict]", context: Type[CCT]): - ... + def __init__(self: "ContextTypes[CCT, ADict, ADict, ADict]", context: type[CCT]): ... @overload def __init__( self: "ContextTypes[CallbackContext[ExtBot[Any], UD, ADict, ADict], UD, ADict, ADict]", - user_data: Type[UD], - ): - ... + user_data: type[UD], + ): ... @overload def __init__( self: "ContextTypes[CallbackContext[ExtBot[Any], ADict, CD, ADict], ADict, CD, ADict]", - chat_data: Type[CD], - ): - ... + chat_data: type[CD], + ): ... @overload def __init__( self: "ContextTypes[CallbackContext[ExtBot[Any], ADict, ADict, BD], ADict, ADict, BD]", - bot_data: Type[BD], - ): - ... + bot_data: type[BD], + ): ... @overload def __init__( - self: "ContextTypes[CCT, UD, ADict, ADict]", context: Type[CCT], user_data: Type[UD] - ): - ... + self: "ContextTypes[CCT, UD, ADict, ADict]", context: type[CCT], user_data: type[UD] + ): ... @overload def __init__( - self: "ContextTypes[CCT, ADict, CD, ADict]", context: Type[CCT], chat_data: Type[CD] - ): - ... + self: "ContextTypes[CCT, ADict, CD, ADict]", context: type[CCT], chat_data: type[CD] + ): ... @overload def __init__( - self: "ContextTypes[CCT, ADict, ADict, BD]", context: Type[CCT], bot_data: Type[BD] - ): - ... + self: "ContextTypes[CCT, ADict, ADict, BD]", context: type[CCT], bot_data: type[BD] + ): ... @overload def __init__( self: "ContextTypes[CallbackContext[ExtBot[Any], UD, CD, ADict], UD, CD, ADict]", - user_data: Type[UD], - chat_data: Type[CD], - ): - ... + user_data: type[UD], + chat_data: type[CD], + ): ... @overload def __init__( self: "ContextTypes[CallbackContext[ExtBot[Any], UD, ADict, BD], UD, ADict, BD]", - user_data: Type[UD], - bot_data: Type[BD], - ): - ... + user_data: type[UD], + bot_data: type[BD], + ): ... @overload def __init__( self: "ContextTypes[CallbackContext[ExtBot[Any], ADict, CD, BD], ADict, CD, BD]", - chat_data: Type[CD], - bot_data: Type[BD], - ): - ... + chat_data: type[CD], + bot_data: type[BD], + ): ... @overload def __init__( self: "ContextTypes[CCT, UD, CD, ADict]", - context: Type[CCT], - user_data: Type[UD], - chat_data: Type[CD], - ): - ... + context: type[CCT], + user_data: type[UD], + chat_data: type[CD], + ): ... @overload def __init__( self: "ContextTypes[CCT, UD, ADict, BD]", - context: Type[CCT], - user_data: Type[UD], - bot_data: Type[BD], - ): - ... + context: type[CCT], + user_data: type[UD], + bot_data: type[BD], + ): ... @overload def __init__( self: "ContextTypes[CCT, ADict, CD, BD]", - context: Type[CCT], - chat_data: Type[CD], - bot_data: Type[BD], - ): - ... + context: type[CCT], + chat_data: type[CD], + bot_data: type[BD], + ): ... @overload def __init__( self: "ContextTypes[CallbackContext[ExtBot[Any], UD, CD, BD], UD, CD, BD]", - user_data: Type[UD], - chat_data: Type[CD], - bot_data: Type[BD], - ): - ... + user_data: type[UD], + chat_data: type[CD], + bot_data: type[BD], + ): ... @overload def __init__( self: "ContextTypes[CCT, UD, CD, BD]", - context: Type[CCT], - user_data: Type[UD], - chat_data: Type[CD], - bot_data: Type[BD], - ): - ... + context: type[CCT], + user_data: type[UD], + chat_data: type[CD], + bot_data: type[BD], + ): ... def __init__( # type: ignore[misc] self, - context: "Type[CallbackContext[ExtBot[Any], ADict, ADict, ADict]]" = CallbackContext, - bot_data: Type[ADict] = dict, - chat_data: Type[ADict] = dict, - user_data: Type[ADict] = dict, + context: "type[CallbackContext[ExtBot[Any], ADict, ADict, ADict]]" = CallbackContext, + bot_data: type[ADict] = dict, + chat_data: type[ADict] = dict, + user_data: type[ADict] = dict, ): if not issubclass(context, CallbackContext): - raise ValueError("context must be a subclass of CallbackContext.") + raise TypeError("context must be a subclass of CallbackContext.") # We make all those only accessible via properties because we don't currently support # changing this at runtime, so overriding the attributes doesn't make sense @@ -214,28 +199,28 @@ def __init__( # type: ignore[misc] self._user_data = user_data @property - def context(self) -> Type[CCT]: + def context(self) -> type[CCT]: """The type of the ``context`` argument of all (error-)handler callbacks and job callbacks. """ return self._context # type: ignore[return-value] @property - def bot_data(self) -> Type[BD]: + def bot_data(self) -> type[BD]: """The type of :attr:`context.bot_data ` of all (error-)handler callbacks and job callbacks. """ return self._bot_data # type: ignore[return-value] @property - def chat_data(self) -> Type[CD]: + def chat_data(self) -> type[CD]: """The type of :attr:`context.chat_data ` of all (error-)handler callbacks and job callbacks. """ return self._chat_data # type: ignore[return-value] @property - def user_data(self) -> Type[UD]: + def user_data(self) -> type[UD]: """The type of :attr:`context.user_data ` of all (error-)handler callbacks and job callbacks. """ diff --git a/src/telegram/ext/_defaults.py b/src/telegram/ext/_defaults.py new file mode 100644 index 00000000000..2986aba0b64 --- /dev/null +++ b/src/telegram/ext/_defaults.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 the class Defaults, which allows passing default values to Application.""" + +import datetime as dtm +from typing import TYPE_CHECKING, Any, NoReturn, final + +from telegram._utils.datetime import UTC +from telegram._utils.warnings import warn +from telegram.warnings import PTBDeprecationWarning + +if TYPE_CHECKING: + from telegram import LinkPreviewOptions + + +@final +class Defaults: + """Convenience Class to gather all parameters with a (user defined) default value + + .. seealso:: :wiki:`Architecture Overview `, + :wiki:`Adding Defaults to Your Bot ` + + .. versionchanged:: 20.0 + Removed the argument and attribute ``timeout``. Specify default timeout behavior for the + networking backend directly via :class:`telegram.ext.ApplicationBuilder` instead. + + .. versionchanged:: 22.0 + Removed deprecated arguments and properties ``disable_web_page_preview`` and ``quote``. + Use :paramref:`link_preview_options` and :paramref:`do_quote` instead. + + Parameters: + parse_mode (:obj:`str`, optional): |parse_mode| + disable_notification (:obj:`bool`, optional): |disable_notification| + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply|. + Will be used for :attr:`telegram.ReplyParameters.allow_sending_without_reply`. + tzinfo (:class:`datetime.tzinfo`, optional): A timezone to be used for all date(time) + inputs appearing throughout PTB, i.e. if a timezone naive date(time) object is passed + somewhere, it will be assumed to be in :paramref:`tzinfo`. Defaults to + :attr:`datetime.timezone.utc` otherwise. + + .. deprecated:: 21.10 + Support for ``pytz`` timezones is deprecated and will be removed in future + versions. + + block (:obj:`bool`, optional): Default setting for the :paramref:`BaseHandler.block` + parameter + of handlers and error handlers registered through :meth:`Application.add_handler` and + :meth:`Application.add_error_handler`. Defaults to :obj:`True`. + protect_content (:obj:`bool`, optional): |protect_content| + + .. versionadded:: 20.0 + link_preview_options (:class:`telegram.LinkPreviewOptions`, optional): + Link preview generation options for all outgoing messages. Mutually exclusive with + :paramref:`disable_web_page_preview`. + This object is used for the corresponding parameter of + :meth:`telegram.Bot.send_message`, :meth:`telegram.Bot.edit_message_text`, + and :class:`telegram.InputTextMessageContent` if not specified. If a value is specified + for the corresponding parameter, only those parameters of + :class:`telegram.LinkPreviewOptions` will be overridden that are not + explicitly set. + + Example: + + .. code-block:: python + + from telegram import LinkPreviewOptions + from telegram.ext import Defaults, ExtBot + + defaults = Defaults( + link_preview_options=LinkPreviewOptions(show_above_text=True) + ) + chat_id = 123 + + async def main(): + async with ExtBot("Token", defaults=defaults) as bot: + # The link preview will be shown above the text. + await bot.send_message(chat_id, "https://python-telegram-bot.org") + + # The link preview will be shown below the text. + await bot.send_message( + chat_id, + "https://python-telegram-bot.org", + link_preview_options=LinkPreviewOptions(show_above_text=False) + ) + + # The link preview will be shown above the text, but the preview will + # show Telegram. + await bot.send_message( + chat_id, + "https://python-telegram-bot.org", + link_preview_options=LinkPreviewOptions(url="https://telegram.org") + ) + + .. versionadded:: 20.8 + do_quote(:obj:`bool`, optional): |reply_quote| + + .. versionadded:: 20.8 + """ + + __slots__ = ( + "_allow_sending_without_reply", + "_api_defaults", + "_block", + "_disable_notification", + "_do_quote", + "_link_preview_options", + "_parse_mode", + "_protect_content", + "_tzinfo", + ) + + def __init__( + self, + parse_mode: str | None = None, + disable_notification: bool | None = None, + tzinfo: dtm.tzinfo = UTC, + block: bool = True, + allow_sending_without_reply: bool | None = None, + protect_content: bool | None = None, + link_preview_options: "LinkPreviewOptions | None" = None, + do_quote: bool | None = None, + ): + self._parse_mode: str | None = parse_mode + self._disable_notification: bool | None = disable_notification + self._allow_sending_without_reply: bool | None = allow_sending_without_reply + self._tzinfo: dtm.tzinfo = tzinfo + self._block: bool = block + self._protect_content: bool | None = protect_content + + if "pytz" in str(self._tzinfo.__class__): + # TODO: When dropping support, make sure to update _utils.datetime accordingly + warn( + message=PTBDeprecationWarning( + version="21.10", + message=( + "Support for pytz timezones is deprecated and will be removed in " + "future versions." + ), + ), + stacklevel=2, + ) + + self._link_preview_options = link_preview_options + self._do_quote = do_quote + + # Gather all defaults that actually have a default value + self._api_defaults = {} + for kwarg in ( + "allow_sending_without_reply", + "disable_notification", + "do_quote", + "explanation_parse_mode", + "link_preview_options", + "parse_mode", + "text_parse_mode", + "protect_content", + "question_parse_mode", + ): + value = getattr(self, kwarg) + if value is not None: + self._api_defaults[kwarg] = value + + def __hash__(self) -> int: + """Builds a hash value for this object such that the hash of two objects is equal if and + only if the objects are equal in terms of :meth:`__eq__`. + + Returns: + :obj:`int` The hash value of the object. + """ + return hash( + ( + self._parse_mode, + self._disable_notification, + self._link_preview_options, + self._allow_sending_without_reply, + self._do_quote, + self._tzinfo, + self._block, + self._protect_content, + ) + ) + + def __eq__(self, other: object) -> bool: + """Defines equality condition for the :class:`Defaults` object. + Two objects of this class are considered to be equal if all their parameters + are identical. + + Returns: + :obj:`True` if both objects have all parameters identical. :obj:`False` otherwise. + """ + if isinstance(other, Defaults): + return all(getattr(self, attr) == getattr(other, attr) for attr in self.__slots__) + return False + + @property + def api_defaults(self) -> dict[str, Any]: # skip-cq: PY-D0003 + return self._api_defaults + + @property + def parse_mode(self) -> str | None: + """:obj:`str`: Optional. Send Markdown or HTML, if you want Telegram apps to show + bold, italic, fixed-width text or URLs in your bot's message. + """ + return self._parse_mode + + @parse_mode.setter + def parse_mode(self, _: object) -> NoReturn: + raise AttributeError("You can not assign a new value to parse_mode after initialization.") + + @property + def explanation_parse_mode(self) -> str | None: + """:obj:`str`: Optional. Alias for :attr:`parse_mode`, used for + the corresponding parameter of :meth:`telegram.Bot.send_poll`. + """ + return self._parse_mode + + @explanation_parse_mode.setter + def explanation_parse_mode(self, _: object) -> NoReturn: + raise AttributeError( + "You can not assign a new value to explanation_parse_mode after initialization." + ) + + @property + def quote_parse_mode(self) -> str | None: + """:obj:`str`: Optional. Alias for :attr:`parse_mode`, used for + the corresponding parameter of :meth:`telegram.ReplyParameters`. + """ + return self._parse_mode + + @quote_parse_mode.setter + def quote_parse_mode(self, _: object) -> NoReturn: + raise AttributeError( + "You can not assign a new value to quote_parse_mode after initialization." + ) + + @property + def text_parse_mode(self) -> str | None: + """:obj:`str`: Optional. Alias for :attr:`parse_mode`, used for + the corresponding parameter of :class:`telegram.InputPollOption` and + :meth:`telegram.Bot.send_gift`. + + .. versionadded:: 21.2 + """ + return self._parse_mode + + @text_parse_mode.setter + def text_parse_mode(self, _: object) -> NoReturn: + raise AttributeError( + "You can not assign a new value to text_parse_mode after initialization." + ) + + @property + def question_parse_mode(self) -> str | None: + """:obj:`str`: Optional. Alias for :attr:`parse_mode`, used for + the corresponding parameter of :meth:`telegram.Bot.send_poll`. + + .. versionadded:: 21.2 + """ + return self._parse_mode + + @question_parse_mode.setter + def question_parse_mode(self, _: object) -> NoReturn: + raise AttributeError( + "You can not assign a new value to question_parse_mode after initialization." + ) + + @property + def disable_notification(self) -> bool | None: + """:obj:`bool`: Optional. Sends the message silently. Users will + receive a notification with no sound. + """ + return self._disable_notification + + @disable_notification.setter + def disable_notification(self, _: object) -> NoReturn: + raise AttributeError( + "You can not assign a new value to disable_notification after initialization." + ) + + @property + def allow_sending_without_reply(self) -> bool | None: + """:obj:`bool`: Optional. Pass :obj:`True`, if the message + should be sent even if the specified replied-to message is not found. + """ + return self._allow_sending_without_reply + + @allow_sending_without_reply.setter + def allow_sending_without_reply(self, _: object) -> NoReturn: + raise AttributeError( + "You can not assign a new value to allow_sending_without_reply after initialization." + ) + + @property + def tzinfo(self) -> dtm.tzinfo: + """:obj:`tzinfo`: A timezone to be used for all date(time) objects appearing + throughout PTB. + """ + return self._tzinfo + + @tzinfo.setter + def tzinfo(self, _: object) -> NoReturn: + raise AttributeError("You can not assign a new value to tzinfo after initialization.") + + @property + def block(self) -> bool: + """:obj:`bool`: Optional. Default setting for the :paramref:`BaseHandler.block` parameter + of handlers and error handlers registered through :meth:`Application.add_handler` and + :meth:`Application.add_error_handler`. + """ + return self._block + + @block.setter + def block(self, _: object) -> NoReturn: + raise AttributeError("You can not assign a new value to block after initialization.") + + @property + def protect_content(self) -> bool | None: + """:obj:`bool`: Optional. Protects the contents of the sent message from forwarding and + saving. + + .. versionadded:: 20.0 + """ + return self._protect_content + + @protect_content.setter + def protect_content(self, _: object) -> NoReturn: + raise AttributeError( + "You can't assign a new value to protect_content after initialization." + ) + + @property + def link_preview_options(self) -> "LinkPreviewOptions | None": + """:class:`telegram.LinkPreviewOptions`: Optional. Link preview generation options for all + outgoing messages. + + .. versionadded:: 20.8 + """ + return self._link_preview_options + + @property + def do_quote(self) -> bool | None: + """:obj:`bool`: Optional. |reply_quote| + + .. versionadded:: 20.8 + """ + return self._do_quote diff --git a/telegram/ext/_dictpersistence.py b/src/telegram/ext/_dictpersistence.py similarity index 87% rename from telegram/ext/_dictpersistence.py rename to src/telegram/ext/_dictpersistence.py index d6f35f554a0..ad72e77096c 100644 --- a/telegram/ext/_dictpersistence.py +++ b/src/telegram/ext/_dictpersistence.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,16 +17,19 @@ # 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 the DictPersistence class.""" + import json from copy import deepcopy -from typing import Any, Dict, Optional, cast +from typing import TYPE_CHECKING, Any, cast -from telegram._utils.types import JSONDict from telegram.ext import BasePersistence, PersistenceInput from telegram.ext._utils.types import CDCData, ConversationDict, ConversationKey +if TYPE_CHECKING: + from telegram._utils.types import JSONDict + -class DictPersistence(BasePersistence[Dict[Any, Any], Dict[Any, Any], Dict[Any, Any]]): +class DictPersistence(BasePersistence[dict[Any, Any], dict[Any, Any], dict[Any, Any]]): """Using Python's :obj:`dict` and :mod:`json` for making your bot persistent. Attention: @@ -76,21 +79,21 @@ class DictPersistence(BasePersistence[Dict[Any, Any], Dict[Any, Any], Dict[Any, """ __slots__ = ( - "_user_data", - "_chat_data", "_bot_data", - "_callback_data", - "_conversations", - "_user_data_json", - "_chat_data_json", "_bot_data_json", + "_callback_data", "_callback_data_json", + "_chat_data", + "_chat_data_json", + "_conversations", "_conversations_json", + "_user_data", + "_user_data_json", ) def __init__( self, - store_data: Optional[PersistenceInput] = None, + store_data: PersistenceInput | None = None, user_data_json: str = "", chat_data_json: str = "", bot_data_json: str = "", @@ -104,11 +107,11 @@ def __init__( self._bot_data = None self._callback_data = None self._conversations = None - self._user_data_json: Optional[str] = None - self._chat_data_json: Optional[str] = None - self._bot_data_json: Optional[str] = None - self._callback_data_json: Optional[str] = None - self._conversations_json: Optional[str] = None + self._user_data_json: str | None = None + self._chat_data_json: str | None = None + self._bot_data_json: str | None = None + self._callback_data_json: str | None = None + self._conversations_json: str | None = None if user_data_json: try: self._user_data = self._decode_user_chat_data_from_json(user_data_json) @@ -143,7 +146,7 @@ def __init__( self._callback_data = None else: self._callback_data = cast( - CDCData, + "CDCData", ([(one, float(two), three) for one, two, three in data[0]], data[1]), ) self._callback_data_json = callback_data_json @@ -168,7 +171,7 @@ def __init__( ) from exc @property - def user_data(self) -> Optional[Dict[int, Dict[Any, Any]]]: + def user_data(self) -> dict[int, dict[Any, Any]] | None: """:obj:`dict`: The user_data as a dict.""" return self._user_data @@ -180,7 +183,7 @@ def user_data_json(self) -> str: return json.dumps(self.user_data) @property - def chat_data(self) -> Optional[Dict[int, Dict[Any, Any]]]: + def chat_data(self) -> dict[int, dict[Any, Any]] | None: """:obj:`dict`: The chat_data as a dict.""" return self._chat_data @@ -192,7 +195,7 @@ def chat_data_json(self) -> str: return json.dumps(self.chat_data) @property - def bot_data(self) -> Optional[Dict[Any, Any]]: + def bot_data(self) -> dict[Any, Any] | None: """:obj:`dict`: The bot_data as a dict.""" return self._bot_data @@ -204,9 +207,9 @@ def bot_data_json(self) -> str: return json.dumps(self.bot_data) @property - def callback_data(self) -> Optional[CDCData]: - """Tuple[List[Tuple[:obj:`str`, :obj:`float`, Dict[:obj:`str`, :class:`object`]]], \ - Dict[:obj:`str`, :obj:`str`]]: The metadata on the stored callback data. + def callback_data(self) -> CDCData | None: + """tuple[list[tuple[:obj:`str`, :obj:`float`, dict[:obj:`str`, :class:`object`]]], \ + dict[:obj:`str`, :obj:`str`]]: The metadata on the stored callback data. .. versionadded:: 13.6 """ @@ -223,7 +226,7 @@ def callback_data_json(self) -> str: return json.dumps(self.callback_data) @property - def conversations(self) -> Optional[Dict[str, ConversationDict]]: + def conversations(self) -> dict[str, ConversationDict] | None: """:obj:`dict`: The conversations as a dict.""" return self._conversations @@ -236,7 +239,7 @@ def conversations_json(self) -> str: return self._encode_conversations_to_json(self.conversations) return json.dumps(self.conversations) - async def get_user_data(self) -> Dict[int, Dict[object, object]]: + async def get_user_data(self) -> dict[int, dict[object, object]]: """Returns the user_data created from the ``user_data_json`` or an empty :obj:`dict`. Returns: @@ -246,7 +249,7 @@ async def get_user_data(self) -> Dict[int, Dict[object, object]]: self._user_data = {} return deepcopy(self.user_data) # type: ignore[arg-type] - async def get_chat_data(self) -> Dict[int, Dict[object, object]]: + async def get_chat_data(self) -> dict[int, dict[object, object]]: """Returns the chat_data created from the ``chat_data_json`` or an empty :obj:`dict`. Returns: @@ -256,7 +259,7 @@ async def get_chat_data(self) -> Dict[int, Dict[object, object]]: self._chat_data = {} return deepcopy(self.chat_data) # type: ignore[arg-type] - async def get_bot_data(self) -> Dict[object, object]: + async def get_bot_data(self) -> dict[object, object]: """Returns the bot_data created from the ``bot_data_json`` or an empty :obj:`dict`. Returns: @@ -266,14 +269,14 @@ async def get_bot_data(self) -> Dict[object, object]: self._bot_data = {} return deepcopy(self.bot_data) # type: ignore[arg-type] - async def get_callback_data(self) -> Optional[CDCData]: + async def get_callback_data(self) -> CDCData | None: """Returns the callback_data created from the ``callback_data_json`` or :obj:`None`. .. versionadded:: 13.6 Returns: - Tuple[List[Tuple[:obj:`str`, :obj:`float`, Dict[:obj:`str`, :class:`object`]]], \ - Dict[:obj:`str`, :obj:`str`]]: The restored metadata or :obj:`None`, \ + tuple[list[tuple[:obj:`str`, :obj:`float`, dict[:obj:`str`, :class:`object`]]], \ + dict[:obj:`str`, :obj:`str`]]: The restored metadata or :obj:`None`, \ if no data was stored. """ if self.callback_data is None: @@ -293,7 +296,7 @@ async def get_conversations(self, name: str) -> ConversationDict: return self.conversations.get(name, {}).copy() # type: ignore[union-attr] async def update_conversation( - self, name: str, key: ConversationKey, new_state: Optional[object] + self, name: str, key: ConversationKey, new_state: object | None ) -> None: """Will update the conversations for the given handler. @@ -309,7 +312,7 @@ async def update_conversation( self._conversations[name][key] = new_state self._conversations_json = None - async def update_user_data(self, user_id: int, data: Dict[Any, Any]) -> None: + async def update_user_data(self, user_id: int, data: dict[Any, Any]) -> None: """Will update the user_data (if changed). Args: @@ -323,7 +326,7 @@ async def update_user_data(self, user_id: int, data: Dict[Any, Any]) -> None: self._user_data[user_id] = data self._user_data_json = None - async def update_chat_data(self, chat_id: int, data: Dict[Any, Any]) -> None: + async def update_chat_data(self, chat_id: int, data: dict[Any, Any]) -> None: """Will update the chat_data (if changed). Args: @@ -337,7 +340,7 @@ async def update_chat_data(self, chat_id: int, data: Dict[Any, Any]) -> None: self._chat_data[chat_id] = data self._chat_data_json = None - async def update_bot_data(self, data: Dict[Any, Any]) -> None: + async def update_bot_data(self, data: dict[Any, Any]) -> None: """Will update the bot_data (if changed). Args: @@ -354,8 +357,8 @@ async def update_callback_data(self, data: CDCData) -> None: .. versionadded:: 13.6 Args: - data (Tuple[List[Tuple[:obj:`str`, :obj:`float`, Dict[:obj:`str`, :class:`object`]]], \ - Dict[:obj:`str`, :obj:`str`]]): The relevant data to restore + data (tuple[list[tuple[:obj:`str`, :obj:`float`, dict[:obj:`str`, :class:`object`]]], \ + dict[:obj:`str`, :obj:`str`]]): The relevant data to restore :class:`telegram.ext.CallbackDataCache`. """ if self._callback_data == data: @@ -389,21 +392,21 @@ async def drop_user_data(self, user_id: int) -> None: self._user_data.pop(user_id, None) self._user_data_json = None - async def refresh_user_data(self, user_id: int, user_data: Dict[Any, Any]) -> None: + async def refresh_user_data(self, user_id: int, user_data: dict[Any, Any]) -> None: """Does nothing. .. versionadded:: 13.6 .. seealso:: :meth:`telegram.ext.BasePersistence.refresh_user_data` """ - async def refresh_chat_data(self, chat_id: int, chat_data: Dict[Any, Any]) -> None: + async def refresh_chat_data(self, chat_id: int, chat_data: dict[Any, Any]) -> None: """Does nothing. .. versionadded:: 13.6 .. seealso:: :meth:`telegram.ext.BasePersistence.refresh_chat_data` """ - async def refresh_bot_data(self, bot_data: Dict[Any, Any]) -> None: + async def refresh_bot_data(self, bot_data: dict[Any, Any]) -> None: """Does nothing. .. versionadded:: 13.6 @@ -418,7 +421,7 @@ async def flush(self) -> None: """ @staticmethod - def _encode_conversations_to_json(conversations: Dict[str, ConversationDict]) -> str: + def _encode_conversations_to_json(conversations: dict[str, ConversationDict]) -> str: """Helper method to encode a conversations dict (that uses tuples as keys) to a JSON-serializable way. Use :meth:`self._decode_conversations_from_json` to decode. @@ -428,7 +431,7 @@ def _encode_conversations_to_json(conversations: Dict[str, ConversationDict]) -> Returns: :obj:`str`: The JSON-serialized conversations dict """ - tmp: Dict[str, JSONDict] = {} + tmp: dict[str, JSONDict] = {} for handler, states in conversations.items(): tmp[handler] = {} for key, state in states.items(): @@ -436,7 +439,7 @@ def _encode_conversations_to_json(conversations: Dict[str, ConversationDict]) -> return json.dumps(tmp) @staticmethod - def _decode_conversations_from_json(json_string: str) -> Dict[str, ConversationDict]: + def _decode_conversations_from_json(json_string: str) -> dict[str, ConversationDict]: """Helper method to decode a conversations dict (that uses tuples as keys) from a JSON-string created with :meth:`self._encode_conversations_to_json`. @@ -447,7 +450,7 @@ def _decode_conversations_from_json(json_string: str) -> Dict[str, ConversationD :obj:`dict`: The conversations dict after decoding """ tmp = json.loads(json_string) - conversations: Dict[str, ConversationDict] = {} + conversations: dict[str, ConversationDict] = {} for handler, states in tmp.items(): conversations[handler] = {} for key, state in states.items(): @@ -455,7 +458,7 @@ def _decode_conversations_from_json(json_string: str) -> Dict[str, ConversationD return conversations @staticmethod - def _decode_user_chat_data_from_json(data: str) -> Dict[int, Dict[object, object]]: + def _decode_user_chat_data_from_json(data: str) -> dict[int, dict[object, object]]: """Helper method to decode chat or user data (that uses ints as keys) from a JSON-string. @@ -465,7 +468,7 @@ def _decode_user_chat_data_from_json(data: str) -> Dict[int, Dict[object, object Returns: :obj:`dict`: The user/chat_data defaultdict after decoding """ - tmp: Dict[int, Dict[object, object]] = {} + tmp: dict[int, dict[object, object]] = {} decoded_data = json.loads(data) for user, user_data in decoded_data.items(): int_user_id = int(user) diff --git a/telegram/ext/_extbot.py b/src/telegram/ext/_extbot.py similarity index 51% rename from telegram/ext/_extbot.py rename to src/telegram/ext/_extbot.py index c0f37efd9a5..15c84b4ce03 100644 --- a/telegram/ext/_extbot.py +++ b/src/telegram/ext/_extbot.py @@ -2,7 +2,7 @@ # pylint: disable=too-many-arguments # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,21 +18,15 @@ # 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 Bot with convenience extensions.""" + +import datetime as dtm +from collections.abc import Callable, Sequence from copy import copy -from datetime import datetime from typing import ( TYPE_CHECKING, Any, - Callable, - Dict, Generic, - List, - Optional, - Sequence, - Tuple, - Type, TypeVar, - Union, cast, no_type_check, overload, @@ -40,6 +34,7 @@ from uuid import uuid4 from telegram import ( + AcceptedGiftTypes, Animation, Audio, Bot, @@ -48,38 +43,48 @@ BotDescription, BotName, BotShortDescription, + BusinessConnection, CallbackQuery, - Chat, ChatAdministratorRights, + ChatFullInfo, ChatInviteLink, ChatMember, ChatPermissions, ChatPhoto, - Contact, Document, File, ForumTopic, GameHighScore, + Gifts, InlineKeyboardMarkup, InlineQueryResultsButton, + InputChecklist, InputMedia, - InputSticker, - Location, + InputPaidMedia, + InputPollOption, + InputProfilePhoto, + LinkPreviewOptions, MaskPosition, MenuButton, Message, MessageId, - PassportElementError, + OwnedGifts, PhotoSize, Poll, + PreparedInlineMessage, + ReactionType, + ReplyParameters, SentWebAppMessage, - ShippingOption, + StarAmount, + StarTransactions, Sticker, StickerSet, + Story, + TelegramObject, Update, User, + UserChatBoosts, UserProfilePhotos, - Venue, Video, VideoNote, Voice, @@ -88,7 +93,16 @@ from telegram._utils.datetime import to_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue from telegram._utils.logging import get_logger -from telegram._utils.types import DVInput, FileInput, JSONDict, ODVInput, ReplyMarkup +from telegram._utils.repr import build_repr_with_selected_attrs +from telegram._utils.types import ( + BaseUrl, + CorrectOptionID, + FileInput, + JSONDict, + ODVInput, + ReplyMarkup, + TimePeriod, +) from telegram.ext._callbackdatacache import CallbackDataCache from telegram.ext._utils.types import RLARGS from telegram.request import BaseRequest @@ -96,17 +110,28 @@ if TYPE_CHECKING: from telegram import ( + Contact, + Gift, InlineQueryResult, InputMediaAudio, InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputSticker, + InputStoryContent, LabeledPrice, + Location, MessageEntity, + PassportElementError, + ShippingOption, + StoryArea, + SuggestedPostParameters, + Venue, ) from telegram.ext import BaseRateLimiter, Defaults -HandledTypes = TypeVar("HandledTypes", bound=Union[Message, CallbackQuery, Chat]) +HandledTypes = TypeVar("HandledTypes", bound=Message | CallbackQuery | ChatFullInfo) +KT = TypeVar("KT", bound=ReplyMarkup) class ExtBot(Bot, Generic[RLARGS]): @@ -122,6 +147,10 @@ class ExtBot(Bot, Generic[RLARGS]): This can be used to pass additional information to the rate limiter, specifically to :paramref:`telegram.ext.BaseRateLimiter.process_request.rate_limit_args`. + This class is a :class:`~typing.Generic` class and accepts one type variable that specifies + the generic type of the :attr:`rate_limiter` used by the bot. Use :obj:`None` if no rate + limiter is used. + Warning: * The keyword argument ``rate_limit_args`` can `not` be used, if :attr:`rate_limiter` is :obj:`None`. @@ -140,6 +169,9 @@ class ExtBot(Bot, Generic[RLARGS]): :attr:`bot.callback_data_cache.maxsize ` to access the size of the cache. + .. versionchanged:: 20.5 + Removed deprecated methods ``set_sticker_set_thumb`` and ``setStickerSetThumb``. + Args: defaults (:class:`telegram.ext.Defaults`, optional): An object containing default values to be used if not set explicitly in the bot methods. @@ -167,48 +199,46 @@ class ExtBot(Bot, Generic[RLARGS]): def __init__( self: "ExtBot[None]", token: str, - base_url: str = "https://api.telegram.org/bot", - base_file_url: str = "https://api.telegram.org/file/bot", - request: Optional[BaseRequest] = None, - get_updates_request: Optional[BaseRequest] = None, - private_key: Optional[bytes] = None, - private_key_password: Optional[bytes] = None, - defaults: Optional["Defaults"] = None, - arbitrary_callback_data: Union[bool, int] = False, + base_url: BaseUrl = "https://api.telegram.org/bot", + base_file_url: BaseUrl = "https://api.telegram.org/file/bot", + request: BaseRequest | None = None, + get_updates_request: BaseRequest | None = None, + private_key: bytes | None = None, + private_key_password: bytes | None = None, + defaults: "Defaults | None" = None, + arbitrary_callback_data: bool | int = False, local_mode: bool = False, - ): - ... + ): ... @overload def __init__( self: "ExtBot[RLARGS]", token: str, - base_url: str = "https://api.telegram.org/bot", - base_file_url: str = "https://api.telegram.org/file/bot", - request: Optional[BaseRequest] = None, - get_updates_request: Optional[BaseRequest] = None, - private_key: Optional[bytes] = None, - private_key_password: Optional[bytes] = None, - defaults: Optional["Defaults"] = None, - arbitrary_callback_data: Union[bool, int] = False, + base_url: BaseUrl = "https://api.telegram.org/bot", + base_file_url: BaseUrl = "https://api.telegram.org/file/bot", + request: BaseRequest | None = None, + get_updates_request: BaseRequest | None = None, + private_key: bytes | None = None, + private_key_password: bytes | None = None, + defaults: "Defaults | None" = None, + arbitrary_callback_data: bool | int = False, local_mode: bool = False, - rate_limiter: Optional["BaseRateLimiter[RLARGS]"] = None, - ): - ... + rate_limiter: "BaseRateLimiter[RLARGS] | None" = None, + ): ... def __init__( self, token: str, - base_url: str = "https://api.telegram.org/bot", - base_file_url: str = "https://api.telegram.org/file/bot", - request: Optional[BaseRequest] = None, - get_updates_request: Optional[BaseRequest] = None, - private_key: Optional[bytes] = None, - private_key_password: Optional[bytes] = None, - defaults: Optional["Defaults"] = None, - arbitrary_callback_data: Union[bool, int] = False, + base_url: BaseUrl = "https://api.telegram.org/bot", + base_file_url: BaseUrl = "https://api.telegram.org/file/bot", + request: BaseRequest | None = None, + get_updates_request: BaseRequest | None = None, + private_key: bytes | None = None, + private_key_password: bytes | None = None, + defaults: "Defaults | None" = None, + arbitrary_callback_data: bool | int = False, local_mode: bool = False, - rate_limiter: Optional["BaseRateLimiter[RLARGS]"] = None, + rate_limiter: "BaseRateLimiter[RLARGS] | None" = None, ): super().__init__( token=token, @@ -221,24 +251,38 @@ def __init__( local_mode=local_mode, ) with self._unfrozen(): - self._defaults: Optional[Defaults] = defaults - self._rate_limiter: Optional[BaseRateLimiter] = rate_limiter - self._callback_data_cache: Optional[CallbackDataCache] = None + self._defaults: Defaults | None = defaults + self._rate_limiter: BaseRateLimiter | None = rate_limiter + self._callback_data_cache: CallbackDataCache | None = None # set up callback_data if arbitrary_callback_data is False: return if not isinstance(arbitrary_callback_data, bool): - maxsize = cast(int, arbitrary_callback_data) + maxsize = cast("int", arbitrary_callback_data) else: maxsize = 1024 self._callback_data_cache = CallbackDataCache(bot=self, maxsize=maxsize) + def __repr__(self) -> str: + """Give a string representation of the bot in the form ``ExtBot[token=...]``. + + As this class doesn't implement :meth:`object.__str__`, the default implementation + will be used, which is equivalent to :meth:`__repr__`. + + Returns: + :obj:`str` + """ + return build_repr_with_selected_attrs(self, token=self.token) + @classmethod def _warn( - cls, message: str, category: Type[Warning] = PTBUserWarning, stacklevel: int = 0 + cls, + message: str | PTBUserWarning, + category: type[Warning] = PTBUserWarning, + stacklevel: int = 0, ) -> None: """We override this method to add one more level to the stacklevel, so that the warning points to the user's code, not to the PTB code. @@ -246,7 +290,7 @@ def _warn( super()._warn(message=message, category=category, stacklevel=stacklevel + 2) @property - def callback_data_cache(self) -> Optional[CallbackDataCache]: + def callback_data_cache(self) -> CallbackDataCache | None: """:class:`telegram.ext.CallbackDataCache`: Optional. The cache for objects passed as callback data for :class:`telegram.InlineKeyboardButton`. @@ -282,8 +326,8 @@ async def shutdown(self) -> None: @classmethod def _merge_api_rl_kwargs( - cls, api_kwargs: Optional[JSONDict], rate_limit_args: Optional[RLARGS] - ) -> Optional[JSONDict]: + cls, api_kwargs: JSONDict | None, rate_limit_args: RLARGS | None + ) -> JSONDict | None: """Inserts the `rate_limit_args` into `api_kwargs` with the special key `__RL_KEY` so that we can extract them later without having to modify the `telegram.Bot` class. """ @@ -295,7 +339,7 @@ def _merge_api_rl_kwargs( return api_kwargs @classmethod - def _extract_rl_kwargs(cls, data: Optional[JSONDict]) -> Optional[RLARGS]: + def _extract_rl_kwargs(cls, data: JSONDict | None) -> RLARGS | None: """Extracts the `rate_limit_args` from `data` if it exists.""" if not data: return None @@ -310,7 +354,7 @@ async def _do_post( write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - ) -> Union[bool, JSONDict, List[JSONDict]]: + ) -> bool | JSONDict | list[JSONDict]: """Order of method calls is: Bot.some_method -> Bot._post -> Bot._do_post. So we can override Bot._do_post to add rate limiting. """ @@ -352,13 +396,13 @@ async def _do_post( ) @property - def defaults(self) -> Optional["Defaults"]: + def defaults(self) -> "Defaults | None": """The :class:`telegram.ext.Defaults` used by this bot, if any.""" # This is a property because defaults shouldn't be changed at runtime return self._defaults @property - def rate_limiter(self) -> Optional["BaseRateLimiter[RLARGS]"]: + def rate_limiter(self) -> "BaseRateLimiter[RLARGS] | None": """The :class:`telegram.ext.BaseRateLimiter` used by this bot, if any. .. versionadded:: 20.0 @@ -366,7 +410,30 @@ def rate_limiter(self) -> Optional["BaseRateLimiter[RLARGS]"]: # This is a property because the rate limiter shouldn't be changed at runtime return self._rate_limiter - def _insert_defaults(self, data: Dict[str, object]) -> None: + def _merge_lpo_defaults(self, lpo: ODVInput[LinkPreviewOptions]) -> LinkPreviewOptions | None: + # This is a standalone method because both _insert_defaults and + # _insert_defaults_for_ilq_results need this logic + # + # If Defaults.LPO is set, and LPO is passed in the bot method we should fuse + # them, giving precedence to passed values. + # Defaults.LPO(True, "google.com", True) & LPO=LPO(True, ..., False) -> + # LPO(True, "google.com", False) + if self.defaults is None or (defaults_lpo := self.defaults.link_preview_options) is None: + return DefaultValue.get_value(lpo) + return LinkPreviewOptions( + **{ + attr: ( + getattr(defaults_lpo, attr) + # only use the default value + # if the value was explicitly passed to the LPO object + if isinstance(orig_attr := getattr(lpo, attr), DefaultValue) + else orig_attr + ) + for attr in defaults_lpo.__slots__ + } + ) + + def _insert_defaults(self, data: dict[str, object]) -> None: """Inserts the defaults values for optional kwargs for which tg.ext.Defaults provides convenience functionality, i.e. the kwargs with a tg.utils.helpers.DefaultValue default @@ -375,48 +442,97 @@ def _insert_defaults(self, data: Dict[str, object]) -> None: This can only work, if all kwargs that may have defaults are passed in data! """ + if self.defaults is None: + # If we have no defaults to insert, the behavior is the same as in `tg.Bot` + super()._insert_defaults(data) + return + # if we have Defaults, we # 1) replace all DefaultValue instances with the relevant Defaults value. If there is none, # we fall back to the default value of the bot method # 2) convert all datetime.datetime objects to timestamps wrt the correct default timezone # 3) set the correct parse_mode for all InputMedia objects + # 4) handle the LinkPreviewOptions case (see below) + # 5) handle the ReplyParameters case (see below) + # 6) handle text_parse_mode in InputPollOption for key, val in data.items(): # 1) if isinstance(val, DefaultValue): - data[key] = ( - self.defaults.api_defaults.get(key, val.value) - if self.defaults - else DefaultValue.get_value(val) - ) + data[key] = self.defaults.api_defaults.get(key, val.value) # 2) - elif isinstance(val, datetime): - data[key] = to_timestamp( - val, tzinfo=self.defaults.tzinfo if self.defaults else None - ) + elif isinstance(val, dtm.datetime): + data[key] = to_timestamp(val, tzinfo=self.defaults.tzinfo) # 3) elif isinstance(val, InputMedia) and val.parse_mode is DEFAULT_NONE: # Copy object as not to edit it in-place copied_val = copy(val) with copied_val._unfrozen(): - copied_val.parse_mode = self.defaults.parse_mode if self.defaults else None + copied_val.parse_mode = self.defaults.parse_mode data[key] = copied_val - 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(media) for media in val] for media in copy_list: if media.parse_mode is DEFAULT_NONE: with media._unfrozen(): - media.parse_mode = self.defaults.parse_mode if self.defaults else None + media.parse_mode = self.defaults.parse_mode data[key] = copy_list - def _replace_keyboard(self, reply_markup: Optional[ReplyMarkup]) -> Optional[ReplyMarkup]: + # 4) LinkPreviewOptions: + elif isinstance(val, LinkPreviewOptions): + data[key] = self._merge_lpo_defaults(val) + + # 5) + # Similar to LinkPreviewOptions, but only two of the arguments of RPs have a default + elif isinstance(val, ReplyParameters) and ( + (defaults_aswr := self.defaults.allow_sending_without_reply) is not None + or self.defaults.quote_parse_mode is not None + ): + new_value = copy(val) + with new_value._unfrozen(): + new_value.allow_sending_without_reply = ( + defaults_aswr + if isinstance(val.allow_sending_without_reply, DefaultValue) + else val.allow_sending_without_reply + ) + new_value.quote_parse_mode = ( + self.defaults.quote_parse_mode + if isinstance(val.quote_parse_mode, DefaultValue) + else val.quote_parse_mode + ) + + data[key] = new_value + + # 6) + elif isinstance(val, Sequence) and all( + isinstance(obj, InputPollOption) for obj in val + ): + new_val = [] + for option in val: + if not isinstance(option.text_parse_mode, DefaultValue): + new_val.append(option) + else: + new_option = copy(option) + with new_option._unfrozen(): + new_option.text_parse_mode = self.defaults.text_parse_mode + new_val.append(new_option) + data[key] = new_val + + def _replace_keyboard(self, reply_markup: KT | None) -> KT | None: # If the reply_markup is an inline keyboard and we allow arbitrary callback data, let the # CallbackDataCache build a new keyboard with the data replaced. Otherwise return the input if isinstance(reply_markup, InlineKeyboardMarkup) and self.callback_data_cache is not None: - return self.callback_data_cache.process_keyboard(reply_markup) + # for some reason mypy doesn't understand that IKB is a subtype of KT | None + return self.callback_data_cache.process_keyboard( # type: ignore[return-value] + reply_markup + ) return reply_markup @@ -459,24 +575,24 @@ def _insert_callback_data(self, obj: HandledTypes) -> HandledTypes: if isinstance(obj, CallbackQuery): self.callback_data_cache.process_callback_query(obj) - return obj # type: ignore[return-value] + return obj if isinstance(obj, Message): if obj.reply_to_message: # reply_to_message can't contain further reply_to_messages, so no need to check self.callback_data_cache.process_message(obj.reply_to_message) - if obj.reply_to_message.pinned_message: + if isinstance(obj.reply_to_message.pinned_message, Message): # pinned messages can't contain reply_to_message, no need to check self.callback_data_cache.process_message(obj.reply_to_message.pinned_message) - if obj.pinned_message: + if isinstance(obj.pinned_message, Message): # pinned messages can't contain reply_to_message, no need to check self.callback_data_cache.process_message(obj.pinned_message) # Finally, handle the message itself self.callback_data_cache.process_message(message=obj) - return obj # type: ignore[return-value] + return obj - if isinstance(obj, Chat) and obj.pinned_message: + if isinstance(obj, ChatFullInfo) and obj.pinned_message: self.callback_data_cache.process_message(obj.pinned_message) return obj @@ -485,22 +601,28 @@ async def _send_message( self, endpoint: str, data: JSONDict, - reply_to_message_id: Optional[int] = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - caption: Optional[str] = None, + message_thread_id: int | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, + api_kwargs: JSONDict | None = None, ) -> Any: # We override this method to call self._replace_keyboard and self._insert_callback_data. # This covers most methods that have a reply_markup @@ -516,12 +638,18 @@ async def _send_message( caption=caption, parse_mode=parse_mode, caption_entities=caption_entities, - disable_web_page_preview=disable_web_page_preview, + link_preview_options=link_preview_options, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) if isinstance(result, Message): self._insert_callback_data(result) @@ -529,17 +657,17 @@ async def _send_message( async def get_updates( self, - offset: Optional[int] = None, - limit: Optional[int] = None, - timeout: Optional[int] = None, - allowed_updates: Optional[Sequence[str]] = None, + offset: int | None = None, + limit: int | None = None, + timeout: TimePeriod | None = None, + allowed_updates: Sequence[str] | None = None, *, - read_timeout: float = 2, + 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, - ) -> Tuple[Update, ...]: + api_kwargs: JSONDict | None = None, + ) -> tuple[Update, ...]: updates = await super().get_updates( offset=offset, limit=limit, @@ -559,12 +687,12 @@ async def get_updates( def _effective_inline_results( self, - results: Union[ - Sequence["InlineQueryResult"], Callable[[int], Optional[Sequence["InlineQueryResult"]]] - ], - next_offset: Optional[str] = None, - current_offset: Optional[str] = None, - ) -> Tuple[Sequence["InlineQueryResult"], Optional[str]]: + results: ( + Sequence["InlineQueryResult"] | Callable[[int], Sequence["InlineQueryResult"] | None] + ), + next_offset: str | None = None, + current_offset: str | None = None, + ) -> tuple[Sequence["InlineQueryResult"], str | None]: """This method is called by Bot.answer_inline_query to build the actual results list. Overriding this to call self._replace_keyboard suffices """ @@ -599,13 +727,17 @@ def _insert_defaults_for_ilq_results(self, res: "InlineQueryResult") -> "InlineQ `obj`. Overriding this to call insert the actual desired default values. """ + if self.defaults is None: + # If we have no defaults to insert, the behavior is the same as in `tg.Bot` + return super()._insert_defaults_for_ilq_results(res) + # Copy the objects that need modification to avoid modifying the original object copied = False if hasattr(res, "parse_mode") and res.parse_mode is DEFAULT_NONE: res = copy(res) with res._unfrozen(): copied = True - res.parse_mode = self.defaults.parse_mode if self.defaults else None + res.parse_mode = self.defaults.parse_mode if hasattr(res, "input_message_content") and res.input_message_content: if ( hasattr(res.input_message_content, "parse_mode") @@ -615,40 +747,65 @@ def _insert_defaults_for_ilq_results(self, res: "InlineQueryResult") -> "InlineQ res = copy(res) copied = True with res.input_message_content._unfrozen(): - res.input_message_content.parse_mode = ( - self.defaults.parse_mode if self.defaults else None - ) - if ( - hasattr(res.input_message_content, "disable_web_page_preview") - and res.input_message_content.disable_web_page_preview is DEFAULT_NONE - ): + res.input_message_content.parse_mode = self.defaults.parse_mode + if hasattr(res.input_message_content, "link_preview_options"): if not copied: res = copy(res) with res.input_message_content._unfrozen(): - res.input_message_content.disable_web_page_preview = ( - self.defaults.disable_web_page_preview if self.defaults else None - ) + if res.input_message_content.link_preview_options is DEFAULT_NONE: + res.input_message_content.link_preview_options = ( + self.defaults.link_preview_options + ) + else: + # merge the existing options with the defaults + res.input_message_content.link_preview_options = self._merge_lpo_defaults( + res.input_message_content.link_preview_options + ) return res + async def do_api_request( + self, + endpoint: str, + api_kwargs: JSONDict | None = None, + return_type: type[TelegramObject] | None = 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, + rate_limit_args: RLARGS | None = None, + ) -> Any: + return await super().do_api_request( + endpoint=endpoint, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + return_type=return_type, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + ) + async def stop_poll( self, - chat_id: Union[int, str], + chat_id: int | str, message_id: int, - reply_markup: Optional[InlineKeyboardMarkup] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + business_connection_id: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Poll: # We override this method to call self._replace_keyboard return await super().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, @@ -658,25 +815,32 @@ async def stop_poll( async def copy_message( self, - chat_id: Union[int, str], - from_chat_id: Union[str, int], + chat_id: int | str, + from_chat_id: str | int, message_id: int, - caption: Optional[str] = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - allow_sending_without_reply: DVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, + caption_entities: Sequence["MessageEntity"] | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + show_caption_above_media: bool | None = None, + allow_paid_broadcast: bool | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> MessageId: # We override this method to call self._replace_keyboard return await super().copy_message( @@ -684,6 +848,7 @@ async def copy_message( from_chat_id=from_chat_id, message_id=message_id, caption=caption, + video_start_timestamp=video_start_timestamp, parse_mode=parse_mode, caption_entities=caption_entities, disable_notification=disable_notification, @@ -692,24 +857,65 @@ async def copy_message( reply_markup=self._replace_keyboard(reply_markup), protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, + 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), + show_caption_above_media=show_caption_above_media, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_effect_id=message_effect_id, + ) + + async def copy_messages( + self, + chat_id: int | str, + from_chat_id: str | int, + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + remove_caption: bool | None = None, + direct_messages_topic_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> tuple["MessageId", ...]: + # We override this method to call self._replace_keyboard + return await super().copy_messages( + chat_id=chat_id, + from_chat_id=from_chat_id, + message_ids=message_ids, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + remove_caption=remove_caption, 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), + direct_messages_topic_id=direct_messages_topic_id, ) async def get_chat( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> Chat: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> ChatFullInfo: # We override this method to call self._insert_callback_data result = await super().get_chat( chat_id=chat_id, @@ -723,33 +929,21 @@ async def get_chat( async def add_sticker_to_set( self, - user_id: Union[str, int], + user_id: int, name: str, - emojis: Optional[str] = None, # Was made optional for compatibility reasons - png_sticker: Optional[FileInput] = None, - mask_position: Optional[MaskPosition] = None, - tgs_sticker: Optional[FileInput] = None, - webm_sticker: Optional[FileInput] = None, - sticker: Optional[ - InputSticker - ] = None, # Actually a required param, but is optional for compat. + sticker: "InputSticker", *, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().add_sticker_to_set( user_id=user_id, name=name, sticker=sticker, - emojis=emojis, - png_sticker=png_sticker, - mask_position=mask_position, - tgs_sticker=tgs_sticker, - webm_sticker=webm_sticker, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -760,17 +954,17 @@ async def add_sticker_to_set( async def answer_callback_query( self, callback_query_id: str, - text: Optional[str] = None, - show_alert: Optional[bool] = None, - url: Optional[str] = None, - cache_time: Optional[int] = None, + text: str | None = None, + show_alert: bool | None = None, + url: str | None = None, + cache_time: TimePeriod | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().answer_callback_query( callback_query_id=callback_query_id, @@ -788,23 +982,21 @@ async def answer_callback_query( async def answer_inline_query( self, inline_query_id: str, - results: Union[ - Sequence["InlineQueryResult"], Callable[[int], Optional[Sequence["InlineQueryResult"]]] - ], - cache_time: Optional[int] = None, - is_personal: Optional[bool] = None, - next_offset: Optional[str] = None, - switch_pm_text: Optional[str] = None, - switch_pm_parameter: Optional[str] = None, - button: Optional[InlineQueryResultsButton] = None, + results: ( + Sequence["InlineQueryResult"] | Callable[[int], Sequence["InlineQueryResult"] | None] + ), + cache_time: TimePeriod | None = None, + is_personal: bool | None = None, + next_offset: str | None = None, + button: InlineQueryResultsButton | None = None, *, - current_offset: Optional[str] = None, + current_offset: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().answer_inline_query( inline_query_id=inline_query_id, @@ -812,8 +1004,6 @@ async def answer_inline_query( cache_time=cache_time, is_personal=is_personal, next_offset=next_offset, - switch_pm_text=switch_pm_text, - switch_pm_parameter=switch_pm_parameter, current_offset=current_offset, read_timeout=read_timeout, write_timeout=write_timeout, @@ -823,18 +1013,48 @@ async def answer_inline_query( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def save_prepared_inline_message( + self, + user_id: int, + result: "InlineQueryResult", + allow_user_chats: bool | None = None, + allow_bot_chats: bool | None = None, + allow_group_chats: bool | None = None, + allow_channel_chats: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> PreparedInlineMessage: + return await super().save_prepared_inline_message( + user_id=user_id, + result=result, + allow_user_chats=allow_user_chats, + allow_bot_chats=allow_bot_chats, + allow_group_chats=allow_group_chats, + allow_channel_chats=allow_channel_chats, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + async def answer_pre_checkout_query( self, pre_checkout_query_id: str, ok: bool, - error_message: Optional[str] = None, + error_message: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().answer_pre_checkout_query( pre_checkout_query_id=pre_checkout_query_id, @@ -851,15 +1071,15 @@ async def answer_shipping_query( self, shipping_query_id: str, ok: bool, - shipping_options: Optional[Sequence[ShippingOption]] = None, - error_message: Optional[str] = None, + shipping_options: Sequence["ShippingOption"] | None = None, + error_message: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().answer_shipping_query( shipping_query_id=shipping_query_id, @@ -882,8 +1102,8 @@ async def answer_web_app_query( 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> SentWebAppMessage: return await super().answer_web_app_query( web_app_query_id=web_app_query_id, @@ -897,15 +1117,15 @@ async def answer_web_app_query( async def approve_chat_join_request( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().approve_chat_join_request( chat_id=chat_id, @@ -919,17 +1139,17 @@ async def approve_chat_join_request( async def ban_chat_member( self, - chat_id: Union[str, int], - user_id: Union[str, int], - until_date: Optional[Union[int, datetime]] = None, - revoke_messages: Optional[bool] = None, + chat_id: str | int, + user_id: int, + until_date: int | dtm.datetime | None = None, + revoke_messages: bool | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().ban_chat_member( chat_id=chat_id, @@ -945,15 +1165,15 @@ async def ban_chat_member( async def ban_chat_sender_chat( self, - chat_id: Union[str, int], + chat_id: str | int, sender_chat_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().ban_chat_sender_chat( chat_id=chat_id, @@ -967,18 +1187,18 @@ async def ban_chat_sender_chat( async def create_chat_invite_link( self, - chat_id: Union[str, int], - expire_date: Optional[Union[int, datetime]] = None, - member_limit: Optional[int] = None, - name: Optional[str] = None, - creates_join_request: Optional[bool] = None, + chat_id: str | int, + expire_date: int | dtm.datetime | None = None, + member_limit: int | None = None, + name: str | None = None, + creates_join_request: bool | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> ChatInviteLink: return await super().create_chat_invite_link( chat_id=chat_id, @@ -998,30 +1218,32 @@ async def create_invoice_link( title: str, description: str, payload: str, - provider_token: str, currency: str, prices: Sequence["LabeledPrice"], - max_tip_amount: Optional[int] = None, - suggested_tip_amounts: Optional[Sequence[int]] = None, - provider_data: Optional[Union[str, object]] = None, - photo_url: Optional[str] = None, - photo_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - need_name: Optional[bool] = None, - need_phone_number: Optional[bool] = None, - need_email: Optional[bool] = None, - need_shipping_address: Optional[bool] = None, - send_phone_number_to_provider: Optional[bool] = None, - send_email_to_provider: Optional[bool] = None, - is_flexible: Optional[bool] = None, + provider_token: str | None = None, + max_tip_amount: int | None = None, + suggested_tip_amounts: Sequence[int] | None = None, + provider_data: str | object | None = None, + photo_url: str | None = None, + photo_size: int | None = None, + photo_width: int | None = None, + photo_height: int | None = None, + need_name: bool | None = None, + need_phone_number: bool | None = None, + need_email: bool | None = None, + need_shipping_address: bool | None = None, + send_phone_number_to_provider: bool | None = None, + send_email_to_provider: bool | None = None, + is_flexible: bool | None = None, + subscription_period: TimePeriod | None = None, + business_connection_id: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> str: return await super().create_invoice_link( title=title, @@ -1048,46 +1270,34 @@ async def create_invoice_link( write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, + subscription_period=subscription_period, + business_connection_id=business_connection_id, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) async def create_new_sticker_set( self, - user_id: Union[str, int], + user_id: int, name: str, title: str, - emojis: Optional[str] = None, # Was made optional for compatibility purposes - png_sticker: Optional[FileInput] = None, - mask_position: Optional[MaskPosition] = None, - tgs_sticker: Optional[FileInput] = None, - webm_sticker: Optional[FileInput] = None, - sticker_type: Optional[str] = None, - stickers: Optional[ - Sequence[InputSticker] - ] = None, # Actually a required param. Optional for compat. - sticker_format: Optional[str] = None, # Actually a required param. Optional for compat. - needs_repainting: Optional[bool] = None, + stickers: Sequence["InputSticker"], + sticker_type: str | None = None, + needs_repainting: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().create_new_sticker_set( user_id=user_id, name=name, title=title, stickers=stickers, - sticker_format=sticker_format, - needs_repainting=needs_repainting, - emojis=emojis, - png_sticker=png_sticker, - mask_position=mask_position, - tgs_sticker=tgs_sticker, - webm_sticker=webm_sticker, sticker_type=sticker_type, + needs_repainting=needs_repainting, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1097,15 +1307,15 @@ async def create_new_sticker_set( async def decline_chat_join_request( self, - chat_id: Union[str, int], + chat_id: str | int, user_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().decline_chat_join_request( chat_id=chat_id, @@ -1119,14 +1329,14 @@ async def decline_chat_join_request( async def delete_chat_photo( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_chat_photo( chat_id=chat_id, @@ -1139,14 +1349,14 @@ async def delete_chat_photo( async def delete_chat_sticker_set( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_chat_sticker_set( chat_id=chat_id, @@ -1159,15 +1369,15 @@ async def delete_chat_sticker_set( async def delete_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_forum_topic( chat_id=chat_id, @@ -1181,15 +1391,15 @@ async def delete_forum_topic( async def delete_message( self, - chat_id: Union[str, int], + chat_id: str | int, message_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_message( chat_id=chat_id, @@ -1201,17 +1411,39 @@ async def delete_message( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def delete_messages( + self, + chat_id: str | int, + message_ids: Sequence[int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().delete_messages( + chat_id=chat_id, + message_ids=message_ids, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + async def delete_my_commands( self, - scope: Optional[BotCommandScope] = None, - language_code: Optional[str] = None, + scope: BotCommandScope | None = None, + language_code: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_my_commands( scope=scope, @@ -1225,14 +1457,14 @@ async def delete_my_commands( async def delete_sticker_from_set( self, - sticker: str, + sticker: "str | Sticker", *, 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_sticker_from_set( sticker=sticker, @@ -1245,14 +1477,14 @@ async def delete_sticker_from_set( async def delete_webhook( self, - drop_pending_updates: Optional[bool] = None, + drop_pending_updates: bool | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_webhook( drop_pending_updates=drop_pending_updates, @@ -1265,19 +1497,19 @@ async def delete_webhook( async def edit_chat_invite_link( self, - chat_id: Union[str, int], - invite_link: Union[str, "ChatInviteLink"], - expire_date: Optional[Union[int, datetime]] = None, - member_limit: Optional[int] = None, - name: Optional[str] = None, - creates_join_request: Optional[bool] = None, + chat_id: str | int, + invite_link: "str | ChatInviteLink", + expire_date: int | dtm.datetime | None = None, + member_limit: int | None = None, + name: str | None = None, + creates_join_request: bool | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> ChatInviteLink: return await super().edit_chat_invite_link( chat_id=chat_id, @@ -1295,17 +1527,17 @@ async def edit_chat_invite_link( async def edit_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, - name: Optional[str] = None, - icon_custom_emoji_id: Optional[str] = None, + name: str | None = None, + icon_custom_emoji_id: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().edit_forum_topic( chat_id=chat_id, @@ -1321,15 +1553,15 @@ async def edit_forum_topic( async def edit_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, name: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().edit_general_forum_topic( chat_id=chat_id, @@ -1343,21 +1575,23 @@ async def edit_general_forum_topic( async def edit_message_caption( self, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + caption: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + business_connection_id: str | None = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> "Message | bool": return await super().edit_message_caption( chat_id=chat_id, message_id=message_id, @@ -1366,33 +1600,37 @@ 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, pool_timeout=pool_timeout, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + show_caption_above_media=show_caption_above_media, ) async def edit_message_live_location( self, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + latitude: float | None = None, + longitude: float | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, + live_period: TimePeriod | None = None, + business_connection_id: str | None = None, *, - location: Optional[Location] = None, + location: "Location | None" = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> "Message | bool": return await super().edit_message_live_location( chat_id=chat_id, message_id=message_id, @@ -1403,7 +1641,9 @@ async def edit_message_live_location( horizontal_accuracy=horizontal_accuracy, heading=heading, 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, @@ -1414,24 +1654,26 @@ async def edit_message_live_location( async def edit_message_media( self, media: "InputMedia", - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + business_connection_id: str | None = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> "Message | bool": return await super().edit_message_media( media=media, chat_id=chat_id, message_id=message_id, inline_message_id=inline_message_id, reply_markup=reply_markup, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1441,23 +1683,25 @@ async def edit_message_media( async def edit_message_reply_markup( self, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + business_connection_id: str | None = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> "Message | bool": return await super().edit_message_reply_markup( chat_id=chat_id, message_id=message_id, inline_message_id=inline_message_id, reply_markup=reply_markup, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1468,21 +1712,23 @@ async def edit_message_reply_markup( async def edit_message_text( self, text: str, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[InlineKeyboardMarkup] = None, - entities: Optional[Sequence["MessageEntity"]] = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + entities: Sequence["MessageEntity"] | None = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + business_connection_id: str | None = None, *, + disable_web_page_preview: bool | None = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> "Message | bool": return await super().edit_message_text( text=text, chat_id=chat_id, @@ -1492,23 +1738,25 @@ 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, pool_timeout=pool_timeout, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + link_preview_options=link_preview_options, ) async def export_chat_invite_link( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> str: return await super().export_chat_invite_link( chat_id=chat_id, @@ -1521,27 +1769,67 @@ async def export_chat_invite_link( async def forward_message( self, - chat_id: Union[int, str], - from_chat_id: Union[str, int], + chat_id: int | str, + from_chat_id: str | int, message_id: int, - disable_notification: DVInput[bool] = DEFAULT_NONE, + disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + video_start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_effect_id: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().forward_message( chat_id=chat_id, from_chat_id=from_chat_id, message_id=message_id, + video_start_timestamp=video_start_timestamp, + disable_notification=disable_notification, + protect_content=protect_content, + message_thread_id=message_thread_id, + suggested_post_parameters=suggested_post_parameters, + 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), + direct_messages_topic_id=direct_messages_topic_id, + message_effect_id=message_effect_id, + ) + + async def forward_messages( + self, + chat_id: int | str, + from_chat_id: str | int, + message_ids: Sequence[int], + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> tuple[MessageId, ...]: + return await super().forward_messages( + chat_id=chat_id, + from_chat_id=from_chat_id, + message_ids=message_ids, disable_notification=disable_notification, protect_content=protect_content, message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1551,15 +1839,15 @@ async def forward_message( async def get_chat_administrators( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> Tuple[ChatMember, ...]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> tuple[ChatMember, ...]: return await super().get_chat_administrators( chat_id=chat_id, read_timeout=read_timeout, @@ -1571,15 +1859,15 @@ async def get_chat_administrators( async def get_chat_member( self, - chat_id: Union[str, int], - user_id: Union[str, int], + chat_id: str | int, + user_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> ChatMember: return await super().get_chat_member( chat_id=chat_id, @@ -1593,14 +1881,14 @@ async def get_chat_member( async def get_chat_member_count( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> int: return await super().get_chat_member_count( chat_id=chat_id, @@ -1613,14 +1901,14 @@ async def get_chat_member_count( async def get_chat_menu_button( self, - chat_id: Optional[int] = None, + chat_id: int | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> MenuButton: return await super().get_chat_menu_button( chat_id=chat_id, @@ -1633,16 +1921,25 @@ async def get_chat_menu_button( async def get_file( self, - file_id: Union[ - str, Animation, Audio, ChatPhoto, Document, PhotoSize, Sticker, Video, VideoNote, Voice - ], + file_id: ( + str + | Animation + | Audio + | ChatPhoto + | Document + | PhotoSize + | Sticker + | Video + | VideoNote + | Voice + ), *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> File: return await super().get_file( file_id=file_id, @@ -1660,9 +1957,9 @@ async def get_forum_topic_icon_stickers( 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, - ) -> Tuple[Sticker, ...]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> tuple[Sticker, ...]: return await super().get_forum_topic_icon_stickers( read_timeout=read_timeout, write_timeout=write_timeout, @@ -1673,18 +1970,18 @@ async def get_forum_topic_icon_stickers( async def get_game_high_scores( self, - user_id: Union[int, str], - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, + user_id: int, + chat_id: int | None = None, + message_id: int | None = None, + inline_message_id: str | None = 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, - ) -> Tuple[GameHighScore, ...]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> tuple[GameHighScore, ...]: return await super().get_game_high_scores( user_id=user_id, chat_id=chat_id, @@ -1704,8 +2001,8 @@ async def get_me( 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> User: return await super().get_me( read_timeout=read_timeout, @@ -1717,16 +2014,16 @@ async def get_me( async def get_my_commands( self, - scope: Optional[BotCommandScope] = None, - language_code: Optional[str] = None, + scope: BotCommandScope | None = None, + language_code: str | None = 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, - ) -> Tuple[BotCommand, ...]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> tuple[BotCommand, ...]: return await super().get_my_commands( scope=scope, language_code=language_code, @@ -1739,14 +2036,14 @@ async def get_my_commands( async def get_my_default_administrator_rights( self, - for_channels: Optional[bool] = None, + for_channels: bool | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> ChatAdministratorRights: return await super().get_my_default_administrator_rights( for_channels=for_channels, @@ -1765,8 +2062,8 @@ async def get_sticker_set( 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> StickerSet: return await super().get_sticker_set( name=name, @@ -1785,9 +2082,9 @@ async def get_custom_emoji_stickers( 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, - ) -> Tuple[Sticker, ...]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> tuple[Sticker, ...]: return await super().get_custom_emoji_stickers( custom_emoji_ids=custom_emoji_ids, read_timeout=read_timeout, @@ -1799,17 +2096,17 @@ async def get_custom_emoji_stickers( async def get_user_profile_photos( self, - user_id: Union[str, int], - offset: Optional[int] = None, - limit: Optional[int] = None, + user_id: int, + offset: int | None = None, + limit: int | None = 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, - ) -> UserProfilePhotos: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> "UserProfilePhotos": return await super().get_user_profile_photos( user_id=user_id, offset=offset, @@ -1828,8 +2125,8 @@ async def get_webhook_info( 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> WebhookInfo: return await super().get_webhook_info( read_timeout=read_timeout, @@ -1841,14 +2138,14 @@ async def get_webhook_info( async def leave_chat( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().leave_chat( chat_id=chat_id, @@ -1866,8 +2163,8 @@ async def log_out( 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().log_out( read_timeout=read_timeout, @@ -1884,8 +2181,8 @@ async def close( 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().close( read_timeout=read_timeout, @@ -1897,15 +2194,15 @@ async def close( async def close_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().close_forum_topic( chat_id=chat_id, @@ -1919,14 +2216,14 @@ async def close_forum_topic( async def close_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().close_general_forum_topic( chat_id=chat_id, @@ -1939,17 +2236,17 @@ async def close_general_forum_topic( async def create_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, name: str, - icon_color: Optional[int] = None, - icon_custom_emoji_id: Optional[str] = None, + icon_color: int | None = None, + icon_custom_emoji_id: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> ForumTopic: return await super().create_forum_topic( chat_id=chat_id, @@ -1965,14 +2262,14 @@ async def create_forum_topic( async def reopen_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().reopen_general_forum_topic( chat_id=chat_id, @@ -1985,14 +2282,14 @@ async def reopen_general_forum_topic( async def hide_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().hide_general_forum_topic( chat_id=chat_id, @@ -2005,14 +2302,14 @@ async def hide_general_forum_topic( async def unhide_general_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().unhide_general_forum_topic( chat_id=chat_id, @@ -2025,16 +2322,17 @@ async def unhide_general_forum_topic( async def pin_chat_message( self, - chat_id: Union[str, int], + chat_id: str | int, message_id: int, disable_notification: ODVInput[bool] = DEFAULT_NONE, + business_connection_id: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().pin_chat_message( chat_id=chat_id, @@ -2044,32 +2342,37 @@ async def pin_chat_message( write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, + business_connection_id=business_connection_id, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) async def promote_chat_member( self, - chat_id: Union[str, int], - user_id: Union[str, int], - can_change_info: Optional[bool] = None, - can_post_messages: Optional[bool] = None, - can_edit_messages: Optional[bool] = None, - can_delete_messages: Optional[bool] = None, - can_invite_users: Optional[bool] = None, - can_restrict_members: Optional[bool] = None, - can_pin_messages: Optional[bool] = None, - can_promote_members: Optional[bool] = None, - is_anonymous: Optional[bool] = None, - can_manage_chat: Optional[bool] = None, - can_manage_video_chats: Optional[bool] = None, - can_manage_topics: Optional[bool] = None, + chat_id: str | int, + user_id: int, + can_change_info: bool | None = None, + can_post_messages: bool | None = None, + can_edit_messages: bool | None = None, + can_delete_messages: bool | None = None, + can_invite_users: bool | None = None, + can_restrict_members: bool | None = None, + can_pin_messages: bool | None = None, + can_promote_members: bool | None = None, + is_anonymous: bool | None = None, + can_manage_chat: bool | None = None, + can_manage_video_chats: bool | None = None, + can_manage_topics: bool | None = None, + can_post_stories: bool | None = None, + can_edit_stories: bool | None = None, + can_delete_stories: bool | None = None, + can_manage_direct_messages: bool | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().promote_chat_member( chat_id=chat_id, @@ -2086,6 +2389,10 @@ async def promote_chat_member( can_manage_chat=can_manage_chat, can_manage_video_chats=can_manage_video_chats, can_manage_topics=can_manage_topics, + can_post_stories=can_post_stories, + can_edit_stories=can_edit_stories, + can_delete_stories=can_delete_stories, + can_manage_direct_messages=can_manage_direct_messages, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2095,15 +2402,15 @@ async def promote_chat_member( async def reopen_forum_topic( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().reopen_forum_topic( chat_id=chat_id, @@ -2117,18 +2424,18 @@ async def reopen_forum_topic( async def restrict_chat_member( self, - chat_id: Union[str, int], - user_id: Union[str, int], + chat_id: str | int, + user_id: int, permissions: ChatPermissions, - until_date: Optional[Union[int, datetime]] = None, - use_independent_chat_permissions: Optional[bool] = None, + until_date: int | dtm.datetime | None = None, + use_independent_chat_permissions: bool | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().restrict_chat_member( chat_id=chat_id, @@ -2145,15 +2452,15 @@ async def restrict_chat_member( async def revoke_chat_invite_link( self, - chat_id: Union[str, int], - invite_link: Union[str, "ChatInviteLink"], + chat_id: str | int, + invite_link: "str | ChatInviteLink", *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> ChatInviteLink: return await super().revoke_chat_invite_link( chat_id=chat_id, @@ -2167,31 +2474,37 @@ async def revoke_chat_invite_link( async def send_animation( self, - chat_id: Union[int, str], - animation: Union[FileInput, "Animation"], - duration: Optional[int] = None, - width: Optional[int] = None, - height: Optional[int] = None, - thumb: Optional[FileInput] = None, - caption: Optional[str] = None, + chat_id: int | str, + animation: "FileInput | Animation", + duration: TimePeriod | None = None, + width: int | None = None, + height: int | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_animation( chat_id=chat_id, @@ -2199,7 +2512,6 @@ async def send_animation( duration=duration, width=width, height=height, - thumb=thumb, caption=caption, parse_mode=parse_mode, disable_notification=disable_notification, @@ -2211,81 +2523,100 @@ async def send_animation( message_thread_id=message_thread_id, has_spoiler=has_spoiler, thumbnail=thumbnail, + reply_parameters=reply_parameters, filename=filename, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, + business_connection_id=business_connection_id, pool_timeout=pool_timeout, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_audio( self, - chat_id: Union[int, str], - audio: Union[FileInput, "Audio"], - duration: Optional[int] = None, - performer: Optional[str] = None, - title: Optional[str] = None, - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, + chat_id: int | str, + audio: "FileInput | Audio", + duration: TimePeriod | None = None, + performer: str | None = None, + title: str | None = None, + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - thumb: Optional[FileInput] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_audio( chat_id=chat_id, audio=audio, duration=duration, performer=performer, + business_connection_id=business_connection_id, title=title, caption=caption, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, reply_markup=reply_markup, parse_mode=parse_mode, - thumb=thumb, allow_sending_without_reply=allow_sending_without_reply, caption_entities=caption_entities, protect_content=protect_content, message_thread_id=message_thread_id, thumbnail=thumbnail, + reply_parameters=reply_parameters, filename=filename, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_chat_action( self, - chat_id: Union[str, int], + chat_id: str | int, action: str, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + business_connection_id: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().send_chat_action( chat_id=chat_id, + business_connection_id=business_connection_id, action=action, message_thread_id=message_thread_id, read_timeout=read_timeout, @@ -2297,25 +2628,31 @@ async def send_chat_action( async def send_contact( self, - chat_id: Union[int, str], - phone_number: Optional[str] = None, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - vcard: Optional[str] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + chat_id: int | str, + phone_number: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + vcard: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - contact: Optional[Contact] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + contact: "Contact | None" = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_contact( chat_id=chat_id, @@ -2329,72 +2666,161 @@ async def send_contact( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, contact=contact, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, + business_connection_id=business_connection_id, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + message_effect_id=message_effect_id, + direct_messages_topic_id=direct_messages_topic_id, + allow_paid_broadcast=allow_paid_broadcast, + suggested_post_parameters=suggested_post_parameters, ) - async def send_dice( + async def send_checklist( self, - chat_id: Union[int, str], + business_connection_id: str, + chat_id: int, + checklist: InputChecklist, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - emoji: Optional[str] = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_effect_id: str | None = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + *, + reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> Message: + return await super().send_checklist( + business_connection_id=business_connection_id, + chat_id=chat_id, + checklist=checklist, + disable_notification=disable_notification, + protect_content=protect_content, + message_effect_id=message_effect_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + reply_to_message_id=reply_to_message_id, + allow_sending_without_reply=allow_sending_without_reply, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def edit_message_checklist( + self, + business_connection_id: str, + chat_id: int, + message_id: int, + checklist: InputChecklist, + reply_markup: "InlineKeyboardMarkup | None" = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> Message: + return await super().edit_message_checklist( + business_connection_id=business_connection_id, + chat_id=chat_id, + message_id=message_id, + checklist=checklist, + reply_markup=reply_markup, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_dice( + self, + chat_id: int | str, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + emoji: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_dice( chat_id=chat_id, disable_notification=disable_notification, + business_connection_id=business_connection_id, reply_to_message_id=reply_to_message_id, reply_markup=reply_markup, emoji=emoji, allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, 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), + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_document( self, - chat_id: Union[int, str], - document: Union[FileInput, "Document"], - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, + chat_id: int | str, + document: "FileInput | Document", + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - thumb: Optional[FileInput] = None, - disable_content_type_detection: Optional[bool] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + disable_content_type_detection: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_document( chat_id=chat_id, @@ -2404,38 +2830,47 @@ async def send_document( reply_to_message_id=reply_to_message_id, reply_markup=reply_markup, parse_mode=parse_mode, - thumb=thumb, disable_content_type_detection=disable_content_type_detection, allow_sending_without_reply=allow_sending_without_reply, caption_entities=caption_entities, protect_content=protect_content, + business_connection_id=business_connection_id, message_thread_id=message_thread_id, thumbnail=thumbnail, + reply_parameters=reply_parameters, filename=filename, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_game( self, - chat_id: Union[int, str], + chat_id: int, game_short_name: str, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "InlineKeyboardMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_game( chat_id=chat_id, @@ -2443,53 +2878,62 @@ async def send_game( disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, reply_markup=reply_markup, + business_connection_id=business_connection_id, allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, 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), + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, ) async def send_invoice( self, - chat_id: Union[int, str], + chat_id: int | str, title: str, description: str, payload: str, - provider_token: str, currency: str, prices: Sequence["LabeledPrice"], - start_parameter: Optional[str] = None, - photo_url: Optional[str] = None, - photo_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - need_name: Optional[bool] = None, - need_phone_number: Optional[bool] = None, - need_email: Optional[bool] = None, - need_shipping_address: Optional[bool] = None, - is_flexible: Optional[bool] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - provider_data: Optional[Union[str, object]] = None, - send_phone_number_to_provider: Optional[bool] = None, - send_email_to_provider: Optional[bool] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - max_tip_amount: Optional[int] = None, - suggested_tip_amounts: Optional[Sequence[int]] = None, + provider_token: str | None = None, + start_parameter: str | None = None, + photo_url: str | None = None, + photo_size: int | None = None, + photo_width: int | None = None, + photo_height: int | None = None, + need_name: bool | None = None, + need_phone_number: bool | None = None, + need_email: bool | None = None, + need_shipping_address: bool | None = None, + is_flexible: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "InlineKeyboardMarkup | None" = None, + provider_data: str | object | None = None, + send_phone_number_to_provider: bool | None = None, + send_email_to_provider: bool | None = None, + max_tip_amount: int | None = None, + suggested_tip_amounts: Sequence[int] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_invoice( chat_id=chat_id, @@ -2520,36 +2964,47 @@ async def send_invoice( suggested_tip_amounts=suggested_tip_amounts, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, 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), + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_location( self, - chat_id: Union[int, str], - latitude: Optional[float] = None, - longitude: Optional[float] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - live_period: Optional[int] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + chat_id: int | str, + latitude: float | None = None, + longitude: float | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + live_period: TimePeriod | None = None, + horizontal_accuracy: float | None = None, + heading: int | None = None, + proximity_alert_radius: int | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - location: Optional[Location] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + location: "Location | None" = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_location( chat_id=chat_id, @@ -2565,36 +3020,47 @@ async def send_location( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, location=location, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, + business_connection_id=business_connection_id, pool_timeout=pool_timeout, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_media_group( self, - chat_id: Union[int, str], + chat_id: int | str, media: Sequence[ - Union["InputMediaAudio", "InputMediaDocument", "InputMediaPhoto", "InputMediaVideo"] + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" ], disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + 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, - caption: Optional[str] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - ) -> Tuple[Message, ...]: + caption_entities: Sequence["MessageEntity"] | None = None, + ) -> tuple[Message, ...]: return await super().send_media_group( chat_id=chat_id, media=media, @@ -2603,36 +3069,48 @@ async def send_media_group( allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, 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), caption=caption, + business_connection_id=business_connection_id, parse_mode=parse_mode, caption_entities=caption_entities, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, ) async def send_message( self, - chat_id: Union[int, str], + chat_id: int | str, text: str, parse_mode: ODVInput[str] = DEFAULT_NONE, - entities: Optional[Sequence["MessageEntity"]] = None, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, - disable_notification: DVInput[bool] = DEFAULT_NONE, + entities: Sequence["MessageEntity"] | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - message_thread_id: Optional[int] = None, + reply_markup: "ReplyMarkup | None" = None, + message_thread_id: int | None = None, + link_preview_options: ODVInput["LinkPreviewOptions"] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, + disable_web_page_preview: bool | None = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_message( chat_id=chat_id, @@ -2641,11 +3119,48 @@ async def send_message( entities=entities, disable_web_page_preview=disable_web_page_preview, disable_notification=disable_notification, + business_connection_id=business_connection_id, protect_content=protect_content, message_thread_id=message_thread_id, reply_to_message_id=reply_to_message_id, allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, + reply_parameters=reply_parameters, + 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), + link_preview_options=link_preview_options, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + ) + + async def send_message_draft( + self, + chat_id: int, + draft_id: int, + text: str, + message_thread_id: int | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + entities: Sequence["MessageEntity"] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().send_message_draft( + chat_id=chat_id, + draft_id=draft_id, + text=text, + message_thread_id=message_thread_id, + parse_mode=parse_mode, + entities=entities, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2655,26 +3170,33 @@ async def send_message( async def send_photo( self, - chat_id: Union[int, str], - photo: Union[FileInput, "PhotoSize"], - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, + chat_id: int | str, + photo: "FileInput | PhotoSize", + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_photo( chat_id=chat_id, @@ -2689,42 +3211,55 @@ async def send_photo( protect_content=protect_content, message_thread_id=message_thread_id, has_spoiler=has_spoiler, + reply_parameters=reply_parameters, filename=filename, + business_connection_id=business_connection_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), + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_poll( self, - chat_id: Union[int, str], + chat_id: int | str, question: str, - options: Sequence[str], - is_anonymous: Optional[bool] = None, - type: Optional[str] = None, # pylint: disable=redefined-builtin - allows_multiple_answers: Optional[bool] = None, - correct_option_id: Optional[int] = None, - is_closed: Optional[bool] = None, + options: Sequence["str | InputPollOption"], + is_anonymous: bool | None = None, + type: str | None = None, # pylint: disable=redefined-builtin + allows_multiple_answers: bool | None = None, + correct_option_id: CorrectOptionID | None = None, + is_closed: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - explanation: Optional[str] = None, + reply_markup: "ReplyMarkup | None" = None, + explanation: str | None = None, explanation_parse_mode: ODVInput[str] = DEFAULT_NONE, - open_period: Optional[int] = None, - close_date: Optional[Union[int, datetime]] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - explanation_entities: Optional[Sequence["MessageEntity"]] = None, + open_period: TimePeriod | None = None, + close_date: int | dtm.datetime | None = None, + explanation_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + question_parse_mode: ODVInput[str] = DEFAULT_NONE, + question_entities: Sequence["MessageEntity"] | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_poll( chat_id=chat_id, @@ -2744,33 +3279,45 @@ async def send_poll( close_date=close_date, allow_sending_without_reply=allow_sending_without_reply, explanation_entities=explanation_entities, + business_connection_id=business_connection_id, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, 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), + question_parse_mode=question_parse_mode, + question_entities=question_entities, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, ) async def send_sticker( self, - chat_id: Union[int, str], - sticker: Union[FileInput, "Sticker"], - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + chat_id: int | str, + sticker: "FileInput | Sticker", + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - emoji: Optional[str] = None, + message_thread_id: int | None = None, + emoji: str | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_sticker( chat_id=chat_id, @@ -2778,42 +3325,54 @@ async def send_sticker( disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, reply_markup=reply_markup, + business_connection_id=business_connection_id, allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, emoji=emoji, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_venue( self, - chat_id: Union[int, str], - latitude: Optional[float] = None, - longitude: Optional[float] = None, - title: Optional[str] = None, - address: Optional[str] = None, - foursquare_id: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - foursquare_type: Optional[str] = None, - google_place_id: Optional[str] = None, - google_place_type: Optional[str] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + chat_id: int | str, + latitude: float | None = None, + longitude: float | None = None, + title: str | None = None, + address: str | None = None, + foursquare_id: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - venue: Optional[Venue] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + venue: "Venue | None" = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_venue( chat_id=chat_id, @@ -2830,43 +3389,57 @@ async def send_venue( google_place_type=google_place_type, allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, + business_connection_id=business_connection_id, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, venue=venue, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_video( self, - chat_id: Union[int, str], - video: Union[FileInput, "Video"], - duration: Optional[int] = None, - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - width: Optional[int] = None, - height: Optional[int] = None, + chat_id: int | str, + video: "FileInput | Video", + duration: TimePeriod | None = None, + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + width: int | None = None, + height: int | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - supports_streaming: Optional[bool] = None, - thumb: Optional[FileInput] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + supports_streaming: bool | None = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, + message_thread_id: int | None = None, + has_spoiler: bool | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + show_caption_above_media: bool | None = None, + cover: "FileInput | None" = None, + start_timestamp: int | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_video( chat_id=chat_id, @@ -2880,43 +3453,56 @@ async def send_video( height=height, parse_mode=parse_mode, supports_streaming=supports_streaming, - thumb=thumb, allow_sending_without_reply=allow_sending_without_reply, caption_entities=caption_entities, protect_content=protect_content, message_thread_id=message_thread_id, + business_connection_id=business_connection_id, has_spoiler=has_spoiler, thumbnail=thumbnail, + cover=cover, + start_timestamp=start_timestamp, filename=filename, + reply_parameters=reply_parameters, 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), + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + show_caption_above_media=show_caption_above_media, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_video_note( self, - chat_id: Union[int, str], - video_note: Union[FileInput, "VideoNote"], - duration: Optional[int] = None, - length: Optional[int] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - thumb: Optional[FileInput] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + chat_id: int | str, + video_note: "FileInput | VideoNote", + duration: TimePeriod | None = None, + length: int | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, + message_thread_id: int | None = None, + thumbnail: "FileInput | None" = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_video_note( chat_id=chat_id, @@ -2926,41 +3512,52 @@ async def send_video_note( disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, reply_markup=reply_markup, - thumb=thumb, allow_sending_without_reply=allow_sending_without_reply, protect_content=protect_content, message_thread_id=message_thread_id, thumbnail=thumbnail, + reply_parameters=reply_parameters, filename=filename, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, ) async def send_voice( self, - chat_id: Union[int, str], - voice: Union[FileInput, "Voice"], - duration: Optional[int] = None, - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, + chat_id: int | str, + voice: "FileInput | Voice", + duration: TimePeriod | None = None, + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, parse_mode: ODVInput[str] = DEFAULT_NONE, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, + caption_entities: Sequence["MessageEntity"] | None = None, protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, + message_thread_id: int | None = None, + reply_parameters: "ReplyParameters | None" = None, + business_connection_id: str | None = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, *, - filename: Optional[str] = None, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> Message: return await super().send_voice( chat_id=chat_id, @@ -2975,26 +3572,32 @@ async def send_voice( caption_entities=caption_entities, protect_content=protect_content, message_thread_id=message_thread_id, + reply_parameters=reply_parameters, filename=filename, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + direct_messages_topic_id=direct_messages_topic_id, + allow_paid_broadcast=allow_paid_broadcast, + suggested_post_parameters=suggested_post_parameters, ) async def set_chat_administrator_custom_title( self, - chat_id: Union[int, str], - user_id: Union[int, str], + chat_id: int | str, + user_id: int, custom_title: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_chat_administrator_custom_title( chat_id=chat_id, @@ -3009,15 +3612,15 @@ async def set_chat_administrator_custom_title( async def set_chat_description( self, - chat_id: Union[str, int], - description: Optional[str] = None, + chat_id: str | int, + description: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_chat_description( chat_id=chat_id, @@ -3029,17 +3632,41 @@ async def set_chat_description( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def set_user_emoji_status( + self, + user_id: int, + emoji_status_custom_emoji_id: str | None = None, + emoji_status_expiration_date: int | dtm.datetime | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().set_user_emoji_status( + user_id=user_id, + emoji_status_custom_emoji_id=emoji_status_custom_emoji_id, + emoji_status_expiration_date=emoji_status_expiration_date, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + async def set_chat_menu_button( self, - chat_id: Optional[int] = None, - menu_button: Optional[MenuButton] = None, + chat_id: int | None = None, + menu_button: MenuButton | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_chat_menu_button( chat_id=chat_id, @@ -3053,16 +3680,16 @@ async def set_chat_menu_button( async def set_chat_permissions( self, - chat_id: Union[str, int], + chat_id: str | int, permissions: ChatPermissions, - use_independent_chat_permissions: Optional[bool] = None, + use_independent_chat_permissions: bool | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_chat_permissions( chat_id=chat_id, @@ -3077,15 +3704,15 @@ async def set_chat_permissions( async def set_chat_photo( self, - chat_id: Union[str, int], + chat_id: str | int, photo: FileInput, *, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_chat_photo( chat_id=chat_id, @@ -3099,15 +3726,15 @@ async def set_chat_photo( async def set_chat_sticker_set( self, - chat_id: Union[str, int], + chat_id: str | int, sticker_set_name: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_chat_sticker_set( chat_id=chat_id, @@ -3121,15 +3748,15 @@ async def set_chat_sticker_set( async def set_chat_title( self, - chat_id: Union[str, int], + chat_id: str | int, title: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_chat_title( chat_id=chat_id, @@ -3143,21 +3770,21 @@ async def set_chat_title( async def set_game_score( self, - user_id: Union[int, str], + user_id: int, score: int, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - force: Optional[bool] = None, - disable_edit_message: Optional[bool] = None, + chat_id: int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + force: bool | None = None, + disable_edit_message: bool | None = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> "Message | bool": return await super().set_game_score( user_id=user_id, score=score, @@ -3175,16 +3802,16 @@ async def set_game_score( async def set_my_commands( self, - commands: Sequence[Union[BotCommand, Tuple[str, str]]], - scope: Optional[BotCommandScope] = None, - language_code: Optional[str] = None, + commands: Sequence[BotCommand | tuple[str, str]], + scope: BotCommandScope | None = None, + language_code: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_my_commands( commands=commands, @@ -3199,15 +3826,15 @@ async def set_my_commands( async def set_my_default_administrator_rights( self, - rights: Optional[ChatAdministratorRights] = None, - for_channels: Optional[bool] = None, + rights: ChatAdministratorRights | None = None, + for_channels: bool | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_my_default_administrator_rights( rights=rights, @@ -3221,15 +3848,15 @@ async def set_my_default_administrator_rights( async def set_passport_data_errors( self, - user_id: Union[str, int], - errors: Sequence[PassportElementError], + user_id: int, + errors: Sequence["PassportElementError"], *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_passport_data_errors( user_id=user_id, @@ -3243,15 +3870,15 @@ async def set_passport_data_errors( async def set_sticker_position_in_set( self, - sticker: str, + sticker: "str | Sticker", position: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_sticker_position_in_set( sticker=sticker, @@ -3266,44 +3893,22 @@ async def set_sticker_position_in_set( async def set_sticker_set_thumbnail( self, name: str, - user_id: Union[str, int], - thumbnail: Optional[FileInput] = None, + user_id: int, + format: str, # pylint: disable=redefined-builtin + thumbnail: "FileInput | None" = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_sticker_set_thumbnail( name=name, user_id=user_id, thumbnail=thumbnail, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), - ) - - async def set_sticker_set_thumb( - self, - name: str, - user_id: Union[str, int], - thumb: Optional[FileInput] = None, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, - ) -> bool: - return await super().set_sticker_set_thumb( - name=name, - user_id=user_id, - thumb=thumb, + format=format, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -3314,19 +3919,19 @@ async def set_sticker_set_thumb( async def set_webhook( self, url: str, - certificate: Optional[FileInput] = None, - max_connections: Optional[int] = None, - allowed_updates: Optional[Sequence[str]] = None, - ip_address: Optional[str] = None, - drop_pending_updates: Optional[bool] = None, - secret_token: Optional[str] = None, + certificate: "FileInput | None" = None, + max_connections: int | None = None, + allowed_updates: Sequence[str] | None = None, + ip_address: str | None = None, + drop_pending_updates: bool | None = None, + secret_token: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_webhook( url=url, @@ -3345,23 +3950,25 @@ async def set_webhook( async def stop_message_live_location( self, - chat_id: Optional[Union[str, int]] = None, - message_id: Optional[int] = None, - inline_message_id: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, + chat_id: str | int | None = None, + message_id: int | None = None, + inline_message_id: str | None = None, + reply_markup: "InlineKeyboardMarkup | None" = None, + business_connection_id: str | None = 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, - ) -> Union[Message, bool]: + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> "Message | bool": return await super().stop_message_live_location( chat_id=chat_id, message_id=message_id, inline_message_id=inline_message_id, reply_markup=reply_markup, + business_connection_id=business_connection_id, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -3371,16 +3978,16 @@ async def stop_message_live_location( async def unban_chat_member( self, - chat_id: Union[str, int], - user_id: Union[str, int], - only_if_banned: Optional[bool] = None, + chat_id: str | int, + user_id: int, + only_if_banned: bool | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().unban_chat_member( chat_id=chat_id, @@ -3395,15 +4002,15 @@ async def unban_chat_member( async def unban_chat_sender_chat( self, - chat_id: Union[str, int], + chat_id: str | int, sender_chat_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().unban_chat_sender_chat( chat_id=chat_id, @@ -3417,14 +4024,14 @@ async def unban_chat_sender_chat( async def unpin_all_chat_messages( self, - chat_id: Union[str, int], + chat_id: str | int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().unpin_all_chat_messages( chat_id=chat_id, @@ -3437,15 +4044,16 @@ async def unpin_all_chat_messages( async def unpin_chat_message( self, - chat_id: Union[str, int], - message_id: Optional[int] = None, + chat_id: str | int, + message_id: int | None = None, + business_connection_id: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().unpin_chat_message( chat_id=chat_id, @@ -3454,20 +4062,21 @@ async def unpin_chat_message( write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, + business_connection_id=business_connection_id, api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) async def unpin_all_forum_topic_messages( self, - chat_id: Union[str, int], + chat_id: str | int, message_thread_id: int, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().unpin_all_forum_topic_messages( chat_id=chat_id, @@ -3479,27 +4088,43 @@ async def unpin_all_forum_topic_messages( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def unpin_all_general_forum_topic_messages( + self, + chat_id: str | int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().unpin_all_general_forum_topic_messages( + chat_id=chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + async def upload_sticker_file( self, - user_id: Union[str, int], - png_sticker: Optional[ - FileInput - ] = None, # Deprecated since bot api 6.6. Optional for compatiblity. - sticker: Optional[FileInput] = None, # Actually required, but optional for compatibility. - sticker_format: Optional[str] = None, # Actually required, but optional for compatibility. + user_id: int, + sticker: FileInput, + sticker_format: str, *, read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, + 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> File: return await super().upload_sticker_file( user_id=user_id, sticker=sticker, sticker_format=sticker_format, - png_sticker=png_sticker, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -3509,15 +4134,15 @@ async def upload_sticker_file( async def set_my_description( self, - description: Optional[str] = None, - language_code: Optional[str] = None, + description: str | None = None, + language_code: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_my_description( description=description, @@ -3531,15 +4156,15 @@ async def set_my_description( async def set_my_short_description( self, - short_description: Optional[str] = None, - language_code: Optional[str] = None, + short_description: str | None = None, + language_code: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_my_short_description( short_description=short_description, @@ -3553,14 +4178,14 @@ async def set_my_short_description( async def get_my_description( self, - language_code: Optional[str] = None, + language_code: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> BotDescription: return await super().get_my_description( language_code=language_code, @@ -3573,14 +4198,14 @@ async def get_my_description( async def get_my_short_description( self, - language_code: Optional[str] = None, + language_code: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> BotShortDescription: return await super().get_my_short_description( language_code=language_code, @@ -3593,15 +4218,15 @@ async def get_my_short_description( async def set_my_name( self, - name: Optional[str] = None, - language_code: Optional[str] = None, + name: str | None = None, + language_code: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_my_name( name=name, @@ -3615,14 +4240,14 @@ async def set_my_name( async def get_my_name( self, - language_code: Optional[str] = None, + language_code: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> BotName: return await super().get_my_name( language_code=language_code, @@ -3636,14 +4261,14 @@ async def get_my_name( async def set_custom_emoji_sticker_set_thumbnail( self, name: str, - custom_emoji_id: Optional[str] = None, + custom_emoji_id: str | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_custom_emoji_sticker_set_thumbnail( name=name, @@ -3664,8 +4289,8 @@ async def set_sticker_set_title( 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_sticker_set_title( name=name, @@ -3685,8 +4310,8 @@ async def delete_sticker_set( 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().delete_sticker_set( name=name, @@ -3699,15 +4324,15 @@ async def delete_sticker_set( async def set_sticker_emoji_list( self, - sticker: str, + sticker: "str | Sticker", emoji_list: Sequence[str], *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - rate_limit_args: Optional[RLARGS] = None, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_sticker_emoji_list( sticker=sticker, @@ -3721,15 +4346,15 @@ async def set_sticker_emoji_list( async def set_sticker_keywords( self, - sticker: str, - keywords: Optional[Sequence[str]] = None, + sticker: "str | Sticker", + keywords: Sequence[str] | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_sticker_keywords( sticker=sticker, @@ -3743,15 +4368,15 @@ async def set_sticker_keywords( async def set_sticker_mask_position( self, - sticker: str, - mask_position: Optional[MaskPosition] = None, + sticker: "str | Sticker", + mask_position: MaskPosition | None = 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, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, ) -> bool: return await super().set_sticker_mask_position( sticker=sticker, @@ -3763,11 +4388,1050 @@ async def set_sticker_mask_position( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def get_user_chat_boosts( + self, + chat_id: str | int, + user_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> UserChatBoosts: + return await super().get_user_chat_boosts( + chat_id=chat_id, + user_id=user_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_message_reaction( + self, + chat_id: str | int, + message_id: int, + reaction: Sequence[ReactionType | str] | ReactionType | str | None = None, + is_big: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().set_message_reaction( + chat_id=chat_id, + message_id=message_id, + reaction=reaction, + is_big=is_big, + 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 gift_premium_subscription( + self, + user_id: int, + month_count: int, + star_count: int, + text: str | None = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Sequence["MessageEntity"] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().gift_premium_subscription( + user_id=user_id, + month_count=month_count, + star_count=star_count, + text=text, + text_parse_mode=text_parse_mode, + text_entities=text_entities, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_business_connection( + self, + business_connection_id: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> BusinessConnection: + return await super().get_business_connection( + business_connection_id=business_connection_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_business_account_gifts( + self, + business_connection_id: str, + exclude_unsaved: bool | None = None, + exclude_saved: bool | None = None, + exclude_unlimited: bool | None = None, + exclude_limited: bool | None = None, + exclude_unique: bool | None = None, + sort_by_price: bool | None = None, + offset: str | None = None, + limit: int | None = None, + exclude_limited_upgradable: bool | None = None, + exclude_limited_non_upgradable: bool | None = None, + exclude_from_blockchain: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> OwnedGifts: + return await super().get_business_account_gifts( + business_connection_id=business_connection_id, + exclude_unsaved=exclude_unsaved, + exclude_saved=exclude_saved, + exclude_unlimited=exclude_unlimited, + exclude_limited=exclude_limited, + exclude_limited_upgradable=exclude_limited_upgradable, + exclude_limited_non_upgradable=exclude_limited_non_upgradable, + exclude_unique=exclude_unique, + exclude_from_blockchain=exclude_from_blockchain, + sort_by_price=sort_by_price, + offset=offset, + limit=limit, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_business_account_star_balance( + self, + business_connection_id: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> StarAmount: + return await super().get_business_account_star_balance( + business_connection_id=business_connection_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def read_business_message( + self, + business_connection_id: str, + chat_id: int, + message_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().read_business_message( + business_connection_id=business_connection_id, + chat_id=chat_id, + message_id=message_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def delete_business_messages( + self, + business_connection_id: str, + message_ids: Sequence[int], + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().delete_business_messages( + business_connection_id=business_connection_id, + message_ids=message_ids, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def post_story( + self, + business_connection_id: str, + content: "InputStoryContent", + active_period: TimePeriod, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + areas: Sequence["StoryArea"] | None = None, + post_to_chat_page: bool | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> Story: + return await super().post_story( + business_connection_id=business_connection_id, + content=content, + active_period=active_period, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + areas=areas, + post_to_chat_page=post_to_chat_page, + protect_content=protect_content, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def edit_story( + self, + business_connection_id: str, + story_id: int, + content: "InputStoryContent", + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + areas: Sequence["StoryArea"] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> Story: + return await super().edit_story( + business_connection_id=business_connection_id, + story_id=story_id, + content=content, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + areas=areas, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def delete_story( + self, + business_connection_id: str, + story_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().delete_story( + business_connection_id=business_connection_id, + story_id=story_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_business_account_name( + self, + business_connection_id: str, + first_name: str, + last_name: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().set_business_account_name( + business_connection_id=business_connection_id, + first_name=first_name, + last_name=last_name, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_business_account_username( + self, + business_connection_id: str, + username: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().set_business_account_username( + business_connection_id=business_connection_id, + username=username, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_business_account_bio( + self, + business_connection_id: str, + bio: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().set_business_account_bio( + business_connection_id=business_connection_id, + bio=bio, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_business_account_gift_settings( + self, + business_connection_id: str, + show_gift_button: bool, + accepted_gift_types: AcceptedGiftTypes, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().set_business_account_gift_settings( + business_connection_id=business_connection_id, + show_gift_button=show_gift_button, + accepted_gift_types=accepted_gift_types, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_business_account_profile_photo( + self, + business_connection_id: str, + photo: "InputProfilePhoto", + is_public: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().set_business_account_profile_photo( + business_connection_id=business_connection_id, + photo=photo, + is_public=is_public, + 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 remove_business_account_profile_photo( + self, + business_connection_id: str, + is_public: bool | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().remove_business_account_profile_photo( + business_connection_id=business_connection_id, + is_public=is_public, + 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 convert_gift_to_stars( + self, + business_connection_id: str, + owned_gift_id: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().convert_gift_to_stars( + business_connection_id=business_connection_id, + owned_gift_id=owned_gift_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def upgrade_gift( + self, + business_connection_id: str, + owned_gift_id: str, + keep_original_details: bool | None = None, + star_count: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().upgrade_gift( + business_connection_id=business_connection_id, + owned_gift_id=owned_gift_id, + keep_original_details=keep_original_details, + star_count=star_count, + 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 transfer_gift( + self, + business_connection_id: str, + owned_gift_id: str, + new_owner_chat_id: int, + star_count: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().transfer_gift( + business_connection_id=business_connection_id, + owned_gift_id=owned_gift_id, + new_owner_chat_id=new_owner_chat_id, + star_count=star_count, + 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 transfer_business_account_stars( + self, + business_connection_id: str, + star_count: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().transfer_business_account_stars( + business_connection_id=business_connection_id, + star_count=star_count, + 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 replace_sticker_in_set( + self, + user_id: int, + name: str, + old_sticker: "str | Sticker", + sticker: "InputSticker", + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().replace_sticker_in_set( + user_id=user_id, + name=name, + old_sticker=old_sticker, + sticker=sticker, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def refund_star_payment( + self, + user_id: int, + telegram_payment_charge_id: str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().refund_star_payment( + user_id=user_id, + telegram_payment_charge_id=telegram_payment_charge_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_star_transactions( + self, + offset: int | None = None, + limit: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = 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 edit_user_star_subscription( + self, + user_id: int, + telegram_payment_charge_id: str, + is_canceled: bool, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().edit_user_star_subscription( + user_id=user_id, + telegram_payment_charge_id=telegram_payment_charge_id, + is_canceled=is_canceled, + 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: str | int, + star_count: int, + media: Sequence["InputPaidMedia"], + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + business_connection_id: str | None = None, + payload: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_thread_id: int | None = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = 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), + business_connection_id=business_connection_id, + payload=payload, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + message_thread_id=message_thread_id, + ) + + async def create_chat_subscription_invite_link( + self, + chat_id: str | int, + subscription_period: TimePeriod, + subscription_price: int, + name: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> ChatInviteLink: + return await super().create_chat_subscription_invite_link( + chat_id=chat_id, + subscription_period=subscription_period, + subscription_price=subscription_price, + name=name, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def edit_chat_subscription_invite_link( + self, + chat_id: str | int, + invite_link: "str | ChatInviteLink", + name: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> ChatInviteLink: + return await super().edit_chat_subscription_invite_link( + chat_id=chat_id, + invite_link=invite_link, + name=name, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_available_gifts( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> Gifts: + return await super().get_available_gifts( + 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_gift( + self, + gift_id: "str | Gift", + text: str | None = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_entities: Sequence["MessageEntity"] | None = None, + pay_for_upgrade: bool | None = None, + chat_id: str | int | None = None, + user_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().send_gift( + user_id=user_id, + chat_id=chat_id, + gift_id=gift_id, + text=text, + text_parse_mode=text_parse_mode, + text_entities=text_entities, + pay_for_upgrade=pay_for_upgrade, + 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 verify_chat( + self, + chat_id: int | str, + custom_description: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().verify_chat( + chat_id=chat_id, + custom_description=custom_description, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def verify_user( + self, + user_id: int, + custom_description: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().verify_user( + user_id=user_id, + custom_description=custom_description, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def remove_chat_verification( + self, + chat_id: int | str, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().remove_chat_verification( + chat_id=chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def remove_user_verification( + self, + user_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().remove_user_verification( + user_id=user_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_my_star_balance( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> StarAmount: + return await super().get_my_star_balance( + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def decline_suggested_post( + self, + chat_id: int, + message_id: int, + comment: str | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().decline_suggested_post( + chat_id=chat_id, + message_id=message_id, + comment=comment, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def approve_suggested_post( + self, + chat_id: int, + message_id: int, + send_date: int | dtm.datetime | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().approve_suggested_post( + chat_id=chat_id, + message_id=message_id, + send_date=send_date, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def repost_story( + self, + business_connection_id: str, + from_chat_id: int, + from_story_id: int, + active_period: TimePeriod, + post_to_chat_page: bool | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> Story: + return await super().repost_story( + business_connection_id=business_connection_id, + from_chat_id=from_chat_id, + from_story_id=from_story_id, + active_period=active_period, + post_to_chat_page=post_to_chat_page, + protect_content=protect_content, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_user_gifts( + self, + user_id: int, + exclude_unlimited: bool | None = None, + exclude_limited_upgradable: bool | None = None, + exclude_limited_non_upgradable: bool | None = None, + exclude_from_blockchain: bool | None = None, + exclude_unique: bool | None = None, + sort_by_price: bool | None = None, + offset: str | None = None, + limit: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> OwnedGifts: + return await super().get_user_gifts( + user_id=user_id, + exclude_unlimited=exclude_unlimited, + exclude_limited_upgradable=exclude_limited_upgradable, + exclude_limited_non_upgradable=exclude_limited_non_upgradable, + exclude_from_blockchain=exclude_from_blockchain, + exclude_unique=exclude_unique, + sort_by_price=sort_by_price, + offset=offset, + limit=limit, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_chat_gifts( + self, + chat_id: int | str, + exclude_unsaved: bool | None = None, + exclude_saved: bool | None = None, + exclude_unlimited: bool | None = None, + exclude_limited_upgradable: bool | None = None, + exclude_limited_non_upgradable: bool | None = None, + exclude_from_blockchain: bool | None = None, + exclude_unique: bool | None = None, + sort_by_price: bool | None = None, + offset: str | None = None, + limit: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> OwnedGifts: + return await super().get_chat_gifts( + chat_id=chat_id, + exclude_unsaved=exclude_unsaved, + exclude_saved=exclude_saved, + exclude_unlimited=exclude_unlimited, + exclude_limited_upgradable=exclude_limited_upgradable, + exclude_limited_non_upgradable=exclude_limited_non_upgradable, + exclude_from_blockchain=exclude_from_blockchain, + exclude_unique=exclude_unique, + sort_by_price=sort_by_price, + 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), + ) + # updated camelCase aliases getMe = get_me sendMessage = send_message + sendMessageDraft = send_message_draft deleteMessage = delete_message + deleteMessages = delete_messages forwardMessage = forward_message + forwardMessages = forward_messages sendPhoto = send_photo sendAudio = send_audio sendDocument = send_document @@ -3785,6 +5449,7 @@ async def set_sticker_mask_position( sendGame = send_game sendChatAction = send_chat_action answerInlineQuery = answer_inline_query + savePreparedInlineMessage = save_prepared_inline_message getUserProfilePhotos = get_user_profile_photos getFile = get_file banChatMember = ban_chat_member @@ -3827,6 +5492,7 @@ async def set_sticker_mask_position( deleteChatPhoto = delete_chat_photo setChatTitle = set_chat_title setChatDescription = set_chat_description + setUserEmojiStatus = set_user_emoji_status pinChatMessage = pin_chat_message unpinChatMessage = unpin_chat_message unpinAllChatMessages = unpin_all_chat_messages @@ -3837,17 +5503,19 @@ async def set_sticker_mask_position( addStickerToSet = add_sticker_to_set setStickerPositionInSet = set_sticker_position_in_set deleteStickerFromSet = delete_sticker_from_set - setStickerSetThumb = set_sticker_set_thumb setStickerSetThumbnail = set_sticker_set_thumbnail setPassportDataErrors = set_passport_data_errors sendPoll = send_poll stopPoll = stop_poll + sendChecklist = send_checklist + editMessageChecklist = edit_message_checklist sendDice = send_dice getMyCommands = get_my_commands setMyCommands = set_my_commands deleteMyCommands = delete_my_commands logOut = log_out copyMessage = copy_message + copyMessages = copy_messages getChatMenuButton = get_chat_menu_button setChatMenuButton = set_chat_menu_button getMyDefaultAdministratorRights = get_my_default_administrator_rights @@ -3877,3 +5545,44 @@ async def set_sticker_mask_position( setStickerMaskPosition = set_sticker_mask_position setMyName = set_my_name getMyName = get_my_name + unpinAllGeneralForumTopicMessages = unpin_all_general_forum_topic_messages + getUserChatBoosts = get_user_chat_boosts + setMessageReaction = set_message_reaction + giftPremiumSubscription = gift_premium_subscription + getBusinessConnection = get_business_connection + getBusinessAccountGifts = get_business_account_gifts + getBusinessAccountStarBalance = get_business_account_star_balance + readBusinessMessage = read_business_message + deleteBusinessMessages = delete_business_messages + postStory = post_story + editStory = edit_story + deleteStory = delete_story + setBusinessAccountName = set_business_account_name + setBusinessAccountUsername = set_business_account_username + setBusinessAccountBio = set_business_account_bio + setBusinessAccountGiftSettings = set_business_account_gift_settings + setBusinessAccountProfilePhoto = set_business_account_profile_photo + removeBusinessAccountProfilePhoto = remove_business_account_profile_photo + convertGiftToStars = convert_gift_to_stars + upgradeGift = upgrade_gift + transferGift = transfer_gift + transferBusinessAccountStars = transfer_business_account_stars + replaceStickerInSet = replace_sticker_in_set + refundStarPayment = refund_star_payment + getStarTransactions = get_star_transactions + editUserStarSubscription = edit_user_star_subscription + createChatSubscriptionInviteLink = create_chat_subscription_invite_link + editChatSubscriptionInviteLink = edit_chat_subscription_invite_link + sendPaidMedia = send_paid_media + getAvailableGifts = get_available_gifts + sendGift = send_gift + verifyChat = verify_chat + verifyUser = verify_user + removeChatVerification = remove_chat_verification + removeUserVerification = remove_user_verification + getMyStarBalance = get_my_star_balance + approveSuggestedPost = approve_suggested_post + declineSuggestedPost = decline_suggested_post + repostStory = repost_story + getUserGifts = get_user_gifts + getChatGifts = get_chat_gifts diff --git a/src/telegram/ext/_handlers/__init__.py b/src/telegram/ext/_handlers/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/telegram/ext/_handler.py b/src/telegram/ext/_handlers/basehandler.py similarity index 86% rename from telegram/ext/_handler.py rename to src/telegram/ext/_handlers/basehandler.py index 1af83b88da3..6a880191b83 100644 --- a/telegram/ext/_handler.py +++ b/src/telegram/ext/_handlers/basehandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,10 +17,12 @@ # 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 the base class for handlers as used by the Application.""" + from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Generic, Optional, TypeVar, Union +from typing import TYPE_CHECKING, Any, Generic, TypeVar from telegram._utils.defaultvalue import DEFAULT_TRUE +from telegram._utils.repr import build_repr_with_selected_attrs from telegram._utils.types import DVType from telegram.ext._utils.types import CCT, HandlerCallback @@ -31,14 +33,14 @@ UT = TypeVar("UT") -class BaseHandler(Generic[UT, CCT], ABC): +class BaseHandler(ABC, Generic[UT, CCT, RT]): """The base class for all update handlers. Create custom handlers by inheriting from it. Warning: When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info. - This class is a :class:`~typing.Generic` class and accepts two type variables: + This class is a :class:`~typing.Generic` class and accepts three type variables: 1. The type of the updates that this handler will handle. Must coincide with the type of the first argument of :paramref:`callback`. :meth:`check_update` must only accept @@ -53,6 +55,7 @@ class BaseHandler(Generic[UT, CCT], ABC): For this type variable, one should usually provide a :class:`~typing.TypeVar` that is also used for the mentioned method arguments. That way, a type checker can check whether this handler fits the definition of the :class:`~Application`. + 3. The return type of the :paramref:`callback` function accepted by this handler. .. seealso:: :wiki:`Types of Handlers ` @@ -78,25 +81,40 @@ async def callback(update: Update, context: CallbackContext) Attributes: callback (:term:`coroutine function`): The callback function for this handler. - block (:obj:`bool`): Determines whether the callback will run in a blocking way.. + block (:obj:`bool`): Determines whether the callback will run in a blocking way. """ __slots__ = ( - "callback", "block", + "callback", ) def __init__( - self, + self: "BaseHandler[UT, CCT, RT]", callback: HandlerCallback[UT, CCT, RT], block: DVType[bool] = DEFAULT_TRUE, ): self.callback: HandlerCallback[UT, CCT, RT] = callback self.block: DVType[bool] = block + def __repr__(self) -> str: + """Give a string representation of the handler in the form ``ClassName[callback=...]``. + + As this class doesn't implement :meth:`object.__str__`, the default implementation + will be used, which is equivalent to :meth:`__repr__`. + + Returns: + :obj:`str` + """ + try: + callback_name = self.callback.__qualname__ + except AttributeError: + callback_name = repr(self.callback) + return build_repr_with_selected_attrs(self, callback=callback_name) + @abstractmethod - def check_update(self, update: object) -> Optional[Union[bool, object]]: + def check_update(self, update: object) -> bool | object | None: """ This method is called to determine if an update should be handled by this handler instance. It should always be overridden. diff --git a/src/telegram/ext/_handlers/businessconnectionhandler.py b/src/telegram/ext/_handlers/businessconnectionhandler.py new file mode 100644 index 00000000000..f180d22ef6d --- /dev/null +++ b/src/telegram/ext/_handlers/businessconnectionhandler.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 the BusinessConnectionHandler class.""" + +from typing import TypeVar + +from telegram import Update +from telegram._utils.defaultvalue import DEFAULT_TRUE +from telegram._utils.types import SCT, DVType +from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._utils._update_parsing import parse_chat_id, parse_username +from telegram.ext._utils.types import CCT, HandlerCallback + +RT = TypeVar("RT") + + +class BusinessConnectionHandler(BaseHandler[Update, CCT, RT]): + """Handler class to handle Telegram + :attr:`Business Connections `. + + .. versionadded:: 21.1 + + Args: + callback (:term:`coroutine function`): The callback function for this handler. Will be + called when :meth:`check_update` has determined that an update should be processed by + this handler. Callback signature:: + + async def callback(update: Update, context: CallbackContext) + user_id (:obj:`int` | Collection[:obj:`int`], optional): Filters requests to allow only + those which are from the specified user ID(s). + + username (:obj:`str` | Collection[:obj:`str`], optional): Filters requests to allow only + those which are from the specified username(s). + + block (:obj:`bool`, optional): Determines whether the return value of the callback should + be awaited before processing the next handler in + :meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`. + + .. seealso:: :wiki:`Concurrency` + Attributes: + callback (:term:`coroutine function`): The callback function for this handler. + block (:obj:`bool`): Determines whether the return value of the callback should be + awaited before processing the next handler in + :meth:`telegram.ext.Application.process_update`. + """ + + __slots__ = ( + "_user_ids", + "_usernames", + ) + + def __init__( + self: "BusinessConnectionHandler[CCT, RT]", + callback: HandlerCallback[Update, CCT, RT], + user_id: SCT[int] | None = None, + username: SCT[str] | None = None, + block: DVType[bool] = DEFAULT_TRUE, + ): + super().__init__(callback, block=block) + + self._user_ids = parse_chat_id(user_id) + self._usernames = parse_username(username) + + def check_update(self, update: object) -> bool: + """Determines whether an update should be passed to this handler's :attr:`callback`. + + Args: + update (:class:`telegram.Update` | :obj:`object`): Incoming update. + + Returns: + :obj:`bool` + + """ + if isinstance(update, Update) and update.business_connection: + if not self._user_ids and not self._usernames: + return True + if update.business_connection.user.id in self._user_ids: + return True + return update.business_connection.user.username in self._usernames + return False diff --git a/src/telegram/ext/_handlers/businessmessagesdeletedhandler.py b/src/telegram/ext/_handlers/businessmessagesdeletedhandler.py new file mode 100644 index 00000000000..dfbd80fc5f3 --- /dev/null +++ b/src/telegram/ext/_handlers/businessmessagesdeletedhandler.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 the BusinessMessagesDeletedHandler class.""" + +from typing import TypeVar + +from telegram import Update +from telegram._utils.defaultvalue import DEFAULT_TRUE +from telegram._utils.types import SCT, DVType +from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._utils._update_parsing import parse_chat_id, parse_username +from telegram.ext._utils.types import CCT, HandlerCallback + +RT = TypeVar("RT") + + +class BusinessMessagesDeletedHandler(BaseHandler[Update, CCT, RT]): + """Handler class to handle + :attr:`deleted Telegram Business messages `. + + .. versionadded:: 21.1 + + Args: + callback (:term:`coroutine function`): The callback function for this handler. Will be + called when :meth:`check_update` has determined that an update should be processed by + this handler. Callback signature:: + + async def callback(update: Update, context: CallbackContext) + chat_id (:obj:`int` | Collection[:obj:`int`], optional): Filters requests to allow only + those which are from the specified chat ID(s). + + username (:obj:`str` | Collection[:obj:`str`], optional): Filters requests to allow only + those which are from the specified username(s). + + block (:obj:`bool`, optional): Determines whether the return value of the callback should + be awaited before processing the next handler in + :meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`. + + .. seealso:: :wiki:`Concurrency` + Attributes: + callback (:term:`coroutine function`): The callback function for this handler. + block (:obj:`bool`): Determines whether the return value of the callback should be + awaited before processing the next handler in + :meth:`telegram.ext.Application.process_update`. + """ + + __slots__ = ( + "_chat_ids", + "_usernames", + ) + + def __init__( + self: "BusinessMessagesDeletedHandler[CCT, RT]", + callback: HandlerCallback[Update, CCT, RT], + chat_id: SCT[int] | None = None, + username: SCT[str] | None = None, + block: DVType[bool] = DEFAULT_TRUE, + ): + super().__init__(callback, block=block) + + self._chat_ids = parse_chat_id(chat_id) + self._usernames = parse_username(username) + + def check_update(self, update: object) -> bool: + """Determines whether an update should be passed to this handler's :attr:`callback`. + + Args: + update (:class:`telegram.Update` | :obj:`object`): Incoming update. + + Returns: + :obj:`bool` + + """ + if isinstance(update, Update) and update.deleted_business_messages: + if not self._chat_ids and not self._usernames: + return True + if update.deleted_business_messages.chat.id in self._chat_ids: + return True + return update.deleted_business_messages.chat.username in self._usernames + return False diff --git a/telegram/ext/_callbackqueryhandler.py b/src/telegram/ext/_handlers/callbackqueryhandler.py similarity index 65% rename from telegram/ext/_callbackqueryhandler.py rename to src/telegram/ext/_handlers/callbackqueryhandler.py index 9e19f88960d..8a903e7cd7a 100644 --- a/telegram/ext/_callbackqueryhandler.py +++ b/src/telegram/ext/_handlers/callbackqueryhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,14 +17,17 @@ # 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 the CallbackQueryHandler class.""" + import asyncio import re -from typing import TYPE_CHECKING, Any, Callable, Match, Optional, Pattern, TypeVar, Union, cast +from collections.abc import Callable +from re import Match, Pattern +from typing import TYPE_CHECKING, Any, TypeVar, cast from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.types import DVType -from telegram.ext._handler import BaseHandler +from telegram.ext._handlers.basehandler import BaseHandler from telegram.ext._utils.types import CCT, HandlerCallback if TYPE_CHECKING: @@ -33,8 +36,8 @@ RT = TypeVar("RT") -class CallbackQueryHandler(BaseHandler[Update, CCT]): - """BaseHandler class to handle Telegram +class CallbackQueryHandler(BaseHandler[Update, CCT, RT]): + """Handler class to handle Telegram :attr:`callback queries `. Optionally based on a regex. Read the documentation of the :mod:`re` module for more information. @@ -51,6 +54,15 @@ class CallbackQueryHandler(BaseHandler[Update, CCT]): .. versionadded:: 13.6 + * If neither :paramref:`pattern` nor :paramref:`game_pattern` is set, `any` + ``CallbackQuery`` will be handled. If only :paramref:`pattern` is set, queries with + :attr:`~telegram.CallbackQuery.game_short_name` will `not` be considered and vice versa. + If both patterns are set, queries with either :attr: + `~telegram.CallbackQuery.game_short_name` or :attr:`~telegram.CallbackQuery.data` + matching the defined pattern will be handled + + .. versionadded:: 21.5 + Warning: When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info. @@ -85,6 +97,13 @@ async def callback(update: Update, context: CallbackContext) .. versionchanged:: 13.6 Added support for arbitrary callback data. + game_pattern (:obj:`str` | :func:`re.Pattern ` | optional) + Pattern to test :attr:`telegram.CallbackQuery.game_short_name` against. If a string or + a regex pattern is passed, :func:`re.match` is used on + :attr:`telegram.CallbackQuery.game_short_name` to determine if an update should be + handled by this handler. + + .. versionadded:: 21.5 block (:obj:`bool`, optional): Determines whether the return value of the callback should be awaited before processing the next handler in :meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`. @@ -98,20 +117,21 @@ async def callback(update: Update, context: CallbackContext) .. versionchanged:: 13.6 Added support for arbitrary callback data. + game_pattern (:func:`re.Pattern `): Optional. + Regex pattern to test :attr:`telegram.CallbackQuery.game_short_name` block (:obj:`bool`): Determines whether the return value of the callback should be awaited before processing the next handler in :meth:`telegram.ext.Application.process_update`. """ - __slots__ = ("pattern",) + __slots__ = ("game_pattern", "pattern") def __init__( - self, + self: "CallbackQueryHandler[CCT, RT]", callback: HandlerCallback[Update, CCT, RT], - pattern: Optional[ - Union[str, Pattern[str], type, Callable[[object], Optional[bool]]] - ] = None, + pattern: str | Pattern[str] | type | Callable[[object], bool] | None = None, + game_pattern: str | Pattern[str] | None = None, block: DVType[bool] = DEFAULT_TRUE, ): super().__init__(callback, block=block) @@ -120,15 +140,15 @@ def __init__( raise TypeError( "The `pattern` must not be a coroutine function! Use an ordinary function instead." ) - if isinstance(pattern, str): pattern = re.compile(pattern) - self.pattern: Optional[ - Union[str, Pattern[str], type, Callable[[object], Optional[bool]]] - ] = pattern + if isinstance(game_pattern, str): + game_pattern = re.compile(game_pattern) + self.pattern: str | Pattern[str] | type | Callable[[object], bool] | None = pattern + self.game_pattern: str | Pattern[str] | None = game_pattern - def check_update(self, update: object) -> Optional[Union[bool, object]]: + def check_update(self, update: object) -> bool | object | None: """Determines whether an update should be passed to this handler's :attr:`callback`. Args: @@ -139,34 +159,48 @@ def check_update(self, update: object) -> Optional[Union[bool, object]]: """ # pylint: disable=too-many-return-statements - if isinstance(update, Update) and update.callback_query: - callback_data = update.callback_query.data - if self.pattern: - if callback_data is None: - return False - if isinstance(self.pattern, type): - return isinstance(callback_data, self.pattern) - if callable(self.pattern): - return self.pattern(callback_data) - if not isinstance(callback_data, str): - return False - match = re.match(self.pattern, callback_data) - if match: - return match - else: - return True - return None + if not (isinstance(update, Update) and update.callback_query): + return None + + callback_data = update.callback_query.data + game_short_name = update.callback_query.game_short_name + + if not any([self.pattern, self.game_pattern]): + return True + + # we check for .data or .game_short_name from update to filter based on whats coming + # this gives xor-like behavior + if callback_data: + if not self.pattern: + return False + if isinstance(self.pattern, type): + return isinstance(callback_data, self.pattern) + if callable(self.pattern): + return self.pattern(callback_data) + if not isinstance(callback_data, str): + return False + if match := re.match(self.pattern, callback_data): + return match + + elif game_short_name: + if not self.game_pattern: + return False + if match := re.match(self.game_pattern, game_short_name): + return match + else: + return True + return False def collect_additional_context( self, context: CCT, - update: Update, # skipcq: BAN-B301 - application: "Application[Any, CCT, Any, Any, Any, Any]", # skipcq: BAN-B301 - check_result: Union[bool, Match[str]], + update: Update, # noqa: ARG002 + application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 + check_result: bool | Match[str], ) -> None: """Add the result of ``re.match(pattern, update.callback_query.data)`` to :attr:`CallbackContext.matches` as list with one element. """ if self.pattern: - check_result = cast(Match, check_result) + check_result = cast("Match", check_result) context.matches = [check_result] diff --git a/src/telegram/ext/_handlers/chatboosthandler.py b/src/telegram/ext/_handlers/chatboosthandler.py new file mode 100644 index 00000000000..0e7feba0a27 --- /dev/null +++ b/src/telegram/ext/_handlers/chatboosthandler.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 the ChatBoostHandler class.""" + +from typing import Final + +from telegram import Update +from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._utils._update_parsing import parse_chat_id, parse_username +from telegram.ext._utils.types import CCT, RT, HandlerCallback + + +class ChatBoostHandler(BaseHandler[Update, CCT, RT]): + """ + Handler class to handle Telegram updates that contain a chat boost. + + Warning: + When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom + attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info. + + .. versionadded:: 20.8 + + Args: + callback (:term:`coroutine function`): The callback function for this handler. Will be + called when :meth:`check_update` has determined that an update should be processed by + this handler. Callback signature:: + + async def callback(update: Update, context: CallbackContext) + + The return value of the callback is usually ignored except for the special case of + :class:`telegram.ext.ConversationHandler`. + chat_boost_types (:obj:`int`, optional): Pass one of + :attr:`CHAT_BOOST`, :attr:`REMOVED_CHAT_BOOST` or + :attr:`ANY_CHAT_BOOST` to specify if this handler should handle only updates with + :attr:`telegram.Update.chat_boost`, + :attr:`telegram.Update.removed_chat_boost` or both. Defaults to + :attr:`CHAT_BOOST`. + chat_id (:obj:`int` | Collection[:obj:`int`], optional): Filters reactions to allow + only those which happen in the specified chat ID(s). + chat_username (:obj:`str` | Collection[:obj:`str`], optional): Filters reactions to allow + only those which happen in the specified username(s). + block (:obj:`bool`, optional): Determines whether the return value of the callback should + be awaited before processing the next handler in + :meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`. + + .. seealso:: :wiki:`Concurrency` + + Attributes: + callback (:term:`coroutine function`): The callback function for this handler. + chat_boost_types (:obj:`int`): Optional. Specifies if this handler should handle only + updates with :attr:`telegram.Update.chat_boost`, + :attr:`telegram.Update.removed_chat_boost` or both. + block (:obj:`bool`): Determines whether the callback will run in a blocking way. + """ + + __slots__ = ( + "_chat_ids", + "_chat_usernames", + "chat_boost_types", + ) + + CHAT_BOOST: Final[int] = -1 + """ :obj:`int`: Used as a constant to handle only :attr:`telegram.Update.chat_boost`.""" + REMOVED_CHAT_BOOST: Final[int] = 0 + """:obj:`int`: Used as a constant to handle only :attr:`telegram.Update.removed_chat_boost`.""" + ANY_CHAT_BOOST: Final[int] = 1 + """:obj:`int`: Used as a constant to handle both :attr:`telegram.Update.chat_boost` + and :attr:`telegram.Update.removed_chat_boost`.""" + + def __init__( + self: "ChatBoostHandler[CCT, RT]", + callback: HandlerCallback[Update, CCT, RT], + chat_boost_types: int = CHAT_BOOST, + chat_id: int | None = None, + chat_username: str | None = None, + block: bool = True, + ): + super().__init__(callback, block=block) + self.chat_boost_types: int = chat_boost_types + self._chat_ids = parse_chat_id(chat_id) + self._chat_usernames = parse_username(chat_username) + + def check_update(self, update: object) -> bool: + """Determines whether an update should be passed to this handler's :attr:`callback`. + + Args: + update (:class:`telegram.Update` | :obj:`object`): Incoming update. + + Returns: + :obj:`bool` + + """ + if not isinstance(update, Update): + return False + + if not (update.chat_boost or update.removed_chat_boost): + return False + + if self.chat_boost_types == self.CHAT_BOOST and not update.chat_boost: + return False + + if self.chat_boost_types == self.REMOVED_CHAT_BOOST and not update.removed_chat_boost: + return False + + if not any((self._chat_ids, self._chat_usernames)): + return True + + # Extract chat and user IDs and usernames from the update for comparison + chat_id = chat.id if (chat := update.effective_chat) else None + chat_username = chat.username if chat else None + + return bool(self._chat_ids and (chat_id in self._chat_ids)) or bool( + self._chat_usernames and (chat_username in self._chat_usernames) + ) diff --git a/telegram/ext/_chatjoinrequesthandler.py b/src/telegram/ext/_handlers/chatjoinrequesthandler.py similarity index 76% rename from telegram/ext/_chatjoinrequesthandler.py rename to src/telegram/ext/_handlers/chatjoinrequesthandler.py index d9875813185..a578c50bafd 100644 --- a/telegram/ext/_chatjoinrequesthandler.py +++ b/src/telegram/ext/_handlers/chatjoinrequesthandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,17 +18,16 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the ChatJoinRequestHandler class.""" -from typing import FrozenSet, Optional - from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.types import RT, SCT, DVType -from telegram.ext._handler import BaseHandler +from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._utils._update_parsing import parse_chat_id, parse_username from telegram.ext._utils.types import CCT, HandlerCallback -class ChatJoinRequestHandler(BaseHandler[Update, CCT]): - """BaseHandler class to handle Telegram updates that contain +class ChatJoinRequestHandler(BaseHandler[Update, CCT, RT]): + """Handler class to handle Telegram updates that contain :attr:`telegram.Update.chat_join_request`. Note: @@ -80,32 +79,16 @@ async def callback(update: Update, context: CallbackContext) ) def __init__( - self, + self: "ChatJoinRequestHandler[CCT, RT]", callback: HandlerCallback[Update, CCT, RT], - chat_id: Optional[SCT[int]] = None, - username: Optional[SCT[str]] = None, + chat_id: SCT[int] | None = None, + username: SCT[str] | None = None, block: DVType[bool] = DEFAULT_TRUE, ): super().__init__(callback, block=block) - self._chat_ids = self._parse_chat_id(chat_id) - self._usernames = self._parse_username(username) - - @staticmethod - def _parse_chat_id(chat_id: Optional[SCT[int]]) -> FrozenSet[int]: - if chat_id is None: - return frozenset() - if isinstance(chat_id, int): - return frozenset({chat_id}) - return frozenset(chat_id) - - @staticmethod - def _parse_username(username: Optional[SCT[str]]) -> FrozenSet[str]: - if username is None: - return frozenset() - if isinstance(username, str): - return frozenset({username[1:] if username.startswith("@") else username}) - return frozenset({usr[1:] if usr.startswith("@") else usr for usr in username}) + self._chat_ids = parse_chat_id(chat_id) + self._usernames = parse_username(username) def check_update(self, update: object) -> bool: """Determines whether an update should be passed to this handler's :attr:`callback`. @@ -122,7 +105,5 @@ def check_update(self, update: object) -> bool: return True if update.chat_join_request.chat.id in self._chat_ids: return True - if update.chat_join_request.from_user.username in self._usernames: - return True - return False + return update.chat_join_request.from_user.username in self._usernames return False diff --git a/telegram/ext/_chatmemberhandler.py b/src/telegram/ext/_handlers/chatmemberhandler.py similarity index 72% rename from telegram/ext/_chatmemberhandler.py rename to src/telegram/ext/_handlers/chatmemberhandler.py index 2267ee59387..cfdeae714ea 100644 --- a/telegram/ext/_chatmemberhandler.py +++ b/src/telegram/ext/_handlers/chatmemberhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,19 +17,21 @@ # 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 the ChatMemberHandler class.""" -from typing import ClassVar, Optional, TypeVar + +from typing import Final, TypeVar from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE -from telegram._utils.types import DVType -from telegram.ext._handler import BaseHandler +from telegram._utils.types import SCT, DVType +from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._utils._update_parsing import parse_chat_id from telegram.ext._utils.types import CCT, HandlerCallback RT = TypeVar("RT") -class ChatMemberHandler(BaseHandler[Update, CCT]): - """BaseHandler class to handle Telegram updates that contain a chat member update. +class ChatMemberHandler(BaseHandler[Update, CCT, RT]): + """Handler class to handle Telegram updates that contain a chat member update. Warning: When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom @@ -58,6 +60,9 @@ async def callback(update: Update, context: CallbackContext) :meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`. .. seealso:: :wiki:`Concurrency` + chat_id (:obj:`int` | Collection[:obj:`int`], optional): Filters chat member updates from + specified chat ID(s) only. + .. versionadded:: 21.3 Attributes: callback (:term:`coroutine function`): The callback function for this handler. @@ -70,24 +75,29 @@ async def callback(update: Update, context: CallbackContext) """ - __slots__ = ("chat_member_types",) - MY_CHAT_MEMBER: ClassVar[int] = -1 + __slots__ = ( + "_chat_ids", + "chat_member_types", + ) + MY_CHAT_MEMBER: Final[int] = -1 """:obj:`int`: Used as a constant to handle only :attr:`telegram.Update.my_chat_member`.""" - CHAT_MEMBER: ClassVar[int] = 0 + CHAT_MEMBER: Final[int] = 0 """:obj:`int`: Used as a constant to handle only :attr:`telegram.Update.chat_member`.""" - ANY_CHAT_MEMBER: ClassVar[int] = 1 + ANY_CHAT_MEMBER: Final[int] = 1 """:obj:`int`: Used as a constant to handle both :attr:`telegram.Update.my_chat_member` and :attr:`telegram.Update.chat_member`.""" def __init__( - self, + self: "ChatMemberHandler[CCT, RT]", callback: HandlerCallback[Update, CCT, RT], chat_member_types: int = MY_CHAT_MEMBER, block: DVType[bool] = DEFAULT_TRUE, + chat_id: SCT[int] | None = None, ): super().__init__(callback, block=block) - self.chat_member_types: Optional[int] = chat_member_types + self.chat_member_types: int | None = chat_member_types + self._chat_ids = parse_chat_id(chat_id) def check_update(self, update: object) -> bool: """Determines whether an update should be passed to this handler's :attr:`callback`. @@ -99,12 +109,18 @@ def check_update(self, update: object) -> bool: :obj:`bool` """ - if isinstance(update, Update): - if not (update.my_chat_member or update.chat_member): - return False - if self.chat_member_types == self.ANY_CHAT_MEMBER: - return True - if self.chat_member_types == self.CHAT_MEMBER: - return bool(update.chat_member) - return bool(update.my_chat_member) - return False + if not isinstance(update, Update): + return False + if not (update.my_chat_member or update.chat_member): + return False + if ( + self._chat_ids + and update.effective_chat + and update.effective_chat.id not in self._chat_ids + ): + return False + if self.chat_member_types == self.ANY_CHAT_MEMBER: + return True + if self.chat_member_types == self.CHAT_MEMBER: + return bool(update.chat_member) + return bool(update.my_chat_member) diff --git a/telegram/ext/_choseninlineresulthandler.py b/src/telegram/ext/_handlers/choseninlineresulthandler.py similarity index 84% rename from telegram/ext/_choseninlineresulthandler.py rename to src/telegram/ext/_handlers/choseninlineresulthandler.py index 4836905a860..8a70582c46d 100644 --- a/telegram/ext/_choseninlineresulthandler.py +++ b/src/telegram/ext/_handlers/choseninlineresulthandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,13 +17,15 @@ # 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 the ChosenInlineResultHandler class.""" + import re -from typing import TYPE_CHECKING, Any, Match, Optional, Pattern, TypeVar, Union, cast +from re import Match, Pattern +from typing import TYPE_CHECKING, Any, TypeVar, cast from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.types import DVType -from telegram.ext._handler import BaseHandler +from telegram.ext._handlers.basehandler import BaseHandler from telegram.ext._utils.types import CCT, HandlerCallback RT = TypeVar("RT") @@ -32,8 +34,8 @@ from telegram.ext import Application -class ChosenInlineResultHandler(BaseHandler[Update, CCT]): - """BaseHandler class to handle Telegram updates that contain +class ChosenInlineResultHandler(BaseHandler[Update, CCT, RT]): + """Handler class to handle Telegram updates that contain :attr:`telegram.Update.chosen_inline_result`. Warning: @@ -76,19 +78,19 @@ async def callback(update: Update, context: CallbackContext) __slots__ = ("pattern",) def __init__( - self, + self: "ChosenInlineResultHandler[CCT, RT]", callback: HandlerCallback[Update, CCT, RT], block: DVType[bool] = DEFAULT_TRUE, - pattern: Optional[Union[str, Pattern[str]]] = None, + pattern: str | Pattern[str] | None = None, ): super().__init__(callback, block=block) if isinstance(pattern, str): pattern = re.compile(pattern) - self.pattern: Optional[Union[str, Pattern[str]]] = pattern + self.pattern: str | Pattern[str] | None = pattern - def check_update(self, update: object) -> Optional[Union[bool, object]]: + def check_update(self, update: object) -> bool | object | None: """Determines whether an update should be passed to this handler's :attr:`callback`. Args: @@ -100,8 +102,7 @@ def check_update(self, update: object) -> Optional[Union[bool, object]]: """ if isinstance(update, Update) and update.chosen_inline_result: if self.pattern: - match = re.match(self.pattern, update.chosen_inline_result.result_id) - if match: + if match := re.match(self.pattern, update.chosen_inline_result.result_id): return match else: return True @@ -110,13 +111,13 @@ def check_update(self, update: object) -> Optional[Union[bool, object]]: def collect_additional_context( self, context: CCT, - update: Update, # skipcq: BAN-B301 - application: "Application[Any, CCT, Any, Any, Any, Any]", # skipcq: BAN-B301 - check_result: Union[bool, Match[str]], + update: Update, # noqa: ARG002 + application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 + check_result: bool | Match[str], ) -> None: """This function adds the matched regex pattern result to :attr:`telegram.ext.CallbackContext.matches`. """ if self.pattern: - check_result = cast(Match, check_result) + check_result = cast("Match", check_result) context.matches = [check_result] diff --git a/telegram/ext/_commandhandler.py b/src/telegram/ext/_handlers/commandhandler.py similarity index 68% rename from telegram/ext/_commandhandler.py rename to src/telegram/ext/_handlers/commandhandler.py index c68035208f2..8056a5acb77 100644 --- a/telegram/ext/_commandhandler.py +++ b/src/telegram/ext/_handlers/commandhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,14 +17,15 @@ # 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 the CommandHandler class.""" + import re -from typing import TYPE_CHECKING, Any, FrozenSet, List, Optional, Tuple, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeVar from telegram import MessageEntity, Update from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.types import SCT, DVType from telegram.ext import filters as filters_module -from telegram.ext._handler import BaseHandler +from telegram.ext._handlers.basehandler import BaseHandler from telegram.ext._utils.types import CCT, FilterDataDict, HandlerCallback if TYPE_CHECKING: @@ -33,13 +34,14 @@ RT = TypeVar("RT") -class CommandHandler(BaseHandler[Update, CCT]): - """BaseHandler class to handle Telegram commands. +class CommandHandler(BaseHandler[Update, CCT, RT]): + """Handler class to handle Telegram commands. - Commands are Telegram messages that start with ``/``, optionally followed by an ``@`` and the - bot's name and/or some additional text. The handler will add a :obj:`list` to the - :class:`CallbackContext` named :attr:`CallbackContext.args`. It will contain a list of strings, - which is the text following the command split on single or consecutive whitespace characters. + Commands are Telegram messages that start with a :attr:`telegram.MessageEntity.BOT_COMMAND` + (so with ``/``, optionally followed by an ``@`` and the bot's name and/or some additional + text). The handler will add a :obj:`list` to the :class:`CallbackContext` named + :attr:`CallbackContext.args`. It will contain a list of strings, which is the text following + the command split on single or consecutive whitespace characters. By default, the handler listens to messages as well as edited messages. To change this behavior use :attr:`~filters.UpdateType.EDITED_MESSAGE ` @@ -53,6 +55,11 @@ class CommandHandler(BaseHandler[Update, CCT]): :attr:`telegram.ext.filters.CAPTION` and :class:`telegram.ext.filters.Regex`) to handle those messages. + Note: + If you want to support a different entity in the beginning, e.g. if a command message is + wrapped in a :attr:`telegram.MessageEntity.CODE`, use the + :class:`telegram.ext.PrefixHandler`. + Warning: When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info. @@ -88,28 +95,42 @@ async def callback(update: Update, context: CallbackContext) :meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`. .. seealso:: :wiki:`Concurrency` + has_args (:obj:`bool` | :obj:`int`, optional): + Determines whether the command handler should process the update or not. + If :obj:`True`, the handler will process any non-zero number of args. + If :obj:`False`, the handler will only process if there are no args. + if :obj:`int`, the handler will only process if there are exactly that many args. + Defaults to :obj:`None`, which means the handler will process any or no args. + + .. versionadded:: 20.5 Raises: :exc:`ValueError`: When the command is too long or has illegal chars. Attributes: - commands (FrozenSet[:obj:`str`]): The set of commands this handler should listen for. + commands (frozenset[:obj:`str`]): The set of commands this handler should listen for. callback (:term:`coroutine function`): The callback function for this handler. filters (:class:`telegram.ext.filters.BaseFilter`): Optional. Only allow updates with these - Filters. + filters. block (:obj:`bool`): Determines whether the return value of the callback should be awaited before processing the next handler in :meth:`telegram.ext.Application.process_update`. + has_args (:obj:`bool` | :obj:`int` | None): + Optional argument, otherwise all implementations of :class:`CommandHandler` will break. + Defaults to :obj:`None`, which means the handler will process any args or no args. + + .. versionadded:: 20.5 """ - __slots__ = ("commands", "filters") + __slots__ = ("commands", "filters", "has_args") def __init__( - self, + self: "CommandHandler[CCT, RT]", command: SCT[str], callback: HandlerCallback[Update, CCT, RT], - filters: Optional[filters_module.BaseFilter] = None, + filters: filters_module.BaseFilter | None = None, block: DVType[bool] = DEFAULT_TRUE, + has_args: bool | int | None = None, ): super().__init__(callback, block=block) @@ -120,15 +141,34 @@ def __init__( for comm in commands: if not re.match(r"^[\da-z_]{1,32}$", comm): raise ValueError(f"Command `{comm}` is not a valid bot command") - self.commands: FrozenSet[str] = commands + self.commands: frozenset[str] = commands self.filters: filters_module.BaseFilter = ( filters if filters is not None else filters_module.UpdateType.MESSAGES ) + self.has_args: bool | int | None = has_args + + if (isinstance(self.has_args, int)) and (self.has_args < 0): + raise ValueError("CommandHandler argument has_args cannot be a negative integer") + + def _check_correct_args(self, args: list[str]) -> bool | None: + """Determines whether the args are correct for this handler. Implemented in check_update(). + Args: + args (:obj:`list`): The args for the handler. + Returns: + :obj:`bool`: Whether the args are valid for this handler. + """ + return bool( + (self.has_args is None) + or (self.has_args is True and args) + or (self.has_args is False and not args) + or (isinstance(self.has_args, int) and len(args) == self.has_args) + ) + def check_update( self, update: object - ) -> Optional[Union[bool, Tuple[List[str], Optional[Union[bool, FilterDataDict]]]]]: + ) -> bool | tuple[list[str], bool | FilterDataDict | None] | None: """Determines whether an update should be passed to this handler's :attr:`callback`. Args: @@ -159,6 +199,9 @@ def check_update( ): return None + if not self._check_correct_args(args): + return None + filter_result = self.filters.check_update(update) if filter_result: return args, filter_result @@ -168,9 +211,9 @@ def check_update( def collect_additional_context( self, context: CCT, - update: Update, # skipcq: BAN-B301 - application: "Application[Any, CCT, Any, Any, Any, Any]", # skipcq: BAN-B301 - check_result: Optional[Union[bool, Tuple[List[str], Optional[bool]]]], + update: Update, # noqa: ARG002 + application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 + check_result: bool | tuple[list[str], bool] | None, ) -> None: """Add text after the command to :attr:`CallbackContext.args` as list, split on single whitespaces and add output of data filters to :attr:`CallbackContext` as well. diff --git a/telegram/ext/_conversationhandler.py b/src/telegram/ext/_handlers/conversationhandler.py similarity index 85% rename from telegram/ext/_conversationhandler.py rename to src/telegram/ext/_handlers/conversationhandler.py index a7ee91436a8..044e957aa41 100644 --- a/telegram/ext/_conversationhandler.py +++ b/src/telegram/ext/_handlers/conversationhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,44 +17,33 @@ # 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 the ConversationHandler.""" + import asyncio -import datetime +import datetime as dtm from dataclasses import dataclass -from typing import ( - TYPE_CHECKING, - Any, - ClassVar, - Dict, - Generic, - List, - NoReturn, - Optional, - Set, - Tuple, - Union, - cast, -) +from typing import TYPE_CHECKING, Any, Final, Generic, NoReturn, cast from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE, DefaultValue from telegram._utils.logging import get_logger +from telegram._utils.repr import build_repr_with_selected_attrs from telegram._utils.types import DVType from telegram._utils.warnings import warn from telegram.ext._application import ApplicationHandlerStop -from telegram.ext._callbackqueryhandler import CallbackQueryHandler -from telegram.ext._choseninlineresulthandler import ChosenInlineResultHandler from telegram.ext._extbot import ExtBot -from telegram.ext._handler import BaseHandler -from telegram.ext._inlinequeryhandler import InlineQueryHandler -from telegram.ext._stringcommandhandler import StringCommandHandler -from telegram.ext._stringregexhandler import StringRegexHandler -from telegram.ext._typehandler import TypeHandler +from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._handlers.callbackqueryhandler import CallbackQueryHandler +from telegram.ext._handlers.choseninlineresulthandler import ChosenInlineResultHandler +from telegram.ext._handlers.inlinequeryhandler import InlineQueryHandler +from telegram.ext._handlers.stringcommandhandler import StringCommandHandler +from telegram.ext._handlers.stringregexhandler import StringRegexHandler +from telegram.ext._handlers.typehandler import TypeHandler from telegram.ext._utils.trackingdict import TrackingDict from telegram.ext._utils.types import CCT, ConversationDict, ConversationKey if TYPE_CHECKING: from telegram.ext import Application, Job, JobQueue -_CheckUpdateType = Tuple[object, ConversationKey, BaseHandler[Update, CCT], object] +_CheckUpdateType = tuple[object, ConversationKey, BaseHandler[Update, CCT, object], object] _LOGGER = get_logger(__name__, class_name="ConversationHandler") @@ -65,7 +54,7 @@ class _ConversationTimeoutContext(Generic[CCT]): :paramref:`JobQueue.run_once.data` parameter. See :meth:`_trigger_timeout`. """ - __slots__ = ("conversation_key", "update", "application", "callback_context") + __slots__ = ("application", "callback_context", "conversation_key", "update") conversation_key: ConversationKey update: Update @@ -80,7 +69,7 @@ class PendingState: It's still hidden from users, since this module itself is private. """ - __slots__ = ("task", "old_state") + __slots__ = ("old_state", "task") task: asyncio.Task old_state: object @@ -118,7 +107,7 @@ def resolve(self) -> object: return res -class ConversationHandler(BaseHandler[Update, CCT]): +class ConversationHandler(BaseHandler[Update, CCT, object]): """ A handler to hold a conversation with a single or multiple users through Telegram updates by managing three collections of other handlers. @@ -191,16 +180,16 @@ class ConversationHandler(BaseHandler[Update, CCT]): * :any:`Persistent Conversation Bot ` Args: - entry_points (List[:class:`telegram.ext.BaseHandler`]): A list of :obj:`BaseHandler` + entry_points (list[:class:`telegram.ext.BaseHandler`]): A list of :obj:`BaseHandler` objects that can trigger the start of the conversation. The first handler whose :meth:`check_update` method returns :obj:`True` will be used. If all return :obj:`False`, the update is not handled. - states (Dict[:obj:`object`, List[:class:`telegram.ext.BaseHandler`]]): A :obj:`dict` that + states (dict[:obj:`object`, list[:class:`telegram.ext.BaseHandler`]]): A :obj:`dict` that defines the different states of conversation a user can be in and one or more associated :obj:`BaseHandler` objects that should be used in that state. The first handler whose :meth:`check_update` method returns :obj:`True` will be used. - fallbacks (List[:class:`telegram.ext.BaseHandler`]): A list of handlers that might be used + fallbacks (list[:class:`telegram.ext.BaseHandler`]): A list of handlers that might be used if the user is in a conversation, but every handler for their current state returned :obj:`False` on :meth:`check_update`. The first handler which :meth:`check_update` method returns :obj:`True` will be used. If all return :obj:`False`, the update is not @@ -237,7 +226,7 @@ class ConversationHandler(BaseHandler[Update, CCT]): .. versionchanged:: 20.0 Was previously named as ``persistence``. - map_to_parent (Dict[:obj:`object`, :obj:`object`], optional): A :obj:`dict` that can be + map_to_parent (dict[:obj:`object`, :obj:`object`], optional): A :obj:`dict` that can be used to instruct a child conversation handler to transition into a mapped state on its parent conversation handler in place of a specified nested state. block (:obj:`bool`, optional): Pass :obj:`False` or :obj:`True` to set a default value for @@ -283,34 +272,34 @@ class ConversationHandler(BaseHandler[Update, CCT]): "timeout_jobs", ) - END: ClassVar[int] = -1 + END: Final[int] = -1 """:obj:`int`: Used as a constant to return when a conversation is ended.""" - TIMEOUT: ClassVar[int] = -2 + TIMEOUT: Final[int] = -2 """:obj:`int`: Used as a constant to handle state when a conversation is timed out (exceeded :attr:`conversation_timeout`). """ - WAITING: ClassVar[int] = -3 + WAITING: Final[int] = -3 """:obj:`int`: Used as a constant to handle state when a conversation is still waiting on the previous :attr:`block=False ` handler to finish.""" # pylint: disable=super-init-not-called def __init__( - self, - entry_points: List[BaseHandler[Update, CCT]], - states: Dict[object, List[BaseHandler[Update, CCT]]], - fallbacks: List[BaseHandler[Update, CCT]], + self: "ConversationHandler[CCT]", + entry_points: list[BaseHandler[Update, CCT, object]], + states: dict[object, list[BaseHandler[Update, CCT, object]]], + fallbacks: list[BaseHandler[Update, CCT, object]], allow_reentry: bool = False, per_chat: bool = True, per_user: bool = True, per_message: bool = False, - conversation_timeout: Optional[Union[float, datetime.timedelta]] = None, - name: Optional[str] = None, + conversation_timeout: float | dtm.timedelta | None = None, + name: str | None = None, persistent: bool = False, - map_to_parent: Optional[Dict[object, object]] = None, + map_to_parent: dict[object, object] | None = None, block: DVType[bool] = DEFAULT_TRUE, ): # these imports need to be here because of circular import error otherwise - from telegram.ext import ( # pylint: disable=import-outside-toplevel + from telegram.ext import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 PollAnswerHandler, PollHandler, PreCheckoutQueryHandler, @@ -323,26 +312,24 @@ def __init__( # Store the actual setting in a protected variable instead self._block: DVType[bool] = block - self._entry_points: List[BaseHandler[Update, CCT]] = entry_points - self._states: Dict[object, List[BaseHandler[Update, CCT]]] = states - self._fallbacks: List[BaseHandler[Update, CCT]] = fallbacks + self._entry_points: list[BaseHandler[Update, CCT, object]] = entry_points + self._states: dict[object, list[BaseHandler[Update, CCT, object]]] = states + self._fallbacks: list[BaseHandler[Update, CCT, object]] = fallbacks self._allow_reentry: bool = allow_reentry self._per_user: bool = per_user self._per_chat: bool = per_chat self._per_message: bool = per_message - self._conversation_timeout: Optional[ - Union[float, datetime.timedelta] - ] = conversation_timeout - self._name: Optional[str] = name - self._map_to_parent: Optional[Dict[object, object]] = map_to_parent + self._conversation_timeout: float | dtm.timedelta | None = conversation_timeout + self._name: str | None = name + self._map_to_parent: dict[object, object] | None = map_to_parent # if conversation_timeout is used, this dict is used to schedule a job which runs when the # conv has timed out. - self.timeout_jobs: Dict[ConversationKey, "Job[Any]"] = {} + self.timeout_jobs: dict[ConversationKey, Job[Any]] = {} self._timeout_jobs_lock = asyncio.Lock() self._conversations: ConversationDict = {} - self._child_conversations: Set["ConversationHandler"] = set() + self._child_conversations: set[ConversationHandler] = set() if persistent and not self.name: raise ValueError("Conversations can't be persistent when handler is unnamed.") @@ -358,7 +345,7 @@ def __init__( stacklevel=2, ) - all_handlers: List[BaseHandler[Update, CCT]] = [] + all_handlers: list[BaseHandler[Update, CCT, object]] = [] all_handlers.extend(entry_points) all_handlers.extend(fallbacks) @@ -379,7 +366,7 @@ def __init__( # this loop is going to warn the user about handlers which can work unexpectedly # in conversations for handler in all_handlers: - if isinstance(handler, (StringCommandHandler, StringRegexHandler)): + if isinstance(handler, StringCommandHandler | StringRegexHandler): warn( "The `ConversationHandler` only handles updates of type `telegram.Update`. " f"{handler.__class__.__name__} handles updates of type `str`.", @@ -402,18 +389,16 @@ def __init__( elif self.per_chat and ( isinstance( handler, - ( - ShippingQueryHandler, - InlineQueryHandler, - ChosenInlineResultHandler, - PreCheckoutQueryHandler, - PollAnswerHandler, - ), + ShippingQueryHandler + | InlineQueryHandler + | ChosenInlineResultHandler + | PreCheckoutQueryHandler + | PollAnswerHandler, ) ): warn( f"Updates handled by {handler.__class__.__name__} only have information about " - f"the user, so this handler won't ever be triggered if `per_chat=True`." + "the user, so this handler won't ever be triggered if `per_chat=True`." f"{per_faq_link}", stacklevel=2, ) @@ -440,41 +425,65 @@ def __init__( stacklevel=2, ) + def __repr__(self) -> str: + """Give a string representation of the ConversationHandler in the form + ``ConversationHandler[name=..., states={...}]``. + + If there are more than 3 states, only the first 3 states are listed. + + As this class doesn't implement :meth:`object.__str__`, the default implementation + will be used, which is equivalent to :meth:`__repr__`. + + Returns: + :obj:`str` + """ + truncation_threshold = 3 + states = dict(list(self.states.items())[:truncation_threshold]) + states_string = str(states) + if len(self.states) > truncation_threshold: + states_string = states_string[:-1] + ", ...}" + + return build_repr_with_selected_attrs( + self, + name=self.name, + states=states_string, + ) + @property - def entry_points(self) -> List[BaseHandler[Update, CCT]]: - """List[:class:`telegram.ext.BaseHandler`]: A list of :obj:`BaseHandler` objects that can + def entry_points(self) -> list[BaseHandler[Update, CCT, object]]: + """list[:class:`telegram.ext.BaseHandler`]: A list of :obj:`BaseHandler` objects that can trigger the start of the conversation. """ return self._entry_points @entry_points.setter - def entry_points(self, value: object) -> NoReturn: + def entry_points(self, _: object) -> NoReturn: raise AttributeError( "You can not assign a new value to entry_points after initialization." ) @property - def states(self) -> Dict[object, List[BaseHandler[Update, CCT]]]: - """Dict[:obj:`object`, List[:class:`telegram.ext.BaseHandler`]]: A :obj:`dict` that + def states(self) -> dict[object, list[BaseHandler[Update, CCT, object]]]: + """dict[:obj:`object`, list[:class:`telegram.ext.BaseHandler`]]: A :obj:`dict` that defines the different states of conversation a user can be in and one or more associated :obj:`BaseHandler` objects that should be used in that state. """ return self._states @states.setter - def states(self, value: object) -> NoReturn: + def states(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to states after initialization.") @property - def fallbacks(self) -> List[BaseHandler[Update, CCT]]: - """List[:class:`telegram.ext.BaseHandler`]: A list of handlers that might be used if + def fallbacks(self) -> list[BaseHandler[Update, CCT, object]]: + """list[:class:`telegram.ext.BaseHandler`]: A list of handlers that might be used if the user is in a conversation, but every handler for their current state returned :obj:`False` on :meth:`check_update`. """ return self._fallbacks @fallbacks.setter - def fallbacks(self, value: object) -> NoReturn: + def fallbacks(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to fallbacks after initialization.") @property @@ -483,7 +492,7 @@ def allow_reentry(self) -> bool: return self._allow_reentry @allow_reentry.setter - def allow_reentry(self, value: object) -> NoReturn: + def allow_reentry(self, _: object) -> NoReturn: raise AttributeError( "You can not assign a new value to allow_reentry after initialization." ) @@ -494,7 +503,7 @@ def per_user(self) -> bool: return self._per_user @per_user.setter - def per_user(self, value: object) -> NoReturn: + def per_user(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to per_user after initialization.") @property @@ -503,7 +512,7 @@ def per_chat(self) -> bool: return self._per_chat @per_chat.setter - def per_chat(self, value: object) -> NoReturn: + def per_chat(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to per_chat after initialization.") @property @@ -512,13 +521,13 @@ def per_message(self) -> bool: return self._per_message @per_message.setter - def per_message(self, value: object) -> NoReturn: + def per_message(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to per_message after initialization.") @property def conversation_timeout( self, - ) -> Optional[Union[float, datetime.timedelta]]: + ) -> float | dtm.timedelta | None: """:obj:`float` | :obj:`datetime.timedelta`: Optional. When this handler is inactive more than this timeout (in seconds), it will be automatically ended. @@ -526,18 +535,18 @@ def conversation_timeout( return self._conversation_timeout @conversation_timeout.setter - def conversation_timeout(self, value: object) -> NoReturn: + def conversation_timeout(self, _: object) -> NoReturn: raise AttributeError( "You can not assign a new value to conversation_timeout after initialization." ) @property - def name(self) -> Optional[str]: + def name(self) -> str | None: """:obj:`str`: Optional. The name for this :class:`ConversationHandler`.""" return self._name @name.setter - def name(self, value: object) -> NoReturn: + def name(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to name after initialization.") @property @@ -549,26 +558,26 @@ def persistent(self) -> bool: return self._persistent @persistent.setter - def persistent(self, value: object) -> NoReturn: + def persistent(self, _: object) -> NoReturn: raise AttributeError("You can not assign a new value to persistent after initialization.") @property - def map_to_parent(self) -> Optional[Dict[object, object]]: - """Dict[:obj:`object`, :obj:`object`]: Optional. A :obj:`dict` that can be + def map_to_parent(self) -> dict[object, object] | None: + """dict[:obj:`object`, :obj:`object`]: Optional. A :obj:`dict` that can be used to instruct a nested :class:`ConversationHandler` to transition into a mapped state on its parent :class:`ConversationHandler` in place of a specified nested state. """ return self._map_to_parent @map_to_parent.setter - def map_to_parent(self, value: object) -> NoReturn: + def map_to_parent(self, _: object) -> NoReturn: raise AttributeError( "You can not assign a new value to map_to_parent after initialization." ) async def _initialize_persistence( self, application: "Application" - ) -> Dict[str, TrackingDict[ConversationKey, object]]: + ) -> dict[str, TrackingDict[ConversationKey, object]]: """Initializes the persistence for this handler and its child conversations. While this method is marked as protected, we expect it to be called by the Application/parent conversations. It's just protected to hide it from users. @@ -589,16 +598,23 @@ async def _initialize_persistence( current_conversations = self._conversations self._conversations = cast( - TrackingDict[ConversationKey, object], + "TrackingDict[ConversationKey, object]", TrackingDict(), ) # In the conversation already processed updates self._conversations.update(current_conversations) # above might be partly overridden but that's okay since we warn about that in # add_handler - self._conversations.update_no_track( - await application.persistence.get_conversations(self.name) - ) + stored_data = await application.persistence.get_conversations(self.name) + self._conversations.update_no_track(stored_data) + + # Since CH.END is stored as normal state, we need to properly parse it here in order to + # actually end the conversation, i.e. delete the key from the _conversations dict + # This also makes sure that these entries are deleted from the persisted data on the next + # run of Application.update_persistence + for key, state in stored_data.items(): + if state == self.END: + self._update_state(new_state=self.END, key=key) out = {self.name: self._conversations} @@ -616,7 +632,7 @@ def _get_key(self, update: Update) -> ConversationKey: chat = update.effective_chat user = update.effective_user - key: List[Union[int, str]] = [] + key: list[int | str] = [] if self.per_chat: if chat is None: @@ -687,7 +703,7 @@ def _schedule_job( _LOGGER.exception("Failed to schedule timeout.", exc_info=exc) # pylint: disable=too-many-return-statements - def check_update(self, update: object) -> Optional[_CheckUpdateType[CCT]]: + def check_update(self, update: object) -> _CheckUpdateType[CCT] | None: """ Determines whether an update should be handled by this conversation handler, and if so in which state the conversation currently is. @@ -715,7 +731,7 @@ def check_update(self, update: object) -> Optional[_CheckUpdateType[CCT]]: key = self._get_key(update) state = self._conversations.get(key) - check: Optional[object] = None + check: object | None = None # Resolve futures if isinstance(state, PendingState): @@ -743,7 +759,7 @@ def check_update(self, update: object) -> Optional[_CheckUpdateType[CCT]]: _LOGGER.debug("Selecting conversation %s with state %s", str(key), str(state)) - handler: Optional[BaseHandler] = None + handler: BaseHandler | None = None # Search entry points for a match if state is None or self.allow_reentry: @@ -784,7 +800,7 @@ async def handle_update( # type: ignore[override] application: "Application[Any, CCT, Any, Any, Any, Any]", check_result: _CheckUpdateType[CCT], context: CCT, - ) -> Optional[object]: + ) -> object | None: """Send the update to the callback for the current state and BaseHandler Args: @@ -831,6 +847,7 @@ async def handle_update( # type: ignore[override] update, application, handler_check_result, context ), update=update, + name=f"ConversationHandler:{update.update_id}:handle_update:non_blocking_cb", ) except ApplicationHandlerStop as exception: new_state = exception.state @@ -856,6 +873,7 @@ async def handle_update( # type: ignore[override] new_state, application, update, context, conversation_key ), update=update, + name=f"ConversationHandler:{update.update_id}:handle_update:timeout_job", ) else: self._schedule_job(new_state, application, update, context, conversation_key) @@ -877,7 +895,7 @@ async def handle_update( # type: ignore[override] return None def _update_state( - self, new_state: object, key: ConversationKey, handler: Optional[BaseHandler] = None + self, new_state: object, key: ConversationKey, handler: BaseHandler | None = None ) -> None: if new_state == self.END: if key in self._conversations: @@ -905,7 +923,7 @@ async def _trigger_timeout(self, context: CCT) -> None: :obj:`True` is handled. """ job = cast("Job", context.job) - ctxt = cast(_ConversationTimeoutContext, job.data) + ctxt = cast("_ConversationTimeoutContext", job.data) _LOGGER.debug( "Conversation timeout was triggered for conversation %s!", ctxt.conversation_key diff --git a/telegram/ext/_inlinequeryhandler.py b/src/telegram/ext/_handlers/inlinequeryhandler.py similarity index 80% rename from telegram/ext/_inlinequeryhandler.py rename to src/telegram/ext/_handlers/inlinequeryhandler.py index 743cbd555dd..de04b25b431 100644 --- a/telegram/ext/_inlinequeryhandler.py +++ b/src/telegram/ext/_handlers/inlinequeryhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,13 +17,15 @@ # 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 the InlineQueryHandler class.""" + import re -from typing import TYPE_CHECKING, Any, List, Match, Optional, Pattern, TypeVar, Union, cast +from re import Match, Pattern +from typing import TYPE_CHECKING, Any, TypeVar, cast from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.types import DVType -from telegram.ext._handler import BaseHandler +from telegram.ext._handlers.basehandler import BaseHandler from telegram.ext._utils.types import CCT, HandlerCallback if TYPE_CHECKING: @@ -32,7 +34,7 @@ RT = TypeVar("RT") -class InlineQueryHandler(BaseHandler[Update, CCT]): +class InlineQueryHandler(BaseHandler[Update, CCT, RT]): """ BaseHandler class to handle Telegram updates that contain a :attr:`telegram.Update.inline_query`. @@ -67,7 +69,7 @@ async def callback(update: Update, context: CallbackContext) :meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`. .. seealso:: :wiki:`Concurrency` - chat_types (List[:obj:`str`], optional): List of allowed chat types. If passed, will only + chat_types (list[:obj:`str`], optional): List of allowed chat types. If passed, will only handle inline queries with the appropriate :attr:`telegram.InlineQuery.chat_type`. .. versionadded:: 13.5 @@ -75,7 +77,7 @@ async def callback(update: Update, context: CallbackContext) callback (:term:`coroutine function`): The callback function for this handler. pattern (:obj:`str` | :func:`re.Pattern `): Optional. Regex pattern to test :attr:`telegram.InlineQuery.query` against. - chat_types (List[:obj:`str`]): Optional. List of allowed chat types. + chat_types (list[:obj:`str`]): Optional. List of allowed chat types. .. versionadded:: 13.5 block (:obj:`bool`): Determines whether the return value of the callback should be @@ -84,24 +86,24 @@ async def callback(update: Update, context: CallbackContext) """ - __slots__ = ("pattern", "chat_types") + __slots__ = ("chat_types", "pattern") def __init__( - self, + self: "InlineQueryHandler[CCT, RT]", callback: HandlerCallback[Update, CCT, RT], - pattern: Optional[Union[str, Pattern[str]]] = None, + pattern: str | Pattern[str] | None = None, block: DVType[bool] = DEFAULT_TRUE, - chat_types: Optional[List[str]] = None, + chat_types: list[str] | None = None, ): super().__init__(callback, block=block) if isinstance(pattern, str): pattern = re.compile(pattern) - self.pattern: Optional[Union[str, Pattern[str]]] = pattern - self.chat_types: Optional[List[str]] = chat_types + self.pattern: str | Pattern[str] | None = pattern + self.chat_types: list[str] | None = chat_types - def check_update(self, update: object) -> Optional[Union[bool, Match[str]]]: + def check_update(self, update: object) -> bool | Match[str] | None: """ Determines whether an update should be passed to this handler's :attr:`callback`. @@ -117,25 +119,22 @@ def check_update(self, update: object) -> Optional[Union[bool, Match[str]]]: update.inline_query.chat_type not in self.chat_types ): return False - if self.pattern: - if update.inline_query.query: - match = re.match(self.pattern, update.inline_query.query) - if match: - return match - else: + if self.pattern and (match := re.match(self.pattern, update.inline_query.query)): + return match + if not self.pattern: return True return None def collect_additional_context( self, context: CCT, - update: Update, # skipcq: BAN-B301 - application: "Application[Any, CCT, Any, Any, Any, Any]", # skipcq: BAN-B301 - check_result: Optional[Union[bool, Match[str]]], + update: Update, # noqa: ARG002 + application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 + check_result: bool | Match[str] | None, ) -> None: """Add the result of ``re.match(pattern, update.inline_query.query)`` to :attr:`CallbackContext.matches` as list with one element. """ if self.pattern: - check_result = cast(Match, check_result) + check_result = cast("Match", check_result) context.matches = [check_result] diff --git a/telegram/ext/_messagehandler.py b/src/telegram/ext/_handlers/messagehandler.py similarity index 87% rename from telegram/ext/_messagehandler.py rename to src/telegram/ext/_handlers/messagehandler.py index eff8b3c77f7..f9bb840c098 100644 --- a/telegram/ext/_messagehandler.py +++ b/src/telegram/ext/_handlers/messagehandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,13 +17,14 @@ # 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 the MessageHandler class.""" -from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypeVar, Union + +from typing import TYPE_CHECKING, Any, TypeVar from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.types import DVType from telegram.ext import filters as filters_module -from telegram.ext._handler import BaseHandler +from telegram.ext._handlers.basehandler import BaseHandler from telegram.ext._utils.types import CCT, HandlerCallback if TYPE_CHECKING: @@ -32,8 +33,8 @@ RT = TypeVar("RT") -class MessageHandler(BaseHandler[Update, CCT]): - """BaseHandler class to handle Telegram messages. They might contain text, media or status +class MessageHandler(BaseHandler[Update, CCT, RT]): + """Handler class to handle Telegram messages. They might contain text, media or status updates. Warning: @@ -75,8 +76,8 @@ async def callback(update: Update, context: CallbackContext) __slots__ = ("filters",) def __init__( - self, - filters: filters_module.BaseFilter, + self: "MessageHandler[CCT, RT]", + filters: filters_module.BaseFilter | None, callback: HandlerCallback[Update, CCT, RT], block: DVType[bool] = DEFAULT_TRUE, ): @@ -85,7 +86,7 @@ def __init__( filters if filters is not None else filters_module.ALL ) - def check_update(self, update: object) -> Optional[Union[bool, Dict[str, List[Any]]]]: + def check_update(self, update: object) -> bool | dict[str, list[Any]] | None: """Determines whether an update should be passed to this handler's :attr:`callback`. Args: @@ -102,9 +103,9 @@ def check_update(self, update: object) -> Optional[Union[bool, Dict[str, List[An def collect_additional_context( self, context: CCT, - update: Update, # skipcq: BAN-B301 - application: "Application[Any, CCT, Any, Any, Any, Any]", # skipcq: BAN-B301 - check_result: Optional[Union[bool, Dict[str, object]]], + update: Update, # noqa: ARG002 + application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 + check_result: bool | dict[str, object] | None, ) -> None: """Adds possible output of data filters to the :class:`CallbackContext`.""" if isinstance(check_result, dict): diff --git a/src/telegram/ext/_handlers/messagereactionhandler.py b/src/telegram/ext/_handlers/messagereactionhandler.py new file mode 100644 index 00000000000..2b638f1d761 --- /dev/null +++ b/src/telegram/ext/_handlers/messagereactionhandler.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 the MessageReactionHandler class.""" + +from typing import Final + +from telegram import Update +from telegram._utils.defaultvalue import DEFAULT_TRUE +from telegram._utils.types import RT, SCT, DVType +from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._utils._update_parsing import parse_chat_id, parse_username +from telegram.ext._utils.types import CCT, HandlerCallback + + +class MessageReactionHandler(BaseHandler[Update, CCT, RT]): + """Handler class to handle Telegram updates that contain a message reaction. + + Note: + The following rules apply to both ``username`` and the ``chat_id`` param groups, + respectively: + + * If none of them are passed, the handler does not filter the update for that specific + attribute. + * If a chat ID **or** a username is passed, the updates will be filtered with that + specific attribute. + * If a chat ID **and** a username are passed, an update containing **any** of them will be + filtered. + * :attr:`telegram.MessageReactionUpdated.actor_chat` is *not* considered for + :paramref:`user_id` and :paramref:`user_username` filtering. + + Warning: + When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom + attributes to :class:`telegram.ext.CallbackContext`. See its docs for more info. + + .. versionadded:: 20.8 + + Args: + callback (:term:`coroutine function`): The callback function for this handler. Will be + called when :meth:`check_update` has determined that an update should be processed by + this handler. Callback signature:: + + async def callback(update: Update, context: CallbackContext) + + The return value of the callback is usually ignored except for the special case of + :class:`telegram.ext.ConversationHandler`. + message_reaction_types (:obj:`int`, optional): Pass one of + :attr:`MESSAGE_REACTION_UPDATED`, :attr:`MESSAGE_REACTION_COUNT_UPDATED` or + :attr:`MESSAGE_REACTION` to specify if this handler should handle only updates with + :attr:`telegram.Update.message_reaction`, + :attr:`telegram.Update.message_reaction_count` or both. Defaults to + :attr:`MESSAGE_REACTION`. + chat_id (:obj:`int` | Collection[:obj:`int`], optional): Filters reactions to allow + only those which happen in the specified chat ID(s). + chat_username (:obj:`str` | Collection[:obj:`str`], optional): Filters reactions to allow + only those which happen in the specified username(s). + user_id (:obj:`int` | Collection[:obj:`int`], optional): Filters reactions to allow + only those which are set by the specified chat ID(s) (this can be the chat itself in + the case of anonymous users, see the + :paramref:`telegram.MessageReactionUpdated.actor_chat`). + user_username (:obj:`str` | Collection[:obj:`str`], optional): Filters reactions to allow + only those which are set by the specified username(s) (this can be the chat itself in + the case of anonymous users, see the + :paramref:`telegram.MessageReactionUpdated.actor_chat`). + block (:obj:`bool`, optional): Determines whether the return value of the callback should + be awaited before processing the next handler in + :meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`. + + .. seealso:: :wiki:`Concurrency` + + Attributes: + callback (:term:`coroutine function`): The callback function for this handler. + message_reaction_types (:obj:`int`): Optional. Specifies if this handler should handle only + updates with :attr:`telegram.Update.message_reaction`, + :attr:`telegram.Update.message_reaction_count` or both. + block (:obj:`bool`): Determines whether the callback will run in a blocking way. + + """ + + __slots__ = ( + "_chat_ids", + "_chat_usernames", + "_user_ids", + "_user_usernames", + "message_reaction_types", + ) + + MESSAGE_REACTION_UPDATED: Final[int] = -1 + """:obj:`int`: Used as a constant to handle only :attr:`telegram.Update.message_reaction`.""" + MESSAGE_REACTION_COUNT_UPDATED: Final[int] = 0 + """:obj:`int`: Used as a constant to handle only + :attr:`telegram.Update.message_reaction_count`.""" + MESSAGE_REACTION: Final[int] = 1 + """:obj:`int`: Used as a constant to handle both :attr:`telegram.Update.message_reaction` + and :attr:`telegram.Update.message_reaction_count`.""" + + def __init__( + self: "MessageReactionHandler[CCT, RT]", + callback: HandlerCallback[Update, CCT, RT], + chat_id: SCT[int] | None = None, + chat_username: SCT[str] | None = None, + user_id: SCT[int] | None = None, + user_username: SCT[str] | None = None, + message_reaction_types: int = MESSAGE_REACTION, + block: DVType[bool] = DEFAULT_TRUE, + ): + super().__init__(callback, block=block) + self.message_reaction_types: int = message_reaction_types + + self._chat_ids = parse_chat_id(chat_id) + self._chat_usernames = parse_username(chat_username) + if (user_id or user_username) and message_reaction_types in ( + self.MESSAGE_REACTION, + self.MESSAGE_REACTION_COUNT_UPDATED, + ): + raise ValueError( + "You can not filter for users and include anonymous reactions. Set " + "`message_reaction_types` to MESSAGE_REACTION_UPDATED." + ) + self._user_ids = parse_chat_id(user_id) + self._user_usernames = parse_username(user_username) + + def check_update(self, update: object) -> bool: + """Determines whether an update should be passed to this handler's :attr:`callback`. + + Args: + update (:class:`telegram.Update` | :obj:`object`): Incoming update. + + Returns: + :obj:`bool` + + """ + if not isinstance(update, Update): + return False + + if not (update.message_reaction or update.message_reaction_count): + return False + + if ( + self.message_reaction_types == self.MESSAGE_REACTION_UPDATED + and update.message_reaction_count + ): + return False + + if ( + self.message_reaction_types == self.MESSAGE_REACTION_COUNT_UPDATED + and update.message_reaction + ): + return False + + if not any((self._chat_ids, self._chat_usernames, self._user_ids, self._user_usernames)): + return True + + # Extract chat and user IDs and usernames from the update for comparison + chat_id = chat.id if (chat := update.effective_chat) else None + chat_username = chat.username if chat else None + user_id = user.id if (user := update.effective_user) else None + user_username = user.username if user else None + + return ( + bool(self._chat_ids and (chat_id in self._chat_ids)) + or bool(self._chat_usernames and (chat_username in self._chat_usernames)) + or bool(self._user_ids and (user_id in self._user_ids)) + or bool(self._user_usernames and (user_username in self._user_usernames)) + ) diff --git a/src/telegram/ext/_handlers/paidmediapurchasedhandler.py b/src/telegram/ext/_handlers/paidmediapurchasedhandler.py new file mode 100644 index 00000000000..484e05ccaa5 --- /dev/null +++ b/src/telegram/ext/_handlers/paidmediapurchasedhandler.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 the PaidMediaPurchased class.""" + +from telegram import Update +from telegram._utils.defaultvalue import DEFAULT_TRUE +from telegram._utils.types import SCT, DVType +from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._utils._update_parsing import parse_chat_id, parse_username +from telegram.ext._utils.types import CCT, RT, HandlerCallback + + +class PaidMediaPurchasedHandler(BaseHandler[Update, CCT, RT]): + """Handler class to handle Telegram + :attr:`purchased paid media `. + + .. versionadded:: 21.6 + + Args: + callback (:term:`coroutine function`): The callback function for this handler. Will be + called when :meth:`check_update` has determined that an update should be processed by + this handler. Callback signature:: + + async def callback(update: Update, context: CallbackContext) + user_id (:obj:`int` | Collection[:obj:`int`], optional): Filters requests to allow only + those which are from the specified user ID(s). + + username (:obj:`str` | Collection[:obj:`str`], optional): Filters requests to allow only + those which are from the specified username(s). + + block (:obj:`bool`, optional): Determines whether the return value of the callback should + be awaited before processing the next handler in + :meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`. + + .. seealso:: :wiki:`Concurrency` + Attributes: + callback (:term:`coroutine function`): The callback function for this handler. + block (:obj:`bool`): Determines whether the return value of the callback should be + awaited before processing the next handler in + :meth:`telegram.ext.Application.process_update`. + """ + + __slots__ = ( + "_user_ids", + "_usernames", + ) + + def __init__( + self: "PaidMediaPurchasedHandler[CCT, RT]", + callback: HandlerCallback[Update, CCT, RT], + user_id: SCT[int] | None = None, + username: SCT[str] | None = None, + block: DVType[bool] = DEFAULT_TRUE, + ): + super().__init__(callback, block=block) + + self._user_ids = parse_chat_id(user_id) + self._usernames = parse_username(username) + + def check_update(self, update: object) -> bool: + """Determines whether an update should be passed to this handler's :attr:`callback`. + + Args: + update (:class:`telegram.Update` | :obj:`object`): Incoming update. + + Returns: + :obj:`bool` + + """ + if not isinstance(update, Update) or not update.purchased_paid_media: + return False + + if not self._user_ids and not self._usernames: + return True + if update.purchased_paid_media.from_user.id in self._user_ids: + return True + return update.purchased_paid_media.from_user.username in self._usernames diff --git a/telegram/ext/_pollanswerhandler.py b/src/telegram/ext/_handlers/pollanswerhandler.py similarity index 91% rename from telegram/ext/_pollanswerhandler.py rename to src/telegram/ext/_handlers/pollanswerhandler.py index 5b317d53f70..5564b588d40 100644 --- a/telegram/ext/_pollanswerhandler.py +++ b/src/telegram/ext/_handlers/pollanswerhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,14 +18,13 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the PollAnswerHandler class.""" - from telegram import Update -from telegram.ext._handler import BaseHandler -from telegram.ext._utils.types import CCT +from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._utils.types import CCT, RT -class PollAnswerHandler(BaseHandler[Update, CCT]): - """BaseHandler class to handle Telegram updates that contain a +class PollAnswerHandler(BaseHandler[Update, CCT, RT]): + """Handler class to handle Telegram updates that contain a :attr:`poll answer `. Warning: diff --git a/telegram/ext/_pollhandler.py b/src/telegram/ext/_handlers/pollhandler.py similarity index 91% rename from telegram/ext/_pollhandler.py rename to src/telegram/ext/_handlers/pollhandler.py index 66260724b24..a029bb559d1 100644 --- a/telegram/ext/_pollhandler.py +++ b/src/telegram/ext/_handlers/pollhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,14 +18,13 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the PollHandler class.""" - from telegram import Update -from telegram.ext._handler import BaseHandler -from telegram.ext._utils.types import CCT +from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._utils.types import CCT, RT -class PollHandler(BaseHandler[Update, CCT]): - """BaseHandler class to handle Telegram updates that contain a +class PollHandler(BaseHandler[Update, CCT, RT]): + """Handler class to handle Telegram updates that contain a :attr:`poll `. Warning: diff --git a/telegram/ext/_precheckoutqueryhandler.py b/src/telegram/ext/_handlers/precheckoutqueryhandler.py similarity index 59% rename from telegram/ext/_precheckoutqueryhandler.py rename to src/telegram/ext/_handlers/precheckoutqueryhandler.py index 36755db703c..5fb1dab2680 100644 --- a/telegram/ext/_precheckoutqueryhandler.py +++ b/src/telegram/ext/_handlers/precheckoutqueryhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,14 +18,21 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the PreCheckoutQueryHandler class.""" +import re +from re import Pattern +from typing import TypeVar from telegram import Update -from telegram.ext._handler import BaseHandler -from telegram.ext._utils.types import CCT +from telegram._utils.defaultvalue import DEFAULT_TRUE +from telegram._utils.types import DVType +from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._utils.types import CCT, HandlerCallback +RT = TypeVar("RT") -class PreCheckoutQueryHandler(BaseHandler[Update, CCT]): - """BaseHandler class to handle Telegram :attr:`telegram.Update.pre_checkout_query`. + +class PreCheckoutQueryHandler(BaseHandler[Update, CCT, RT]): + """Handler class to handle Telegram :attr:`telegram.Update.pre_checkout_query`. Warning: When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom @@ -48,14 +55,32 @@ async def callback(update: Update, context: CallbackContext) :meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`. .. seealso:: :wiki:`Concurrency` + pattern (:obj:`str` | :func:`re.Pattern `, optional): Optional. Regex pattern + to test :attr:`telegram.PreCheckoutQuery.invoice_payload` against. + + .. versionadded:: 20.8 Attributes: callback (:term:`coroutine function`): The callback function for this handler. block (:obj:`bool`): Determines whether the callback will run in a blocking way.. + pattern (:obj:`str` | :func:`re.Pattern `, optional): Optional. Regex pattern + to test :attr:`telegram.PreCheckoutQuery.invoice_payload` against. + + .. versionadded:: 20.8 """ - __slots__ = () + __slots__ = ("pattern",) + + def __init__( + self: "PreCheckoutQueryHandler[CCT, RT]", + callback: HandlerCallback[Update, CCT, RT], + block: DVType[bool] = DEFAULT_TRUE, + pattern: str | Pattern[str] | None = None, + ): + super().__init__(callback, block=block) + + self.pattern: Pattern[str] | None = re.compile(pattern) if pattern is not None else None def check_update(self, update: object) -> bool: """Determines whether an update should be passed to this handler's :attr:`callback`. @@ -67,4 +92,11 @@ def check_update(self, update: object) -> bool: :obj:`bool` """ - return isinstance(update, Update) and bool(update.pre_checkout_query) + if isinstance(update, Update) and update.pre_checkout_query: + invoice_payload = update.pre_checkout_query.invoice_payload + if self.pattern: + if self.pattern.match(invoice_payload): + return True + else: + return True + return False diff --git a/telegram/ext/_prefixhandler.py b/src/telegram/ext/_handlers/prefixhandler.py similarity index 91% rename from telegram/ext/_prefixhandler.py rename to src/telegram/ext/_handlers/prefixhandler.py index d2abc702f0d..2fce0d39e71 100644 --- a/telegram/ext/_prefixhandler.py +++ b/src/telegram/ext/_handlers/prefixhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,14 +17,15 @@ # 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 the PrefixHandler class.""" + import itertools -from typing import TYPE_CHECKING, Any, Dict, FrozenSet, List, Optional, Tuple, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeVar from telegram import Update from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.types import SCT, DVType from telegram.ext import filters as filters_module -from telegram.ext._handler import BaseHandler +from telegram.ext._handlers.basehandler import BaseHandler from telegram.ext._utils.types import CCT, HandlerCallback if TYPE_CHECKING: @@ -33,8 +34,8 @@ RT = TypeVar("RT") -class PrefixHandler(BaseHandler[Update, CCT]): - """BaseHandler class to handle custom prefix commands. +class PrefixHandler(BaseHandler[Update, CCT, RT]): + """Handler class to handle custom prefix commands. This is an intermediate handler between :class:`MessageHandler` and :class:`CommandHandler`. It supports configurable commands with the same options as :class:`CommandHandler`. It will @@ -108,7 +109,7 @@ async def callback(update: Update, context: CallbackContext) .. seealso:: :wiki:`Concurrency` Attributes: - commands (FrozenSet[:obj:`str`]): The commands that this handler will listen for, i.e. the + commands (frozenset[:obj:`str`]): The commands that this handler will listen for, i.e. the combinations of :paramref:`prefix` and :paramref:`command`. callback (:term:`coroutine function`): The callback function for this handler. filters (:class:`telegram.ext.filters.BaseFilter`): Optional. Only allow updates with these @@ -123,11 +124,11 @@ async def callback(update: Update, context: CallbackContext) __slots__ = ("commands", "filters") def __init__( - self, + self: "PrefixHandler[CCT, RT]", prefix: SCT[str], command: SCT[str], callback: HandlerCallback[Update, CCT, RT], - filters: Optional[filters_module.BaseFilter] = None, + filters: filters_module.BaseFilter | None = None, block: DVType[bool] = DEFAULT_TRUE, ): super().__init__(callback=callback, block=block) @@ -136,7 +137,7 @@ def __init__( commands = {command.lower()} if isinstance(command, str) else {x.lower() for x in command} - self.commands: FrozenSet[str] = frozenset( + self.commands: frozenset[str] = frozenset( p + c for p, c in itertools.product(prefixes, commands) ) self.filters: filters_module.BaseFilter = ( @@ -145,7 +146,7 @@ def __init__( def check_update( self, update: object - ) -> Optional[Union[bool, Tuple[List[str], Optional[Union[bool, Dict[Any, Any]]]]]]: + ) -> bool | tuple[list[str], bool | dict[Any, Any] | None] | None: """Determines whether an update should be passed to this handler's :attr:`callback`. Args: @@ -171,9 +172,9 @@ def check_update( def collect_additional_context( self, context: CCT, - update: Update, # skipcq: BAN-B301 - application: "Application[Any, CCT, Any, Any, Any, Any]", # skipcq: BAN-B301 - check_result: Optional[Union[bool, Tuple[List[str], Optional[bool]]]], + update: Update, # noqa: ARG002 + application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 + check_result: bool | tuple[list[str], bool] | None, ) -> None: """Add text after the command to :attr:`CallbackContext.args` as list, split on single whitespaces and add output of data filters to :attr:`CallbackContext` as well. diff --git a/telegram/ext/_shippingqueryhandler.py b/src/telegram/ext/_handlers/shippingqueryhandler.py similarity index 90% rename from telegram/ext/_shippingqueryhandler.py rename to src/telegram/ext/_handlers/shippingqueryhandler.py index 96c13869e11..c46fad19210 100644 --- a/telegram/ext/_shippingqueryhandler.py +++ b/src/telegram/ext/_handlers/shippingqueryhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,14 +18,13 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the ShippingQueryHandler class.""" - from telegram import Update -from telegram.ext._handler import BaseHandler -from telegram.ext._utils.types import CCT +from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._utils.types import CCT, RT -class ShippingQueryHandler(BaseHandler[Update, CCT]): - """BaseHandler class to handle Telegram :attr:`telegram.Update.shipping_query`. +class ShippingQueryHandler(BaseHandler[Update, CCT, RT]): + """Handler class to handle Telegram :attr:`telegram.Update.shipping_query`. Warning: When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom diff --git a/telegram/ext/_stringcommandhandler.py b/src/telegram/ext/_handlers/stringcommandhandler.py similarity index 86% rename from telegram/ext/_stringcommandhandler.py rename to src/telegram/ext/_handlers/stringcommandhandler.py index 632f3be042e..df8ae3fdfec 100644 --- a/telegram/ext/_stringcommandhandler.py +++ b/src/telegram/ext/_handlers/stringcommandhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,19 +18,19 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the StringCommandHandler class.""" -from typing import TYPE_CHECKING, Any, List, Optional +from typing import TYPE_CHECKING, Any from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.types import DVType -from telegram.ext._handler import BaseHandler +from telegram.ext._handlers.basehandler import BaseHandler from telegram.ext._utils.types import CCT, RT, HandlerCallback if TYPE_CHECKING: from telegram.ext import Application -class StringCommandHandler(BaseHandler[str, CCT]): - """BaseHandler class to handle string commands. Commands are string updates that start with +class StringCommandHandler(BaseHandler[str, CCT, RT]): + """Handler class to handle string commands. Commands are string updates that start with ``/``. The handler will add a :obj:`list` to the :class:`CallbackContext` named :attr:`CallbackContext.args`. It will contain a list of strings, which is the text following the command split on single whitespace characters. @@ -49,7 +49,7 @@ class StringCommandHandler(BaseHandler[str, CCT]): called when :meth:`check_update` has determined that an update should be processed by this handler. Callback signature:: - async def callback(update: Update, context: CallbackContext) + async def callback(update: str, context: CallbackContext) The return value of the callback is usually ignored except for the special case of :class:`telegram.ext.ConversationHandler`. @@ -71,7 +71,7 @@ async def callback(update: Update, context: CallbackContext) __slots__ = ("command",) def __init__( - self, + self: "StringCommandHandler[CCT, RT]", command: str, callback: HandlerCallback[str, CCT, RT], block: DVType[bool] = DEFAULT_TRUE, @@ -79,14 +79,14 @@ def __init__( super().__init__(callback, block=block) self.command: str = command - def check_update(self, update: object) -> Optional[List[str]]: + def check_update(self, update: object) -> list[str] | None: """Determines whether an update should be passed to this handler's :attr:`callback`. Args: update (:obj:`object`): The incoming update. Returns: - List[:obj:`str`]: List containing the text command split on whitespace. + list[:obj:`str`]: List containing the text command split on whitespace. """ if isinstance(update, str) and update.startswith("/"): @@ -98,9 +98,9 @@ def check_update(self, update: object) -> Optional[List[str]]: def collect_additional_context( self, context: CCT, - update: str, # skipcq: BAN-B301 - application: "Application[Any, CCT, Any, Any, Any, Any]", # skipcq: BAN-B301 - check_result: Optional[List[str]], + update: str, # noqa: ARG002 + application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 + check_result: list[str] | None, ) -> None: """Add text after the command to :attr:`CallbackContext.args` as list, split on single whitespaces. diff --git a/telegram/ext/_stringregexhandler.py b/src/telegram/ext/_handlers/stringregexhandler.py similarity index 82% rename from telegram/ext/_stringregexhandler.py rename to src/telegram/ext/_handlers/stringregexhandler.py index f3abc17b492..1d315888e09 100644 --- a/telegram/ext/_stringregexhandler.py +++ b/src/telegram/ext/_handlers/stringregexhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,11 +19,12 @@ """This module contains the StringRegexHandler class.""" import re -from typing import TYPE_CHECKING, Any, Match, Optional, Pattern, TypeVar, Union +from re import Match, Pattern +from typing import TYPE_CHECKING, Any, TypeVar from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.types import DVType -from telegram.ext._handler import BaseHandler +from telegram.ext._handlers.basehandler import BaseHandler from telegram.ext._utils.types import CCT, HandlerCallback if TYPE_CHECKING: @@ -32,8 +33,8 @@ RT = TypeVar("RT") -class StringRegexHandler(BaseHandler[str, CCT]): - """BaseHandler class to handle string updates based on a regex which checks the update content. +class StringRegexHandler(BaseHandler[str, CCT, RT]): + """Handler class to handle string updates based on a regex which checks the update content. Read the documentation of the :mod:`re` module for more information. The :func:`re.match` function is used to determine if an update should be handled by this handler. @@ -52,7 +53,7 @@ class StringRegexHandler(BaseHandler[str, CCT]): called when :meth:`check_update` has determined that an update should be processed by this handler. Callback signature:: - async def callback(update: Update, context: CallbackContext) + async def callback(update: str, context: CallbackContext) The return value of the callback is usually ignored except for the special case of :class:`telegram.ext.ConversationHandler`. @@ -74,8 +75,8 @@ async def callback(update: Update, context: CallbackContext) __slots__ = ("pattern",) def __init__( - self, - pattern: Union[str, Pattern[str]], + self: "StringRegexHandler[CCT, RT]", + pattern: str | Pattern[str], callback: HandlerCallback[str, CCT, RT], block: DVType[bool] = DEFAULT_TRUE, ): @@ -84,9 +85,9 @@ def __init__( if isinstance(pattern, str): pattern = re.compile(pattern) - self.pattern: Union[str, Pattern[str]] = pattern + self.pattern: str | Pattern[str] = pattern - def check_update(self, update: object) -> Optional[Match[str]]: + def check_update(self, update: object) -> Match[str] | None: """Determines whether an update should be passed to this handler's :attr:`callback`. Args: @@ -96,18 +97,16 @@ def check_update(self, update: object) -> Optional[Match[str]]: :obj:`None` | :obj:`re.match` """ - if isinstance(update, str): - match = re.match(self.pattern, update) - if match: - return match + if isinstance(update, str) and (match := re.match(self.pattern, update)): + return match return None def collect_additional_context( self, context: CCT, - update: str, # skipcq: BAN-B301 - application: "Application[Any, CCT, Any, Any, Any, Any]", # skipcq: BAN-B301 - check_result: Optional[Match[str]], + update: str, # noqa: ARG002 + application: "Application[Any, CCT, Any, Any, Any, Any]", # noqa: ARG002 + check_result: Match[str] | None, ) -> None: """Add the result of ``re.match(pattern, update)`` to :attr:`CallbackContext.matches` as list with one element. diff --git a/telegram/ext/_typehandler.py b/src/telegram/ext/_handlers/typehandler.py similarity index 82% rename from telegram/ext/_typehandler.py rename to src/telegram/ext/_handlers/typehandler.py index 477e2f6f53b..5265a94719d 100644 --- a/telegram/ext/_typehandler.py +++ b/src/telegram/ext/_handlers/typehandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,19 +18,22 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains the TypeHandler class.""" -from typing import Optional, Type, TypeVar +from typing import TypeVar from telegram._utils.defaultvalue import DEFAULT_TRUE from telegram._utils.types import DVType -from telegram.ext._handler import BaseHandler +from telegram.ext._handlers.basehandler import BaseHandler from telegram.ext._utils.types import CCT, HandlerCallback RT = TypeVar("RT") UT = TypeVar("UT") +# If this is written directly next to the type variable mypy gets confused with [valid-type]. This +# could be reported to them, but I doubt they would change this since we override a builtin type +GenericUT = type[UT] -class TypeHandler(BaseHandler[UT, CCT]): - """BaseHandler class to handle updates of custom types. +class TypeHandler(BaseHandler[UT, CCT, RT]): + """Handler class to handle updates of custom types. Warning: When setting :paramref:`block` to :obj:`False`, you cannot rely on adding custom @@ -43,7 +46,7 @@ class TypeHandler(BaseHandler[UT, CCT]): called when :meth:`check_update` has determined that an update should be processed by this handler. Callback signature:: - async def callback(update: Update, context: CallbackContext) + async def callback(update: object, context: CallbackContext) The return value of the callback is usually ignored except for the special case of :class:`telegram.ext.ConversationHandler`. @@ -67,18 +70,18 @@ async def callback(update: Update, context: CallbackContext) """ - __slots__ = ("type", "strict") + __slots__ = ("strict", "type") def __init__( - self, - type: Type[UT], # pylint: disable=redefined-builtin + self: "TypeHandler[UT, CCT, RT]", + type: GenericUT[UT], # pylint: disable=redefined-builtin callback: HandlerCallback[UT, CCT, RT], strict: bool = False, block: DVType[bool] = DEFAULT_TRUE, ): super().__init__(callback, block=block) - self.type: Type[UT] = type - self.strict: Optional[bool] = strict + self.type: GenericUT[UT] = type + self.strict: bool | None = strict def check_update(self, update: object) -> bool: """Determines whether an update should be passed to this handler's :attr:`callback`. diff --git a/telegram/ext/_jobqueue.py b/src/telegram/ext/_jobqueue.py similarity index 74% rename from telegram/ext/_jobqueue.py rename to src/telegram/ext/_jobqueue.py index 654eeb9d1f4..7ad54530913 100644 --- a/telegram/ext/_jobqueue.py +++ b/src/telegram/ext/_jobqueue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,31 +17,51 @@ # 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 the classes JobQueue and Job.""" + import asyncio -import datetime +import datetime as dtm +import re import weakref -from typing import TYPE_CHECKING, Any, Generic, Optional, Tuple, Union, cast, overload +from typing import TYPE_CHECKING, Any, Generic, cast, overload try: - import pytz from apscheduler.executors.asyncio import AsyncIOExecutor - from apscheduler.job import Job as APSJob from apscheduler.schedulers.asyncio import AsyncIOScheduler APS_AVAILABLE = True except ImportError: APS_AVAILABLE = False +from telegram._utils.datetime import UTC, localize +from telegram._utils.logging import get_logger +from telegram._utils.repr import build_repr_with_selected_attrs from telegram._utils.types import JSONDict -from telegram._utils.warnings import warn from telegram.ext._extbot import ExtBot from telegram.ext._utils.types import CCT, JobCallback if TYPE_CHECKING: + from collections.abc import Iterable + + if APS_AVAILABLE: + from apscheduler.job import Job as APSJob + from telegram.ext import Application _ALL_DAYS = tuple(range(7)) +_LOGGER = get_logger(__name__, class_name="JobQueue") + + +def _get_callback_name(callback: object) -> str: + """Get the name of a callback function or callable object. + + Args: + callback: A callable object (function, method, or callable class instance) + + Returns: + The name of the callback, using __name__ if available, otherwise the class name. + """ + return getattr(callback, "__name__", None) or callback.__class__.__name__ class JobQueue(Generic[CCT]): @@ -58,77 +78,146 @@ class JobQueue(Generic[CCT]): .. code-block:: bash - pip install python-telegram-bot[job-queue] + pip install "python-telegram-bot[job-queue]" Examples: :any:`Timer Bot ` .. seealso:: :wiki:`Architecture Overview `, - :wiki:`Job Queue ` + :wiki:`Job Queue ` .. versionchanged:: 20.0 To use this class, PTB must be installed via - ``pip install python-telegram-bot[job-queue]``. + ``pip install "python-telegram-bot[job-queue]"``. Attributes: scheduler (:class:`apscheduler.schedulers.asyncio.AsyncIOScheduler`): The scheduler. + Warning: + This scheduler is configured by :meth:`set_application`. Additional configuration + settings can be made by users. However, calling + :meth:`~apscheduler.schedulers.base.BaseScheduler.configure` will delete any + previous configuration settings. Therefore, please make sure to pass the values + returned by :attr:`scheduler_configuration` to the method call in addition to your + custom values. + Alternatively, you can also use methods like + :meth:`~apscheduler.schedulers.base.BaseScheduler.add_jobstore` to avoid using + :meth:`~apscheduler.schedulers.base.BaseScheduler.configure` altogether. + .. versionchanged:: 20.0 Uses :class:`~apscheduler.schedulers.asyncio.AsyncIOScheduler` instead of :class:`~apscheduler.schedulers.background.BackgroundScheduler` """ - __slots__ = ("_application", "scheduler", "_executor") + __slots__ = ("_application", "_executor", "scheduler") _CRON_MAPPING = ("sun", "mon", "tue", "wed", "thu", "fri", "sat") def __init__(self) -> None: if not APS_AVAILABLE: raise RuntimeError( "To use `JobQueue`, PTB must be installed via `pip install " - "python-telegram-bot[job-queue]`." + '"python-telegram-bot[job-queue]"`.' ) - self._application: "Optional[weakref.ReferenceType[Application]]" = None + self._application: weakref.ReferenceType[Application] | None = None self._executor = AsyncIOExecutor() - self.scheduler: AsyncIOScheduler = AsyncIOScheduler( - timezone=pytz.utc, executors={"default": self._executor} + self.scheduler: "AsyncIOScheduler" = AsyncIOScheduler( # noqa: UP037 + **self.scheduler_configuration ) - def _tz_now(self) -> datetime.datetime: - return datetime.datetime.now(self.scheduler.timezone) + def __repr__(self) -> str: + """Give a string representation of the JobQueue in the form ``JobQueue[application=...]``. + + As this class doesn't implement :meth:`object.__str__`, the default implementation + will be used, which is equivalent to :meth:`__repr__`. + + Returns: + :obj:`str` + """ + return build_repr_with_selected_attrs(self, application=self.application) + + @property + def application(self) -> "Application[Any, CCT, Any, Any, Any, JobQueue[CCT]]": + """The application this JobQueue is associated with.""" + if self._application is None: + raise RuntimeError("No application was set for this JobQueue.") + application = self._application() + if application is not None: + return application + raise RuntimeError("The application instance is no longer alive.") + + @property + def scheduler_configuration(self) -> JSONDict: + """Provides configuration values that are used by :class:`JobQueue` for :attr:`scheduler`. + + Tip: + Since calling + :meth:`scheduler.configure() ` + deletes any previous setting, please make sure to pass these values to the method call + in addition to your custom values: + + .. code-block:: python + + scheduler.configure(..., **job_queue.scheduler_configuration) + + Alternatively, you can also use methods like + :meth:`~apscheduler.schedulers.base.BaseScheduler.add_jobstore` to avoid using + :meth:`~apscheduler.schedulers.base.BaseScheduler.configure` altogether. + + .. versionadded:: 20.7 + + Returns: + dict[:obj:`str`, :obj:`object`]: The configuration values as dictionary. + + """ + timezone: dtm.tzinfo = UTC + if ( + self._application + and isinstance(self.application.bot, ExtBot) + and self.application.bot.defaults + ): + timezone = self.application.bot.defaults.tzinfo or UTC + + return { + "timezone": timezone, + "executors": {"default": self._executor}, + } + + def _tz_now(self) -> dtm.datetime: + return dtm.datetime.now(self.scheduler.timezone) @overload - def _parse_time_input(self, time: None, shift_day: bool = False) -> None: - ... + def _parse_time_input(self, time: None, shift_day: bool = False) -> None: ... @overload def _parse_time_input( self, - time: Union[float, int, datetime.timedelta, datetime.datetime, datetime.time], + time: float | dtm.timedelta | dtm.datetime | dtm.time, shift_day: bool = False, - ) -> datetime.datetime: - ... + ) -> dtm.datetime: ... def _parse_time_input( self, - time: Union[float, int, datetime.timedelta, datetime.datetime, datetime.time, None], + time: float | dtm.timedelta | dtm.datetime | dtm.time | None, shift_day: bool = False, - ) -> Optional[datetime.datetime]: + ) -> dtm.datetime | None: if time is None: return None - if isinstance(time, (int, float)): - return self._tz_now() + datetime.timedelta(seconds=time) - if isinstance(time, datetime.timedelta): + if isinstance(time, int | float): + return self._tz_now() + dtm.timedelta(seconds=time) + if isinstance(time, dtm.timedelta): return self._tz_now() + time - if isinstance(time, datetime.time): - date_time = datetime.datetime.combine( - datetime.datetime.now(tz=time.tzinfo or self.scheduler.timezone).date(), time + if isinstance(time, dtm.time): + date_time = dtm.datetime.combine( + dtm.datetime.now(tz=time.tzinfo or self.scheduler.timezone).date(), time ) if date_time.tzinfo is None: - date_time = self.scheduler.timezone.localize(date_time) - if shift_day and date_time <= datetime.datetime.now(pytz.utc): - date_time += datetime.timedelta(days=1) + # dtm.combine uses the tzinfo of `time`, which might be None, so we still have + # to localize it + date_time = localize(date_time, self.scheduler.timezone) + if shift_day and date_time <= dtm.datetime.now(UTC): + date_time += dtm.timedelta(days=1) return date_time return time @@ -142,21 +231,7 @@ def set_application( """ self._application = weakref.ref(application) - if isinstance(application.bot, ExtBot) and application.bot.defaults: - self.scheduler.configure( - timezone=application.bot.defaults.tzinfo or pytz.utc, - executors={"default": self._executor}, - ) - - @property - def application(self) -> "Application[Any, CCT, Any, Any, Any, JobQueue[CCT]]": - """The application this JobQueue is associated with.""" - if self._application is None: - raise RuntimeError("No application was set for this JobQueue.") - application = self._application() - if application is not None: - return application - raise RuntimeError("The application instance is no longer alive.") + self.scheduler.configure(**self.scheduler_configuration) @staticmethod async def job_callback(job_queue: "JobQueue[CCT]", job: "Job[CCT]") -> None: @@ -174,7 +249,7 @@ async def job_callback(job_queue: "JobQueue[CCT]", job: "Job[CCT]") -> None: Hint: This method is effectively a wrapper for :meth:`telegram.ext.Job.run`. - .. versionadded:: NEXT.VERSION + .. versionadded:: 20.4 Args: job_queue (:class:`JobQueue`): The job queue that created the job. @@ -185,12 +260,12 @@ async def job_callback(job_queue: "JobQueue[CCT]", job: "Job[CCT]") -> None: def run_once( self, callback: JobCallback[CCT], - when: Union[float, datetime.timedelta, datetime.datetime, datetime.time], - data: Optional[object] = None, - name: Optional[str] = None, - chat_id: Optional[int] = None, - user_id: Optional[int] = None, - job_kwargs: Optional[JSONDict] = None, + when: float | dtm.timedelta | dtm.datetime | dtm.time, + data: object | None = None, + name: str | None = None, + chat_id: int | None = None, + user_id: int | None = None, + job_kwargs: JSONDict | None = None, ) -> "Job[CCT]": """Creates a new :class:`Job` instance that runs once and adds it to the queue. @@ -249,7 +324,7 @@ async def callback(context: CallbackContext) if not job_kwargs: job_kwargs = {} - name = name or callback.__name__ + name = name or _get_callback_name(callback) job = Job(callback=callback, data=data, name=name, chat_id=chat_id, user_id=user_id) date_time = self._parse_time_input(when, shift_day=True) @@ -269,14 +344,14 @@ async def callback(context: CallbackContext) def run_repeating( self, callback: JobCallback[CCT], - interval: Union[float, datetime.timedelta], - first: Optional[Union[float, datetime.timedelta, datetime.datetime, datetime.time]] = None, - last: Optional[Union[float, datetime.timedelta, datetime.datetime, datetime.time]] = None, - data: Optional[object] = None, - name: Optional[str] = None, - chat_id: Optional[int] = None, - user_id: Optional[int] = None, - job_kwargs: Optional[JSONDict] = None, + interval: float | dtm.timedelta, + first: float | dtm.timedelta | dtm.datetime | dtm.time | None = None, + last: float | dtm.timedelta | dtm.datetime | dtm.time | None = None, + data: object | None = None, + name: str | None = None, + chat_id: int | None = None, + user_id: int | None = None, + job_kwargs: JSONDict | None = None, ) -> "Job[CCT]": """Creates a new :class:`Job` instance that runs at specified intervals and adds it to the queue. @@ -367,7 +442,7 @@ async def callback(context: CallbackContext) if not job_kwargs: job_kwargs = {} - name = name or callback.__name__ + name = name or _get_callback_name(callback) job = Job(callback=callback, data=data, name=name, chat_id=chat_id, user_id=user_id) dt_first = self._parse_time_input(first) @@ -376,7 +451,7 @@ async def callback(context: CallbackContext) if dt_last and dt_first and dt_last < dt_first: raise ValueError("'last' must not be before 'first'!") - if isinstance(interval, datetime.timedelta): + if isinstance(interval, dtm.timedelta): interval = interval.total_seconds() j = self.scheduler.add_job( @@ -396,13 +471,13 @@ async def callback(context: CallbackContext) def run_monthly( self, callback: JobCallback[CCT], - when: datetime.time, + when: dtm.time, day: int, - data: Optional[object] = None, - name: Optional[str] = None, - chat_id: Optional[int] = None, - user_id: Optional[int] = None, - job_kwargs: Optional[JSONDict] = None, + data: object | None = None, + name: str | None = None, + chat_id: int | None = None, + user_id: int | None = None, + job_kwargs: JSONDict | None = None, ) -> "Job[CCT]": """Creates a new :class:`Job` that runs on a monthly basis and adds it to the queue. @@ -453,7 +528,7 @@ async def callback(context: CallbackContext) if not job_kwargs: job_kwargs = {} - name = name or callback.__name__ + name = name or _get_callback_name(callback) job = Job(callback=callback, data=data, name=name, chat_id=chat_id, user_id=user_id) j = self.scheduler.add_job( @@ -474,13 +549,13 @@ async def callback(context: CallbackContext) def run_daily( self, callback: JobCallback[CCT], - time: datetime.time, - days: Tuple[int, ...] = _ALL_DAYS, - data: Optional[object] = None, - name: Optional[str] = None, - chat_id: Optional[int] = None, - user_id: Optional[int] = None, - job_kwargs: Optional[JSONDict] = None, + time: dtm.time, + days: tuple[int, ...] = _ALL_DAYS, + data: object | None = None, + name: str | None = None, + chat_id: int | None = None, + user_id: int | None = None, + job_kwargs: JSONDict | None = None, ) -> "Job[CCT]": """Creates a new :class:`Job` that runs on a daily basis and adds it to the queue. @@ -499,7 +574,7 @@ async def callback(context: CallbackContext) time (:obj:`datetime.time`): Time of day at which the job should run. If the timezone (:obj:`datetime.time.tzinfo`) is :obj:`None`, the default timezone of the bot will be used, which is UTC unless :attr:`telegram.ext.Defaults.tzinfo` is used. - days (Tuple[:obj:`int`], optional): Defines on which days of the week the job should + days (tuple[:obj:`int`], optional): Defines on which days of the week the job should run (where ``0-6`` correspond to sunday - saturday). By default, the job will run every day. @@ -533,17 +608,10 @@ async def callback(context: CallbackContext) queue. """ - # TODO: After v20.0, we should remove this warning. - if days != tuple(range(7)): # checks if user passed a custom value - warn( - "Prior to v20.0 the `days` parameter was not aligned to that of cron's weekday " - "scheme. We recommend double checking if the passed value is correct.", - stacklevel=2, - ) if not job_kwargs: job_kwargs = {} - name = name or callback.__name__ + name = name or _get_callback_name(callback) job = Job(callback=callback, data=data, name=name, chat_id=chat_id, user_id=user_id) j = self.scheduler.add_job( @@ -566,10 +634,10 @@ def run_custom( self, callback: JobCallback[CCT], job_kwargs: JSONDict, - data: Optional[object] = None, - name: Optional[str] = None, - chat_id: Optional[int] = None, - user_id: Optional[int] = None, + data: object | None = None, + name: str | None = None, + chat_id: int | None = None, + user_id: int | None = None, ) -> "Job[CCT]": """Creates a new custom defined :class:`Job`. @@ -606,7 +674,7 @@ async def callback(context: CallbackContext) queue. """ - name = name or callback.__name__ + name = name or _get_callback_name(callback) job = Job(callback=callback, data=data, name=name, chat_id=chat_id, user_id=user_id) j = self.scheduler.add_job(self.job_callback, args=(self, job), name=name, **job_kwargs) @@ -643,22 +711,43 @@ async def stop(self, wait: bool = True) -> None: # so give it a tiny bit of time to actually shut down. await asyncio.sleep(0.01) - def jobs(self) -> Tuple["Job[CCT]", ...]: + def jobs(self, pattern: str | re.Pattern[str] | None = None) -> tuple["Job[CCT]", ...]: """Returns a tuple of all *scheduled* jobs that are currently in the :class:`JobQueue`. + Args: + pattern (:obj:`str` | :obj:`re.Pattern`, optional): A regular expression pattern. If + passed, only jobs whose name matches the pattern will be returned. + Defaults to :obj:`None`. + + Hint: + This uses :func:`re.search` and not :func:`re.match`. + + .. versionadded:: 21.10 + Returns: - Tuple[:class:`Job`]: Tuple of all *scheduled* jobs. + tuple[:class:`Job`]: Tuple of all *scheduled* jobs. """ - return tuple(Job.from_aps_job(job) for job in self.scheduler.get_jobs()) + jobs_generator: Iterable[Job] = ( + Job.from_aps_job(job) for job in self.scheduler.get_jobs() + ) + if pattern is None: + return tuple(jobs_generator) + return tuple( + job for job in jobs_generator if (job.name and re.compile(pattern).search(job.name)) + ) - def get_jobs_by_name(self, name: str) -> Tuple["Job[CCT]", ...]: - """Returns a tuple of all *pending/scheduled* jobs with the given name that are currently + def get_jobs_by_name(self, name: str) -> tuple["Job[CCT]", ...]: + """Returns a tuple of all *scheduled* jobs with the given name that are currently in the :class:`JobQueue`. + Hint: + This method is a convenience wrapper for :meth:`jobs` with a pattern that matches the + given name. + Returns: - Tuple[:class:`Job`]: Tuple of all *pending* or *scheduled* jobs matching the name. + tuple[:class:`Job`]: Tuple of all *scheduled* jobs matching the name. """ - return tuple(job for job in self.jobs() if job.name == name) + return self.jobs(f"^{re.escape(name)}$") class Job(Generic[CCT]): @@ -678,7 +767,7 @@ class Job(Generic[CCT]): .. code-block:: bash - pip install python-telegram-bot[job-queue] + pip install "python-telegram-bot[job-queue]" Note: All attributes and instance methods of :attr:`job` are also directly available as @@ -688,7 +777,7 @@ class Job(Generic[CCT]): This class should not be instantiated manually. Use the methods of :class:`telegram.ext.JobQueue` to schedule jobs. - .. seealso:: :wiki:`Job Queue ` + .. seealso:: :wiki:`Job Queue ` .. versionchanged:: 20.0 @@ -696,7 +785,7 @@ class Job(Generic[CCT]): * Renamed ``Job.context`` to :attr:`Job.data`. * Removed argument ``job`` * To use this class, PTB must be installed via - ``pip install python-telegram-bot[job-queue]``. + ``pip install "python-telegram-bot[job-queue]"``. Args: callback (:term:`coroutine function`): The callback function that should be executed by the @@ -729,86 +818,111 @@ async def callback(context: CallbackContext) """ __slots__ = ( - "callback", - "data", - "name", - "_removed", "_enabled", "_job", + "_removed", + "callback", "chat_id", + "data", + "name", "user_id", ) def __init__( self, callback: JobCallback[CCT], - data: Optional[object] = None, - name: Optional[str] = None, - chat_id: Optional[int] = None, - user_id: Optional[int] = None, + data: object | None = None, + name: str | None = None, + chat_id: int | None = None, + user_id: int | None = None, ): if not APS_AVAILABLE: raise RuntimeError( "To use `Job`, PTB must be installed via `pip install " - "python-telegram-bot[job-queue]`." + '"python-telegram-bot[job-queue]"`.' ) self.callback: JobCallback[CCT] = callback - self.data: Optional[object] = data - self.name: Optional[str] = name or callback.__name__ - self.chat_id: Optional[int] = chat_id - self.user_id: Optional[int] = user_id + self.data: object | None = data + self.name: str | None = name or _get_callback_name(callback) + self.chat_id: int | None = chat_id + self.user_id: int | None = user_id self._removed = False self._enabled = False - self._job = cast("APSJob", None) # skipcq: PTC-W0052 + self._job = cast("APSJob", None) - @property - def job(self) -> "APSJob": - """:class:`apscheduler.job.Job`: The APS Job this job is a wrapper for. + def __getattr__(self, item: str) -> object: + """Overrides :py:meth:`object.__getattr__` to get specific attribute of the + :class:`telegram.ext.Job` object or of its attribute :class:`apscheduler.job.Job`, + if exists. - .. versionchanged:: 20.0 - This property is now read-only. + Args: + item (:obj:`str`): The name of the attribute. + + Returns: + :object: The value of the attribute. + + Raises: + :exc:`AttributeError`: If the attribute does not exist in both + :class:`telegram.ext.Job` and :class:`apscheduler.job.Job` objects. """ - return self._job + try: + return getattr(self.job, item) + except AttributeError as exc: + raise AttributeError( + f"Neither 'telegram.ext.Job' nor 'apscheduler.job.Job' has attribute '{item}'" + ) from exc - async def run( - self, application: "Application[Any, CCT, Any, Any, Any, JobQueue[CCT]]" - ) -> None: - """Executes the callback function independently of the jobs schedule. Also calls - :meth:`telegram.ext.Application.update_persistence`. + def __eq__(self, other: object) -> bool: + """Defines equality condition for the :class:`telegram.ext.Job` object. + Two objects of this class are considered to be equal if their + :class:`id ` are equal. - .. versionchanged:: 20.0 - Calls :meth:`telegram.ext.Application.update_persistence`. + Returns: + :obj:`True` if both objects have :paramref:`id` parameters identical. + :obj:`False` otherwise. + """ + if isinstance(other, self.__class__): + return self.id == other.id + return False - Args: - application (:class:`telegram.ext.Application`): The application this job is associated - with. + def __hash__(self) -> int: + """Builds a hash value for this object such that the hash of two objects is + equal if and only if the objects are equal in terms of :meth:`__eq__`. + + Returns: + :obj:`int`: The hash value of the object. """ - # We shield the task such that the job isn't cancelled mid-run - await asyncio.shield(self._run(application)) + return hash(self.id) - async def _run( - self, application: "Application[Any, CCT, Any, Any, Any, JobQueue[CCT]]" - ) -> None: - try: - context = application.context_types.context.from_job(self, application) - await context.refresh_data() - await self.callback(context) - except Exception as exc: - await application.create_task(application.process_error(None, exc, job=self)) - finally: - # This is internal logic of application - let's keep it private for now - application._mark_for_persistence_update(job=self) # pylint: disable=protected-access + def __repr__(self) -> str: + """Give a string representation of the job in the form + ``Job[id=..., name=..., callback=..., trigger=...]``. - def schedule_removal(self) -> None: + As this class doesn't implement :meth:`object.__str__`, the default implementation + will be used, which is equivalent to :meth:`__repr__`. + + Returns: + :obj:`str` """ - Schedules this job for removal from the :class:`JobQueue`. It will be removed without - executing its callback function again. + return build_repr_with_selected_attrs( + self, + id=self.job.id, + name=self.name, + callback=_get_callback_name(self.callback), + trigger=self.job.trigger, + ) + + @property + def job(self) -> "APSJob": + """:class:`apscheduler.job.Job`: The APS Job this job is a wrapper for. + + .. versionchanged:: 20.0 + This property is now read-only. """ - self.job.remove() - self._removed = True + return self._job @property def removed(self) -> bool: @@ -829,7 +943,7 @@ def enabled(self, status: bool) -> None: self._enabled = status @property - def next_t(self) -> Optional[datetime.datetime]: + def next_t(self) -> dtm.datetime | None: """ :class:`datetime.datetime`: Datetime for the next job execution. Datetime is localized according to :attr:`datetime.datetime.tzinfo`. @@ -850,7 +964,7 @@ def from_aps_job(cls, aps_job: "APSJob") -> "Job[CCT]": This method can be useful when using advanced APScheduler features along with :class:`telegram.ext.JobQueue`. - .. versionadded:: NEXT.VERSION + .. versionadded:: 20.4 Args: aps_job (:class:`apscheduler.job.Job`): The APScheduler job @@ -862,18 +976,51 @@ def from_aps_job(cls, aps_job: "APSJob") -> "Job[CCT]": ext_job._job = aps_job # pylint: disable=protected-access return ext_job - def __getattr__(self, item: str) -> object: + async def run( + self, application: "Application[Any, CCT, Any, Any, Any, JobQueue[CCT]]" + ) -> None: + """Executes the callback function independently of the jobs schedule. Also calls + :meth:`telegram.ext.Application.update_persistence`. + + .. versionchanged:: 20.0 + Calls :meth:`telegram.ext.Application.update_persistence`. + + Args: + application (:class:`telegram.ext.Application`): The application this job is associated + with. + """ + # We shield the task such that the job isn't cancelled mid-run + await asyncio.shield(self._run(application)) + + async def _run( + self, application: "Application[Any, CCT, Any, Any, Any, JobQueue[CCT]]" + ) -> None: try: - return getattr(self.job, item) - except AttributeError as exc: - raise AttributeError( - f"Neither 'telegram.ext.Job' nor 'apscheduler.job.Job' has attribute '{item}'" - ) from exc + try: + context = application.context_types.context.from_job(self, application) + except Exception as exc: + _LOGGER.critical( + "Error while building CallbackContext for job %s. Job will not be run.", + self._job, + exc_info=exc, + ) + return - def __eq__(self, other: object) -> bool: - if isinstance(other, self.__class__): - return self.id == other.id - return False + await context.refresh_data() + await self.callback(context) + except Exception as exc: + await application.create_task( + application.process_error(None, exc, job=self), + name=f"Job:{self.id}:run:process_error", + ) + finally: + # This is internal logic of application - let's keep it private for now + application._mark_for_persistence_update(job=self) # pylint: disable=protected-access - def __hash__(self) -> int: - return hash(self.id) + def schedule_removal(self) -> None: + """ + Schedules this job for removal from the :class:`JobQueue`. It will be removed without + executing its callback function again. + """ + self.job.remove() + self._removed = True diff --git a/telegram/ext/_picklepersistence.py b/src/telegram/ext/_picklepersistence.py similarity index 87% rename from telegram/ext/_picklepersistence.py rename to src/telegram/ext/_picklepersistence.py index 34fdc36dca7..acabff8da88 100644 --- a/telegram/ext/_picklepersistence.py +++ b/src/telegram/ext/_picklepersistence.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,12 +17,12 @@ # 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 the PicklePersistence class.""" -import copyreg + import pickle +from collections.abc import Callable from copy import deepcopy from pathlib import Path -from sys import version_info as py_ver -from typing import Any, Callable, Dict, Optional, Set, Tuple, Type, TypeVar, Union, cast, overload +from typing import Any, TypeVar, cast, overload from telegram import Bot, TelegramObject from telegram._utils.types import FilePathInput @@ -37,7 +37,7 @@ TelegramObj = TypeVar("TelegramObj", bound=TelegramObject) -def _all_subclasses(cls: Type[TelegramObj]) -> Set[Type[TelegramObj]]: +def _all_subclasses(cls: type[TelegramObj]) -> set[type[TelegramObj]]: """Gets all subclasses of the specified object, recursively. from https://stackoverflow.com/a/3862957/9706202 """ @@ -45,7 +45,7 @@ def _all_subclasses(cls: Type[TelegramObj]) -> Set[Type[TelegramObj]]: return set(subclasses).union([s for c in subclasses for s in _all_subclasses(c)]) -def _reconstruct_to(cls: Type[TelegramObj], kwargs: dict) -> TelegramObj: +def _reconstruct_to(cls: type[TelegramObj], kwargs: dict) -> TelegramObj: """ This method is used for unpickling. The data, which is in the form a dictionary, is converted back into a class. Works mostly the same as :meth:`TelegramObject.__setstate__`. @@ -57,7 +57,7 @@ def _reconstruct_to(cls: Type[TelegramObj], kwargs: dict) -> TelegramObj: return obj -def _custom_reduction(cls: TelegramObj) -> Tuple[Callable, Tuple[Type[TelegramObj], dict]]: +def _custom_reduction(cls: TelegramObj) -> tuple[Callable, tuple[type[TelegramObj], dict]]: """ This method is used for pickling. The bot attribute is preserved so _BotPickler().persistent_id works as intended. @@ -74,24 +74,21 @@ class _BotPickler(pickle.Pickler): def __init__(self, bot: Bot, *args: Any, **kwargs: Any): self._bot = bot - if py_ver < (3, 8): # self.reducer_override is used above this version - # Here we define a private dispatch_table, because we want to preserve the bot - # attribute of objects so persistent_id works as intended. Otherwise, the bot attribute - # is deleted in __getstate__, which is used during regular pickling (via pickle.dumps) - self.dispatch_table = copyreg.dispatch_table.copy() - for obj in _all_subclasses(TelegramObject): - self.dispatch_table[obj] = _custom_reduction super().__init__(*args, **kwargs) - def reducer_override( # skipcq: PYL-R0201 + def reducer_override( self, obj: TelegramObj - ) -> Tuple[Callable, Tuple[Type[TelegramObj], dict]]: + ) -> tuple[Callable, tuple[type[TelegramObj], dict]]: + """ + This method is used for pickling. The bot attribute is preserved so + _BotPickler().persistent_id works as intended. + """ if not isinstance(obj, TelegramObject): return NotImplemented return _custom_reduction(obj) - def persistent_id(self, obj: object) -> Optional[str]: + def persistent_id(self, obj: object) -> str | None: """Used to 'mark' the Bot, so it can be replaced later. See https://docs.python.org/3/library/pickle.html#pickle.Pickler.persistent_id for more info """ @@ -113,7 +110,7 @@ def __init__(self, bot: Bot, *args: Any, **kwargs: Any): self._bot = bot super().__init__(*args, **kwargs) - def persistent_load(self, pid: str) -> Optional[Bot]: + def persistent_load(self, pid: str) -> Bot | None: """Replaces the bot with the current bot if known, else it is replaced by :obj:`None`.""" if pid == _REPLACED_KNOWN_BOT: return self._bot @@ -191,60 +188,58 @@ class PicklePersistence(BasePersistence[UD, CD, BD]): """ __slots__ = ( - "filepath", - "single_file", - "on_flush", - "user_data", - "chat_data", "bot_data", "callback_data", - "conversations", + "chat_data", "context_types", + "conversations", + "filepath", + "on_flush", + "single_file", + "user_data", ) @overload def __init__( - self: "PicklePersistence[Dict[Any, Any], Dict[Any, Any], Dict[Any, Any]]", + self: "PicklePersistence[dict[Any, Any], dict[Any, Any], dict[Any, Any]]", filepath: FilePathInput, - store_data: Optional[PersistenceInput] = None, + store_data: PersistenceInput | None = None, single_file: bool = True, on_flush: bool = False, update_interval: float = 60, - ): - ... + ): ... @overload def __init__( self: "PicklePersistence[UD, CD, BD]", filepath: FilePathInput, - store_data: Optional[PersistenceInput] = None, + store_data: PersistenceInput | None = None, single_file: bool = True, on_flush: bool = False, update_interval: float = 60, - context_types: Optional[ContextTypes[Any, UD, CD, BD]] = None, - ): - ... + context_types: ContextTypes[Any, UD, CD, BD] | None = None, + ): ... def __init__( self, filepath: FilePathInput, - store_data: Optional[PersistenceInput] = None, + store_data: PersistenceInput | None = None, single_file: bool = True, on_flush: bool = False, update_interval: float = 60, - context_types: Optional[ContextTypes[Any, UD, CD, BD]] = None, + context_types: ContextTypes[Any, UD, CD, BD] | None = None, ): super().__init__(store_data=store_data, update_interval=update_interval) self.filepath: Path = Path(filepath) - self.single_file: Optional[bool] = single_file - self.on_flush: Optional[bool] = on_flush - self.user_data: Optional[Dict[int, UD]] = None - self.chat_data: Optional[Dict[int, CD]] = None - self.bot_data: Optional[BD] = None - self.callback_data: Optional[CDCData] = None - self.conversations: Optional[Dict[str, Dict[Tuple[Union[int, str], ...], object]]] = None + self.single_file: bool | None = single_file + self.on_flush: bool | None = on_flush + self.user_data: dict[int, UD] | None = None + self.chat_data: dict[int, CD] | None = None + self.bot_data: BD | None = None + self.callback_data: CDCData | None = None + self.conversations: dict[str, dict[tuple[int | str, ...], object]] | None = None self.context_types: ContextTypes[Any, UD, CD, BD] = cast( - ContextTypes[Any, UD, CD, BD], context_types or ContextTypes() + "ContextTypes[Any, UD, CD, BD]", context_types or ContextTypes() ) def _load_singlefile(self) -> None: @@ -297,11 +292,11 @@ def _dump_file(self, filepath: Path, data: object) -> None: with filepath.open("wb") as file: _BotPickler(self.bot, file, protocol=pickle.HIGHEST_PROTOCOL).dump(data) - async def get_user_data(self) -> Dict[int, UD]: + async def get_user_data(self) -> dict[int, UD]: """Returns the user_data from the pickle file if it exists or an empty :obj:`dict`. Returns: - Dict[:obj:`int`, :obj:`dict`]: The restored user data. + dict[:obj:`int`, :obj:`dict`]: The restored user data. """ if self.user_data: pass @@ -314,11 +309,11 @@ async def get_user_data(self) -> Dict[int, UD]: self._load_singlefile() return deepcopy(self.user_data) # type: ignore[arg-type] - async def get_chat_data(self) -> Dict[int, CD]: + async def get_chat_data(self) -> dict[int, CD]: """Returns the chat_data from the pickle file if it exists or an empty :obj:`dict`. Returns: - Dict[:obj:`int`, :obj:`dict`]: The restored chat data. + dict[:obj:`int`, :obj:`dict`]: The restored chat data. """ if self.chat_data: pass @@ -349,14 +344,14 @@ async def get_bot_data(self) -> BD: self._load_singlefile() return deepcopy(self.bot_data) # type: ignore[return-value] - async def get_callback_data(self) -> Optional[CDCData]: + async def get_callback_data(self) -> CDCData | None: """Returns the callback data from the pickle file if it exists or :obj:`None`. .. versionadded:: 13.6 Returns: - Tuple[List[Tuple[:obj:`str`, :obj:`float`, Dict[:obj:`str`, :class:`object`]]], - Dict[:obj:`str`, :obj:`str`]] | :obj:`None`: The restored metadata or :obj:`None`, + tuple[list[tuple[:obj:`str`, :obj:`float`, dict[:obj:`str`, :class:`object`]]], + dict[:obj:`str`, :obj:`str`]] | :obj:`None`: The restored metadata or :obj:`None`, if no data was stored. """ if self.callback_data: @@ -393,7 +388,7 @@ async def get_conversations(self, name: str) -> ConversationDict: return self.conversations.get(name, {}).copy() # type: ignore[union-attr] async def update_conversation( - self, name: str, key: ConversationKey, new_state: Optional[object] + self, name: str, key: ConversationKey, new_state: object | None ) -> None: """Will update the conversations for the given handler and depending on :attr:`on_flush` save the pickle file. @@ -473,8 +468,8 @@ async def update_callback_data(self, data: CDCData) -> None: .. versionadded:: 13.6 Args: - data (Tuple[List[Tuple[:obj:`str`, :obj:`float`, \ - Dict[:obj:`str`, :class:`object`]]], Dict[:obj:`str`, :obj:`str`]]): + data (tuple[list[tuple[:obj:`str`, :obj:`float`, \ + dict[:obj:`str`, :class:`object`]]], dict[:obj:`str`, :obj:`str`]]): The relevant data to restore :class:`telegram.ext.CallbackDataCache`. """ if self.callback_data == data: @@ -497,7 +492,7 @@ async def drop_chat_data(self, chat_id: int) -> None: """ if self.chat_data is None: return - self.chat_data.pop(chat_id, None) # type: ignore[arg-type] + self.chat_data.pop(chat_id, None) if not self.on_flush: if not self.single_file: @@ -516,7 +511,7 @@ async def drop_user_data(self, user_id: int) -> None: """ if self.user_data is None: return - self.user_data.pop(user_id, None) # type: ignore[arg-type] + self.user_data.pop(user_id, None) if not self.on_flush: if not self.single_file: diff --git a/telegram/ext/_updater.py b/src/telegram/ext/_updater.py similarity index 66% rename from telegram/ext/_updater.py rename to src/telegram/ext/_updater.py index 231500b1542..672768afb8e 100644 --- a/telegram/ext/_updater.py +++ b/src/telegram/ext/_updater.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,27 +17,22 @@ # 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 the class Updater, which tries to make creating Telegram bots intuitive.""" + import asyncio import contextlib +import datetime as dtm import ssl +from collections.abc import Callable, Coroutine, Sequence from pathlib import Path from types import TracebackType -from typing import ( - TYPE_CHECKING, - AsyncContextManager, - Callable, - Coroutine, - List, - Optional, - Type, - TypeVar, - Union, -) - -from telegram._utils.defaultvalue import DEFAULT_NONE +from typing import TYPE_CHECKING, Any, TypeVar + +from telegram._utils.defaultvalue import DEFAULT_80, DEFAULT_IP, DefaultValue from telegram._utils.logging import get_logger -from telegram._utils.types import ODVInput -from telegram.error import InvalidToken, RetryAfter, TelegramError, TimedOut +from telegram._utils.repr import build_repr_with_selected_attrs +from telegram._utils.types import DVType, TimePeriod +from telegram.error import TelegramError +from telegram.ext._utils.networkloop import network_retry_loop try: from telegram.ext._utils.webhookhandler import WebhookAppClass, WebhookServer @@ -47,6 +42,8 @@ WEBHOOKS_AVAILABLE = False if TYPE_CHECKING: + from socket import socket + from telegram import Bot @@ -54,7 +51,7 @@ _LOGGER = get_logger(__name__) -class Updater(AsyncContextManager["Updater"]): +class Updater(contextlib.AbstractAsyncContextManager["Updater"]): """This class fetches updates for the bot either via long polling or by starting a webhook server. Received updates are enqueued into the :attr:`update_queue` and may be fetched from there to handle them appropriately. @@ -76,6 +73,8 @@ class Updater(AsyncContextManager["Updater"]): finally: await updater.shutdown() + .. seealso:: :meth:`__aenter__` and :meth:`__aexit__`. + .. seealso:: :wiki:`Architecture Overview `, :wiki:`Builder Pattern ` @@ -97,14 +96,16 @@ class Updater(AsyncContextManager["Updater"]): """ __slots__ = ( - "bot", - "update_queue", - "_last_update_id", - "_running", - "_initialized", - "_httpd", "__lock", + "__polling_cleanup_cb", "__polling_task", + "__polling_task_stop_event", + "_httpd", + "_initialized", + "_last_update_id", + "_running", + "bot", + "update_queue", ) def __init__( @@ -113,14 +114,56 @@ def __init__( update_queue: "asyncio.Queue[object]", ): self.bot: Bot = bot - self.update_queue: "asyncio.Queue[object]" = update_queue + self.update_queue: asyncio.Queue[object] = update_queue self._last_update_id = 0 self._running = False self._initialized = False - self._httpd: Optional[WebhookServer] = None + self._httpd: WebhookServer | None = None self.__lock = asyncio.Lock() - self.__polling_task: Optional[asyncio.Task] = None + self.__polling_task: asyncio.Task | None = None + self.__polling_task_stop_event: asyncio.Event = asyncio.Event() + self.__polling_cleanup_cb: Callable[[], Coroutine[Any, Any, None]] | None = None + + async def __aenter__(self: _UpdaterType) -> _UpdaterType: + """ + |async_context_manager| :meth:`initializes ` the Updater. + + Returns: + The initialized Updater instance. + + Raises: + :exc:`Exception`: If an exception is raised during initialization, :meth:`shutdown` + is called in this case. + """ + try: + await self.initialize() + except Exception: + await self.shutdown() + raise + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """|async_context_manager| :meth:`shuts down ` the Updater.""" + # Make sure not to return `True` so that exceptions are not suppressed + # https://docs.python.org/3/reference/datamodel.html?#object.__aexit__ + await self.shutdown() + + def __repr__(self) -> str: + """Give a string representation of the updater in the form ``Updater[bot=...]``. + + As this class doesn't implement :meth:`object.__str__`, the default implementation + will be used, which is equivalent to :meth:`__repr__`. + + Returns: + :obj:`str` + """ + return build_repr_with_selected_attrs(self, bot=self.bot) @property def running(self) -> bool: @@ -161,68 +204,50 @@ async def shutdown(self) -> None: self._initialized = False _LOGGER.debug("Shut down of Updater complete") - async def __aenter__(self: _UpdaterType) -> _UpdaterType: - """Simple context manager which initializes the Updater.""" - try: - await self.initialize() - return self - except Exception as exc: - await self.shutdown() - raise exc - - async def __aexit__( - self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> None: - """Shutdown the Updater from the context manager.""" - # Make sure not to return `True` so that exceptions are not suppressed - # https://docs.python.org/3/reference/datamodel.html?#object.__aexit__ - await self.shutdown() - async def start_polling( self, poll_interval: float = 0.0, - timeout: int = 10, - bootstrap_retries: int = -1, - read_timeout: float = 2, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - allowed_updates: Optional[List[str]] = None, - drop_pending_updates: Optional[bool] = None, - error_callback: Optional[Callable[[TelegramError], None]] = None, + timeout: TimePeriod = dtm.timedelta(seconds=10), + bootstrap_retries: int = 0, + allowed_updates: Sequence[str] | None = None, + drop_pending_updates: bool | None = None, + error_callback: Callable[[TelegramError], None] | None = None, ) -> "asyncio.Queue[object]": """Starts polling updates from Telegram. .. versionchanged:: 20.0 Removed the ``clean`` argument in favor of :paramref:`drop_pending_updates`. + .. versionchanged:: 22.0 + Removed the deprecated arguments ``read_timeout``, ``write_timeout``, + ``connect_timeout``, and ``pool_timeout`` in favor of setting the timeouts via + the corresponding methods of :class:`telegram.ext.ApplicationBuilder`. or + by specifying the timeout via :paramref:`telegram.Bot.get_updates_request`. + Args: poll_interval (:obj:`float`, optional): Time to wait between polling updates from Telegram in seconds. Default is ``0.0``. - timeout (:obj:`int`, optional): Passed to - :paramref:`telegram.Bot.get_updates.timeout`. Defaults to ``10`` seconds. - bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the - :class:`telegram.ext.Updater` will retry on failures on the Telegram server. + timeout (:obj:`int` | :class:`datetime.timedelta`, optional): Passed to + :paramref:`telegram.Bot.get_updates.timeout`. Defaults to + ``timedelta(seconds=10)``. + + .. versionchanged:: v22.2 + |time-period-input| + bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of + will retry on failures on the Telegram server. - * < 0 - retry indefinitely (default) - * 0 - no retries + * < 0 - retry indefinitely + * 0 - no retries (default) * > 0 - retry up to X times - read_timeout (:obj:`float`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.read_timeout`. Defaults to ``2``. - write_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.write_timeout`. Defaults to - :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. - connect_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.connect_timeout`. Defaults to - :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. - pool_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.pool_timeout`. Defaults to - :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. - allowed_updates (List[:obj:`str`], optional): Passed to + + .. versionchanged:: 21.11 + The default value will be changed to from ``-1`` to ``0``. Indefinite retries + during bootstrapping are not recommended. + allowed_updates (Sequence[:obj:`str`], optional): Passed to :meth:`telegram.Bot.get_updates`. + + .. versionchanged:: 21.9 + Accepts any :class:`collections.abc.Sequence` as input instead of just a list drop_pending_updates (:obj:`bool`, optional): Whether to clean any pending updates on Telegram servers before actually starting to poll. Default is :obj:`False`. @@ -246,6 +271,10 @@ def callback(error: telegram.error.TelegramError) :exc:`RuntimeError`: If the updater is already running or was not initialized. """ + # We refrain from issuing deprecation warnings for the timeout parameters here, as we + # already issue them in `Application`. This means that there are no warnings when using + # `Updater` without `Application`, but this is a rather special use case. + if error_callback and asyncio.iscoroutinefunction(error_callback): raise TypeError( "The `error_callback` must not be a coroutine function! Use an ordinary function " @@ -267,10 +296,6 @@ def callback(error: telegram.error.TelegramError) await self._start_polling( poll_interval=poll_interval, timeout=timeout, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, bootstrap_retries=bootstrap_retries, drop_pending_updates=drop_pending_updates, allowed_updates=allowed_updates, @@ -281,25 +306,20 @@ def callback(error: telegram.error.TelegramError) _LOGGER.debug("Waiting for polling to start") await polling_ready.wait() _LOGGER.debug("Polling updates from Telegram started") - - return self.update_queue - except Exception as exc: + except Exception: self._running = False - raise exc + raise + return self.update_queue async def _start_polling( self, poll_interval: float, - timeout: int, - read_timeout: float, - write_timeout: ODVInput[float], - connect_timeout: ODVInput[float], - pool_timeout: ODVInput[float], + timeout: TimePeriod, bootstrap_retries: int, - drop_pending_updates: Optional[bool], - allowed_updates: Optional[List[str]], + drop_pending_updates: bool | None, + allowed_updates: Sequence[str] | None, ready: asyncio.Event, - error_callback: Optional[Callable[[TelegramError], None]], + error_callback: Callable[[TelegramError], None] | None, ) -> None: _LOGGER.debug("Updater started (polling)") @@ -315,24 +335,16 @@ async def _start_polling( _LOGGER.debug("Bootstrap done") - async def polling_action_cb() -> bool: + async def polling_action_cb() -> None: try: updates = await self.bot.get_updates( offset=self._last_update_id, timeout=timeout, - read_timeout=read_timeout, - connect_timeout=connect_timeout, - write_timeout=write_timeout, - pool_timeout=pool_timeout, allowed_updates=allowed_updates, ) - except asyncio.CancelledError as exc: - # TODO: in py3.8+, CancelledError is a subclass of BaseException, so we can drop - # this clause when we drop py3.7 - raise exc - except TelegramError as exc: + except TelegramError: # TelegramErrors should be processed by the network retry loop - raise exc + raise except Exception as exc: # Other exceptions should not. Let's log them for now. _LOGGER.critical( @@ -340,7 +352,7 @@ async def polling_action_cb() -> bool: "Received data was *not* processed!", exc_info=exc, ) - return True + return if updates: if not self.running: @@ -353,7 +365,7 @@ async def polling_action_cb() -> bool: await self.update_queue.put(update) self._last_update_id = updates[-1].update_id + 1 # Add one to 'confirm' it - return True # Keep fetching updates & don't quit. Polls with poll_interval. + return def default_error_callback(exc: TelegramError) -> None: _LOGGER.exception("Exception happened while polling for updates.", exc_info=exc) @@ -362,31 +374,63 @@ def default_error_callback(exc: TelegramError) -> None: # updates from Telegram and inserts them in the update queue of the # Application. self.__polling_task = asyncio.create_task( - self._network_loop_retry( + network_retry_loop( + is_running=lambda: self.running, action_cb=polling_action_cb, on_err_cb=error_callback or default_error_callback, - description="getting Updates", + description="Polling Updates", interval=poll_interval, - ) + stop_event=self.__polling_task_stop_event, + max_retries=-1, + repeat_on_success=True, + ), + name="Updater:start_polling:polling_task", ) + # Prepare a cleanup callback to await on _stop_polling + # Calling get_updates one more time with the latest `offset` parameter ensures that + # all updates that where put into the update queue are also marked as "read" to TG, + # so we do not receive them again on the next startup + # We define this here so that we can use the same parameters as in the polling task + async def _get_updates_cleanup() -> None: + _LOGGER.debug( + "Calling `get_updates` one more time to mark all fetched updates as read." + ) + try: + await self.bot.get_updates( + offset=self._last_update_id, + # We don't want to do long polling here! + timeout=dtm.timedelta(seconds=0), + allowed_updates=allowed_updates, + ) + except TelegramError: + _LOGGER.exception( + "Error while calling `get_updates` one more time to mark all fetched updates. " + "Suppressing error to ensure graceful shutdown. When polling for " + "updates is restarted, updates may be fetched again. Please adjust timeouts " + "via `ApplicationBuilder` or the parameter `get_updates_request` of `Bot`.", + ) + + self.__polling_cleanup_cb = _get_updates_cleanup + if ready is not None: ready.set() async def start_webhook( self, - listen: str = "127.0.0.1", - port: int = 80, + listen: DVType[str] = DEFAULT_IP, + port: DVType[int] = DEFAULT_80, url_path: str = "", - cert: Optional[Union[str, Path]] = None, - key: Optional[Union[str, Path]] = None, + cert: str | Path | None = None, + key: str | Path | None = None, bootstrap_retries: int = 0, - webhook_url: Optional[str] = None, - allowed_updates: Optional[List[str]] = None, - drop_pending_updates: Optional[bool] = None, - ip_address: Optional[str] = None, + webhook_url: str | None = None, + allowed_updates: Sequence[str] | None = None, + drop_pending_updates: bool | None = None, + ip_address: str | None = None, max_connections: int = 40, - secret_token: Optional[str] = None, + secret_token: str | None = None, + unix: "str | Path | socket | None" = None, ) -> "asyncio.Queue[object]": """ Starts a small http server to listen for updates via webhook. If :paramref:`cert` @@ -401,7 +445,7 @@ async def start_webhook( .. code-block:: bash - pip install python-telegram-bot[webhooks] + pip install "python-telegram-bot[webhooks]" .. seealso:: :wiki:`Webhooks` @@ -427,8 +471,8 @@ async def start_webhook( Telegram servers before actually starting to poll. Default is :obj:`False`. .. versionadded :: 13.4 - bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of the - :class:`telegram.ext.Updater` will retry on failures on the Telegram server. + bootstrap_retries (:obj:`int`, optional): Whether the bootstrapping phase of + will retry on failures on the Telegram server. * < 0 - retry indefinitely * 0 - no retries (default) @@ -440,8 +484,11 @@ async def start_webhook( Defaults to :obj:`None`. .. versionadded :: 13.4 - allowed_updates (List[:obj:`str`], optional): Passed to + allowed_updates (Sequence[:obj:`str`], optional): Passed to :meth:`telegram.Bot.set_webhook`. Defaults to :obj:`None`. + + .. versionchanged:: 21.9 + Accepts any :class:`collections.abc.Sequence` as input instead of just a list max_connections (:obj:`int`, optional): Passed to :meth:`telegram.Bot.set_webhook`. Defaults to ``40``. @@ -455,6 +502,27 @@ async def start_webhook( header isn't set or it is set to a wrong token. .. versionadded:: 20.0 + unix (:class:`pathlib.Path` | :obj:`str` | :class:`socket.socket`, optional): Can be + either: + + * the path to the unix socket file as :class:`pathlib.Path` or :obj:`str`. This + will be passed to `tornado.netutil.bind_unix_socket `_ to create the socket. + If the Path does not exist, the file will be created. + + * or the socket itself. This option allows you to e.g. restrict the permissions of + the socket for improved security. Note that you need to pass the correct family, + type and socket options yourself. + + Caution: + This parameter is a replacement for the default TCP bind. Therefore, it is + mutually exclusive with :paramref:`listen` and :paramref:`port`. When using + this param, you must also run a reverse proxy to the unix socket and set the + appropriate :paramref:`webhook_url`. + + .. versionadded:: 20.8 + .. versionchanged:: 21.1 + Added support to pass a socket instance itself. Returns: :class:`queue.Queue`: The update queue that can be filled from the main thread. @@ -464,8 +532,23 @@ async def start_webhook( if not WEBHOOKS_AVAILABLE: raise RuntimeError( "To use `start_webhook`, PTB must be installed via `pip install " - "python-telegram-bot[webhooks]`." + '"python-telegram-bot[webhooks]"`.' + ) + # unix has special requirements what must and mustn't be set when using it + if unix: + error_msg = ( + "You can not pass unix and {0}, only use one. Unix if you want to " + "initialize a unix socket, or {0} for a standard TCP server." ) + if not isinstance(listen, DefaultValue): + raise RuntimeError(error_msg.format("listen")) + if not isinstance(port, DefaultValue): + raise RuntimeError(error_msg.format("port")) + if not webhook_url: + raise RuntimeError( + "Since you set unix, you also need to set the URL to the webhook " + "of the proxy you run in front of the unix socket." + ) async with self.__lock: if self.running: @@ -480,8 +563,8 @@ async def start_webhook( webhook_ready = asyncio.Event() await self._start_webhook( - listen=listen, - port=port, + listen=DefaultValue.get_value(listen), + port=DefaultValue.get_value(port), url_path=url_path, cert=cert, key=key, @@ -493,14 +576,15 @@ async def start_webhook( ip_address=ip_address, max_connections=max_connections, secret_token=secret_token, + unix=unix, ) _LOGGER.debug("Waiting for webhook server to start") await webhook_ready.wait() _LOGGER.debug("Webhook server started") - except Exception as exc: + except Exception: self._running = False - raise exc + raise # Return the update queue so the main thread can insert updates return self.update_queue @@ -511,15 +595,16 @@ async def _start_webhook( port: int, url_path: str, bootstrap_retries: int, - allowed_updates: Optional[List[str]], - cert: Optional[Union[str, Path]] = None, - key: Optional[Union[str, Path]] = None, - drop_pending_updates: Optional[bool] = None, - webhook_url: Optional[str] = None, - ready: Optional[asyncio.Event] = None, - ip_address: Optional[str] = None, + allowed_updates: Sequence[str] | None, + cert: str | Path | None = None, + key: str | Path | None = None, + drop_pending_updates: bool | None = None, + webhook_url: str | None = None, + ready: asyncio.Event | None = None, + ip_address: str | None = None, max_connections: int = 40, - secret_token: Optional[str] = None, + secret_token: str | None = None, + unix: "str | Path | socket | None" = None, ) -> None: _LOGGER.debug("Updater thread started (webhook)") @@ -536,7 +621,7 @@ async def _start_webhook( # the SSL handshake, e.g. in case a reverse proxy is used if cert is not None and key is not None: try: - ssl_ctx: Optional[ssl.SSLContext] = ssl.create_default_context( + ssl_ctx: ssl.SSLContext | None = ssl.create_default_context( ssl.Purpose.CLIENT_AUTH ) ssl_ctx.load_cert_chain(cert, key) # type: ignore[union-attr] @@ -544,14 +629,13 @@ async def _start_webhook( raise TelegramError("Invalid SSL Certificate") from exc else: ssl_ctx = None - # Create and start server - self._httpd = WebhookServer(listen, port, app, ssl_ctx) + self._httpd = WebhookServer(listen, port, app, ssl_ctx, unix) if not webhook_url: webhook_url = self._gen_webhook_url( protocol="https" if ssl_ctx else "http", - listen=listen, + listen=DefaultValue.get_value(listen), port=port, url_path=url_path, ) @@ -578,91 +662,35 @@ def _gen_webhook_url(protocol: str, listen: str, port: int, url_path: str) -> st # say differently! return f"{protocol}://{listen}:{port}{url_path}" - async def _network_loop_retry( - self, - action_cb: Callable[..., Coroutine], - on_err_cb: Callable[[TelegramError], None], - description: str, - interval: float, - ) -> None: - """Perform a loop calling `action_cb`, retrying after network errors. - - Stop condition for loop: `self.running` evaluates :obj:`False` or return value of - `action_cb` evaluates :obj:`False`. - - Args: - action_cb (:term:`coroutine function`): Network oriented callback function to call. - on_err_cb (:obj:`callable`): Callback to call when TelegramError is caught. Receives - the exception object as a parameter. - description (:obj:`str`): Description text to use for logs and exception raised. - interval (:obj:`float` | :obj:`int`): Interval to sleep between each call to - `action_cb`. - - """ - _LOGGER.debug("Start network loop retry %s", description) - cur_interval = interval - while self.running: - try: - try: - if not await action_cb(): - break - except RetryAfter as exc: - _LOGGER.info("%s", exc) - cur_interval = 0.5 + exc.retry_after - except TimedOut as toe: - _LOGGER.debug("Timed out %s: %s", description, toe) - # If failure is due to timeout, we should retry asap. - cur_interval = 0 - except InvalidToken as pex: - _LOGGER.error("Invalid token; aborting") - raise pex - except TelegramError as telegram_exc: - _LOGGER.error("Error while %s: %s", description, telegram_exc) - on_err_cb(telegram_exc) - - # increase waiting times on subsequent errors up to 30secs - cur_interval = 1 if cur_interval == 0 else min(30, 1.5 * cur_interval) - else: - cur_interval = interval - - if cur_interval: - await asyncio.sleep(cur_interval) - - except asyncio.CancelledError: - _LOGGER.debug("Network loop retry %s was cancelled", description) - break - async def _bootstrap( self, max_retries: int, - webhook_url: Optional[str], - allowed_updates: Optional[List[str]], - drop_pending_updates: Optional[bool] = None, - cert: Optional[bytes] = None, + webhook_url: str | None, + allowed_updates: Sequence[str] | None, + drop_pending_updates: bool | None = None, + cert: bytes | None = None, bootstrap_interval: float = 1, - ip_address: Optional[str] = None, + ip_address: str | None = None, max_connections: int = 40, - secret_token: Optional[str] = None, + secret_token: str | None = None, ) -> None: """Prepares the setup for fetching updates: delete or set the webhook and drop pending updates if appropriate. If there are unsuccessful attempts, this will retry as specified by :paramref:`max_retries`. """ - retries = 0 - async def bootstrap_del_webhook() -> bool: + async def bootstrap_del_webhook() -> None: _LOGGER.debug("Deleting webhook") if drop_pending_updates: _LOGGER.debug("Dropping pending updates from Telegram server") await self.bot.delete_webhook(drop_pending_updates=drop_pending_updates) - return False - async def bootstrap_set_webhook() -> bool: + async def bootstrap_set_webhook() -> None: _LOGGER.debug("Setting webhook") if drop_pending_updates: _LOGGER.debug("Dropping pending updates from Telegram server") await self.bot.set_webhook( - url=webhook_url, + url=webhook_url, # type: ignore[arg-type] certificate=cert, allowed_updates=allowed_updates, ip_address=ip_address, @@ -670,45 +698,29 @@ async def bootstrap_set_webhook() -> bool: max_connections=max_connections, secret_token=secret_token, ) - return False - - def bootstrap_on_err_cb(exc: Exception) -> None: - # We need this since retries is an immutable object otherwise and the changes - # wouldn't propagate outside of thi function - nonlocal retries - - if not isinstance(exc, InvalidToken) and (max_retries < 0 or retries < max_retries): - retries += 1 - _LOGGER.warning( - "Failed bootstrap phase; try=%s max_retries=%s", retries, max_retries - ) - else: - _LOGGER.error("Failed bootstrap phase after %s retries (%s)", retries, exc) - raise exc # Dropping pending updates from TG can be efficiently done with the drop_pending_updates # parameter of delete/start_webhook, even in the case of polling. Also, we want to make # sure that no webhook is configured in case of polling, so we just always call # delete_webhook for polling if drop_pending_updates or not webhook_url: - await self._network_loop_retry( - bootstrap_del_webhook, - bootstrap_on_err_cb, - "bootstrap del webhook", - bootstrap_interval, + await network_retry_loop( + action_cb=bootstrap_del_webhook, + description="Bootstrap delete Webhook", + interval=bootstrap_interval, + stop_event=None, + max_retries=max_retries, ) - # Reset the retries counter for the next _network_loop_retry call - retries = 0 - # Restore/set webhook settings, if needed. Again, we don't know ahead if a webhook is set, # so we set it anyhow. if webhook_url: - await self._network_loop_retry( - bootstrap_set_webhook, - bootstrap_on_err_cb, - "bootstrap set webhook", - bootstrap_interval, + await network_retry_loop( + action_cb=bootstrap_set_webhook, + description="Bootstrap Set Webhook", + interval=bootstrap_interval, + stop_event=None, + max_retries=max_retries, ) async def stop(self) -> None: @@ -744,7 +756,7 @@ async def _stop_polling(self) -> None: """Stops the polling task by awaiting it.""" if self.__polling_task: _LOGGER.debug("Waiting background polling task to finish up.") - self.__polling_task.cancel() + self.__polling_task_stop_event.set() with contextlib.suppress(asyncio.CancelledError): await self.__polling_task @@ -752,3 +764,13 @@ async def _stop_polling(self) -> None: # after start_polling(), but lets better be safe than sorry ... self.__polling_task = None + self.__polling_task_stop_event.clear() + + if self.__polling_cleanup_cb: + await self.__polling_cleanup_cb() + self.__polling_cleanup_cb = None + else: + _LOGGER.warning( + "No polling cleanup callback defined. The last fetched updates may be " + "fetched again on the next polling start." + ) diff --git a/telegram/ext/_utils/__init__.py b/src/telegram/ext/_utils/__init__.py similarity index 96% rename from telegram/ext/_utils/__init__.py rename to src/telegram/ext/_utils/__init__.py index c422adbe4e8..7210c0bcce1 100644 --- a/telegram/ext/_utils/__init__.py +++ b/src/telegram/ext/_utils/__init__.py @@ -1,6 +1,6 @@ # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/src/telegram/ext/_utils/_update_parsing.py b/src/telegram/ext/_utils/_update_parsing.py new file mode 100644 index 00000000000..536dfcf8faf --- /dev/null +++ b/src/telegram/ext/_utils/_update_parsing.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 helper functions related to parsing updates and their contents. + +.. versionadded:: 20.8 + +Warning: + Contents of this module are intended to be used internally by the library and *not* by the + user. Changes to this module are not considered breaking changes and may not be documented in + the changelog. +""" + +from telegram._utils.types import SCT + + +def parse_chat_id(chat_id: SCT[int] | None) -> frozenset[int]: + """Accepts a chat id or collection of chat ids and returns a frozenset of chat ids.""" + if chat_id is None: + return frozenset() + if isinstance(chat_id, int): + return frozenset({chat_id}) + return frozenset(chat_id) + + +def parse_username(username: SCT[str] | None) -> frozenset[str]: + """Accepts a username or collection of usernames and returns a frozenset of usernames. + Strips the leading ``@`` if present. + """ + if username is None: + return frozenset() + if isinstance(username, str): + return frozenset({username.removeprefix("@")}) + return frozenset(usr.removeprefix("@") for usr in username) diff --git a/src/telegram/ext/_utils/asyncio.py b/src/telegram/ext/_utils/asyncio.py new file mode 100644 index 00000000000..ec6e44d0d97 --- /dev/null +++ b/src/telegram/ext/_utils/asyncio.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 helper functions related to the std-lib asyncio module. + +.. versionadded:: 21.11 + +Warning: + Contents of this module are intended to be used internally by the library and *not* by the + user. Changes to this module are not considered breaking changes and may not be documented in + the changelog. +""" + +import asyncio +from typing import Literal + + +class TrackedBoundedSemaphore(asyncio.BoundedSemaphore): + """Simple subclass of :class:`asyncio.BoundedSemaphore` that tracks the current value of the + semaphore. While there is an attribute ``_value`` in the superclass, it's private and we + don't want to rely on it. + """ + + __slots__ = ("_current_value",) + + def __init__(self, value: int = 1) -> None: + super().__init__(value) + self._current_value = value + + @property + def current_value(self) -> int: + return self._current_value + + async def acquire(self) -> Literal[True]: + await super().acquire() + self._current_value -= 1 + return True + + def release(self) -> None: + super().release() + self._current_value += 1 diff --git a/src/telegram/ext/_utils/networkloop.py b/src/telegram/ext/_utils/networkloop.py new file mode 100644 index 00000000000..f3696b589aa --- /dev/null +++ b/src/telegram/ext/_utils/networkloop.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains a network retry loop implementation. +Its specifically tailored to handling the Telegram API and its errors. + +.. versionadded:: 21.11 + +Hint: + It was originally part of the `Updater` class, but as part of #4657 it was extracted into its + own module to be used by other parts of the library. + +Warning: + Contents of this module are intended to be used internally by the library and *not* by the + user. Changes to this module are not considered breaking changes and may not be documented in + the changelog. +""" + +import asyncio +import contextlib +from collections.abc import Callable, Coroutine + +from telegram._utils.logging import get_logger +from telegram.error import InvalidToken, RetryAfter, TelegramError, TimedOut + +_LOGGER = get_logger(__name__) + + +async def network_retry_loop( + *, + action_cb: Callable[..., Coroutine], + on_err_cb: Callable[[TelegramError], None] | None = None, + description: str, + interval: float, + stop_event: asyncio.Event | None = None, + is_running: Callable[[], bool] | None = None, + max_retries: int, + repeat_on_success: bool = False, +) -> None: + """Perform a loop calling `action_cb`, retrying after network errors. + + Stop condition for loop in case of ``max_retries < 0``: + * `is_running()` evaluates :obj:`False` + * `stop_event` is set. + * calling `action_cb` succeeds and `repeat_on_success` is :obj:`False`. + + Additional stop condition for loop in case of `max_retries >= 0``: + * a call to `action_cb` succeeds + * or `max_retries` is reached. + + Args: + action_cb (:term:`coroutine function`): Network oriented callback function to call. + on_err_cb (:obj:`callable`): Optional. Callback to call when TelegramError is caught. + Receives the exception object as a parameter. + + Hint: + Only required if you want to handle the error in a special way. Logging about + the error is already handled by the loop. + + Important: + Must not raise exceptions! If it does, the loop will be aborted. + description (:obj:`str`): Description text to use for logs and exception raised. + interval (:obj:`float` | :obj:`int`): Interval to sleep between each call to + `action_cb`. + stop_event (:class:`asyncio.Event` | :obj:`None`): Event to wait on for stopping the + loop. Setting the event will make the loop exit even if `action_cb` is currently + running. Defaults to :obj:`None`. + is_running (:obj:`callable`): Function to check if the loop should continue running. + Must return a boolean value. Defaults to `lambda: True`. + max_retries (:obj:`int`): Maximum number of retries before stopping the loop. + + * < 0: Retry indefinitely. + * 0: No retries. + * > 0: Number of retries. + + repeat_on_success (:obj:`bool`): Whether to repeat the action after a successful call. + Defaults to :obj:`False`. + + Raises: + ValueError: When passing `repeat_on_success=True` and `max_retries >= 0`. This case is + currently not supported. + + """ + if repeat_on_success and max_retries >= 0: # pragma: no cover + # This case here is only for completeness. It should not be used anywhere in the library. + raise ValueError("Cannot use repeat_on_success=True with max_retries >= 0") + + log_prefix = f"Network Retry Loop ({description}):" + effective_is_running = is_running or (lambda: True) + + def check_max_retries_and_log(current_retries: int, exception_info: str = "") -> bool: + """Check if max retries reached and log accordingly. + + Args: + current_retries: The current retry count. + exception_info: Additional context about the exception (e.g., "Timed out: ..."). + + Returns: + bool: True if max retries reached (should abort), False otherwise (should retry). + """ + prefix_with_info = f"{log_prefix} {exception_info}" if exception_info else log_prefix + + if max_retries < 0 or current_retries < max_retries: + _LOGGER.debug( + "%s Failed run number %s of %s. Retrying.", + prefix_with_info, + current_retries, + max_retries, + ) + return False + _LOGGER.exception( + "%s Failed run number %s of %s. Aborting.", + prefix_with_info, + current_retries, + max_retries, + ) + return True + + async def do_action() -> None: + if not stop_event: + await action_cb() + return + + action_cb_task = asyncio.create_task(action_cb()) + stop_task = asyncio.create_task(stop_event.wait()) + done, pending = await asyncio.wait( + (action_cb_task, stop_task), return_when=asyncio.FIRST_COMPLETED + ) + with contextlib.suppress(asyncio.CancelledError): + for task in pending: + task.cancel() + + if stop_task in done: + _LOGGER.debug("%s Cancelled", log_prefix) + return + + # Calling `result()` on `action_cb_task` will raise an exception if the task failed. + # this is important to propagate the error to the caller. + action_cb_task.result() + + _LOGGER.debug("%s Starting", log_prefix) + cur_interval = interval + retries = 0 + while effective_is_running(): + try: + await do_action() + if not repeat_on_success: + _LOGGER.debug("%s Action succeeded. Stopping loop.", log_prefix) + break + except RetryAfter as exc: + slack_time = 0.5 + # pylint: disable=protected-access + cur_interval = slack_time + exc._retry_after.total_seconds() + exception_info = f"{exc}. Adding {slack_time} seconds to the specified time." + + # Check max_retries for RetryAfter as well + if check_max_retries_and_log(retries, exception_info): + raise + except TimedOut as toe: + # If failure is due to timeout, we should retry asap. + cur_interval = 0 + exception_info = f"Timed out: {toe}." + + # Check max_retries for TimedOut as well + if check_max_retries_and_log(retries, exception_info): + raise + except InvalidToken: + _LOGGER.exception("%s Invalid token. Aborting retry loop.", log_prefix) + raise + except TelegramError as telegram_exc: + if on_err_cb: + on_err_cb(telegram_exc) + + if check_max_retries_and_log(retries): + raise + + # increase waiting times on subsequent errors up to 30secs + cur_interval = 1 if cur_interval == 0 else min(30, 1.5 * cur_interval) + else: + cur_interval = interval + finally: + retries += 1 + + if cur_interval: + await asyncio.sleep(cur_interval) diff --git a/telegram/ext/_utils/stack.py b/src/telegram/ext/_utils/stack.py similarity index 95% rename from telegram/ext/_utils/stack.py rename to src/telegram/ext/_utils/stack.py index a8fb3c3dd1e..9c7c097ec06 100644 --- a/telegram/ext/_utils/stack.py +++ b/src/telegram/ext/_utils/stack.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,16 +25,16 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ + from pathlib import Path from types import FrameType -from typing import Optional from telegram._utils.logging import get_logger _LOGGER = get_logger(__name__) -def was_called_by(frame: Optional[FrameType], caller: Path) -> bool: +def was_called_by(frame: FrameType | None, caller: Path) -> bool: """Checks if the passed frame was called by the specified file. Example: diff --git a/telegram/ext/_utils/trackingdict.py b/src/telegram/ext/_utils/trackingdict.py similarity index 87% rename from telegram/ext/_utils/trackingdict.py rename to src/telegram/ext/_utils/trackingdict.py index aeb0496b385..bf7af4d5dab 100644 --- a/telegram/ext/_utils/trackingdict.py +++ b/src/telegram/ext/_utils/trackingdict.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,8 +25,10 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ + from collections import UserDict -from typing import ClassVar, Generic, List, Mapping, Optional, Set, Tuple, TypeVar, Union +from collections.abc import Mapping +from typing import Final, Generic, TypeVar from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue @@ -45,35 +47,43 @@ class TrackingDict(UserDict, Generic[_KT, _VT]): * deleting values is considered writing """ - DELETED: ClassVar = object() + DELETED: Final = object() """Special marker indicating that an entry was deleted.""" __slots__ = ("_write_access_keys",) def __init__(self) -> None: super().__init__() - self._write_access_keys: Set[_KT] = set() + self._write_access_keys: set[_KT] = set() + + def __setitem__(self, key: _KT, value: _VT) -> None: + self.__track_write(key) + super().__setitem__(key, value) + + def __delitem__(self, key: _KT) -> None: + self.__track_write(key) + super().__delitem__(key) - def __track_write(self, key: Union[_KT, Set[_KT]]) -> None: + def __track_write(self, key: _KT | set[_KT]) -> None: if isinstance(key, set): self._write_access_keys |= key else: self._write_access_keys.add(key) - def pop_accessed_keys(self) -> Set[_KT]: + def pop_accessed_keys(self) -> set[_KT]: """Returns all keys that were write-accessed since the last time this method was called.""" out = self._write_access_keys self._write_access_keys = set() return out - def pop_accessed_write_items(self) -> List[Tuple[_KT, _VT]]: + def pop_accessed_write_items(self) -> list[tuple[_KT, _VT]]: """ Returns all keys & corresponding values as set of tuples that were write-accessed since the last time this method was called. If a key was deleted, the value will be :attr:`DELETED`. """ keys = self.pop_accessed_keys() - return [(key, self[key] if key in self else self.DELETED) for key in keys] + return [(key, self.get(key, self.DELETED)) for key in keys] def mark_as_accessed(self, key: _KT) -> None: """Use this method have the key returned again in the next call to @@ -83,14 +93,6 @@ def mark_as_accessed(self, key: _KT) -> None: # Override methods to track access - def __setitem__(self, key: _KT, value: _VT) -> None: - self.__track_write(key) - super().__setitem__(key, value) - - def __delitem__(self, key: _KT) -> None: - self.__track_write(key) - super().__delitem__(key) - def update_no_track(self, mapping: Mapping[_KT, _VT]) -> None: """Like ``update``, but doesn't count towards write access.""" for key, value in mapping.items(): @@ -99,7 +101,9 @@ def update_no_track(self, mapping: Mapping[_KT, _VT]) -> None: # Mypy seems a bit inconsistent about what it wants as types for `default` and return value # so we just ignore a bit def pop( # type: ignore[override] - self, key: _KT, default: _VT = DEFAULT_NONE # type: ignore[assignment] + self, + key: _KT, + default: _VT = DEFAULT_NONE, # type: ignore[assignment] ) -> _VT: if key in self: self.__track_write(key) @@ -113,7 +117,7 @@ def clear(self) -> None: # Mypy seems a bit inconsistent about what it wants as types for `default` and return value # so we just ignore a bit - def setdefault(self: "TrackingDict[_KT, _T]", key: _KT, default: Optional[_T] = None) -> _T: + def setdefault(self: "TrackingDict[_KT, _T]", key: _KT, default: _T | None = None) -> _T: if key in self: return self[key] diff --git a/telegram/ext/_utils/types.py b/src/telegram/ext/_utils/types.py similarity index 79% rename from telegram/ext/_utils/types.py rename to src/telegram/ext/_utils/types.py index 12a64ff9206..dab51c6601a 100644 --- a/telegram/ext/_utils/types.py +++ b/src/telegram/ext/_utils/types.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,22 +25,11 @@ user. Changes to this module are not considered breaking changes and may not be documented in the changelog. """ -from typing import ( - TYPE_CHECKING, - Any, - Callable, - Coroutine, - Dict, - List, - MutableMapping, - Tuple, - TypeVar, - Union, -) -if TYPE_CHECKING: - from typing import Optional +from collections.abc import Callable, Coroutine, MutableMapping +from typing import TYPE_CHECKING, Any, TypeVar +if TYPE_CHECKING: from telegram import Bot from telegram.ext import BaseRateLimiter, CallbackContext, JobQueue @@ -63,17 +52,17 @@ .. versionadded:: 20.0 """ -ConversationKey = Tuple[Union[int, str], ...] +ConversationKey = tuple[int | str, ...] ConversationDict = MutableMapping[ConversationKey, object] -"""Dict[Tuple[:obj:`int` | :obj:`str`, ...], Optional[:obj:`object`]]: +"""dict[tuple[:obj:`int` | :obj:`str`, ...], :obj:`object` | None]: Dicts as maintained by the :class:`telegram.ext.ConversationHandler`. .. versionadded:: 13.6 """ -CDCData = Tuple[List[Tuple[str, float, Dict[str, Any]]], Dict[str, str]] -"""Tuple[List[Tuple[:obj:`str`, :obj:`float`, Dict[:obj:`str`, :class:`object`]]], \ - Dict[:obj:`str`, :obj:`str`]]: Data returned by +CDCData = tuple[list[tuple[str, float, dict[str, Any]]], dict[str, str]] +"""tuple[list[tuple[:obj:`str`, :obj:`float`, dict[:obj:`str`, :class:`object`]]], \ + dict[:obj:`str`, :obj:`str`]]: Data returned by :attr:`telegram.ext.CallbackDataCache.persistence_data`. .. versionadded:: 13.6 @@ -99,12 +88,12 @@ .. versionadded:: 13.6 """ -JQ = TypeVar("JQ", bound=Union[None, "JobQueue"]) +JQ = TypeVar("JQ", bound="None | JobQueue") """Type of the job queue. .. versionadded:: 20.0""" -RL = TypeVar("RL", bound="Optional[BaseRateLimiter]") +RL = TypeVar("RL", bound="BaseRateLimiter | None") """Type of the rate limiter. .. versionadded:: 20.0""" @@ -113,4 +102,4 @@ """Type of the rate limiter arguments. .. versionadded:: 20.0""" -FilterDataDict = Dict[str, List[Any]] +FilterDataDict = dict[str, list[Any]] diff --git a/telegram/ext/_utils/webhookhandler.py b/src/telegram/ext/_utils/webhookhandler.py similarity index 81% rename from telegram/ext/_utils/webhookhandler.py rename to src/telegram/ext/_utils/webhookhandler.py index 05ad223df20..4fdd7798bb6 100644 --- a/telegram/ext/_utils/webhookhandler.py +++ b/src/telegram/ext/_utils/webhookhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,15 +20,24 @@ import asyncio import json from http import HTTPStatus +from pathlib import Path +from socket import socket from ssl import SSLContext from types import TracebackType -from typing import TYPE_CHECKING, Optional, Type +from typing import TYPE_CHECKING # Instead of checking for ImportError here, we do that in `updater.py`, where we import from # this module. Doing it here would be tricky, as the classes below subclass tornado classes import tornado.web from tornado.httpserver import HTTPServer +try: + from tornado.netutil import bind_unix_socket + + UNIX_AVAILABLE = True +except ImportError: + UNIX_AVAILABLE = False + from telegram import Update from telegram._utils.logging import get_logger from telegram.ext._extbot import ExtBot @@ -45,26 +54,42 @@ class WebhookServer: __slots__ = ( "_http_server", - "listen", - "port", - "is_running", "_server_lock", "_shutdown_lock", + "is_running", + "listen", + "port", + "unix", ) def __init__( - self, listen: str, port: int, webhook_app: "WebhookAppClass", ssl_ctx: Optional[SSLContext] + self, + listen: str, + port: int, + webhook_app: "WebhookAppClass", + ssl_ctx: SSLContext | None, + unix: str | Path | socket | None = None, ): + if unix and not UNIX_AVAILABLE: + raise RuntimeError("This OS does not support binding unix sockets.") self._http_server = HTTPServer(webhook_app, ssl_options=ssl_ctx) self.listen = listen self.port = port self.is_running = False + self.unix = None + if unix and isinstance(unix, socket): + self.unix = unix + elif unix: + self.unix = bind_unix_socket(str(unix)) self._server_lock = asyncio.Lock() self._shutdown_lock = asyncio.Lock() - async def serve_forever(self, ready: Optional[asyncio.Event] = None) -> None: + async def serve_forever(self, ready: asyncio.Event | None = None) -> None: async with self._server_lock: - self._http_server.listen(self.port, address=self.listen) + if self.unix: + self._http_server.add_socket(self.unix) + else: + self._http_server.listen(self.port, address=self.listen) self.is_running = True if ready is not None: @@ -91,7 +116,7 @@ def __init__( webhook_path: str, bot: "Bot", update_queue: asyncio.Queue, - secret_token: Optional[str] = None, + secret_token: str | None = None, ): self.shared_objects = { "bot": bot, @@ -109,7 +134,7 @@ def log_request(self, handler: tornado.web.RequestHandler) -> None: class TelegramHandler(tornado.web.RequestHandler): """BaseHandler that processes incoming requests from Telegram""" - __slots__ = ("bot", "update_queue", "secret_token") + __slots__ = ("bot", "secret_token", "update_queue") SUPPORTED_METHODS = ("POST",) # type: ignore[assignment] @@ -117,8 +142,8 @@ def initialize(self, bot: "Bot", update_queue: asyncio.Queue, secret_token: str) """Initialize for each request - that's the interface provided by tornado""" # pylint: disable=attribute-defined-outside-init self.bot = bot - self.update_queue = update_queue # skipcq: PYL-W0201 - self.secret_token = secret_token # skipcq: PYL-W0201 + self.update_queue = update_queue + self.secret_token = secret_token if secret_token: _LOGGER.debug( "The webhook server has a secret token, expecting it in incoming requests now" @@ -143,9 +168,13 @@ 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( + HTTPStatus.BAD_REQUEST, reason="Update could not be processed" + ) from exc if update: _LOGGER.debug( @@ -181,9 +210,9 @@ def _validate_post(self) -> None: def log_exception( self, - typ: Optional[Type[BaseException]], - value: Optional[BaseException], - tb: Optional[TracebackType], + typ: type[BaseException] | None, + value: BaseException | None, + tb: TracebackType | None, ) -> None: """Override the default logging and instead use our custom logging.""" _LOGGER.debug( diff --git a/telegram/ext/filters.py b/src/telegram/ext/filters.py similarity index 74% rename from telegram/ext/filters.py rename to src/telegram/ext/filters.py index b0772736ddb..417fd6010a1 100644 --- a/telegram/ext/filters.py +++ b/src/telegram/ext/filters.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -34,6 +34,9 @@ * Filters which do both (like ``Filters.text``) are now split as ready-to-use version ``filters.TEXT`` and class version ``filters.Text(...)``. +.. versionchanged:: 22.0 + Removed deprecated attribute `CHAT`. + """ __all__ = ( @@ -41,80 +44,89 @@ "ANIMATION", "ATTACHMENT", "AUDIO", - "BaseFilter", + "BOOST_ADDED", "CAPTION", - "CHAT", + "CHECKLIST", "COMMAND", "CONTACT", - "Caption", - "CaptionEntity", - "CaptionRegex", - "Chat", - "ChatType", - "Command", - "Dice", - "Document", - "Entity", + "DIRECT_MESSAGES", + "EFFECT_ID", + "FORUM", "FORWARDED", - "ForwardedFrom", "GAME", + "GIVEAWAY", + "GIVEAWAY_WINNERS", "HAS_MEDIA_SPOILER", "HAS_PROTECTED_CONTENT", "INVOICE", "IS_AUTOMATIC_FORWARD", + "IS_FROM_OFFLINE", "IS_TOPIC_MESSAGE", "LOCATION", - "Language", - "MessageFilter", + "PAID_MEDIA", "PASSPORT_DATA", "PHOTO", "POLL", + "PREMIUM_USER", "REPLY", - "Regex", - "Sticker", + "REPLY_TO_STORY", + "SENDER_BOOST_COUNT", + "STORY", "SUCCESSFUL_PAYMENT", - "SenderChat", - "StatusUpdate", + "SUGGESTED_POST_INFO", "TEXT", - "Text", "USER", "USER_ATTACHMENT", - "PREMIUM_USER", - "UpdateFilter", - "UpdateType", - "User", "VENUE", "VIA_BOT", "VIDEO", "VIDEO_NOTE", "VOICE", + "BaseFilter", + "Caption", + "CaptionEntity", + "CaptionRegex", + "Chat", + "ChatType", + "Command", + "Dice", + "Document", + "Entity", + "ForwardedFrom", + "Language", + "Mention", + "MessageFilter", + "Regex", + "SenderChat", + "StatusUpdate", + "Sticker", + "SuccessfulPayment", + "Text", + "UpdateFilter", + "UpdateType", + "User", "ViaBot", ) - import mimetypes import re from abc import ABC, abstractmethod -from typing import ( - Collection, - Dict, - FrozenSet, - List, - Match, - NoReturn, - Optional, - Pattern, - Sequence, - Set, - Tuple, - Union, - cast, -) +from collections.abc import Collection, Iterable, Sequence +from re import Match, Pattern +from typing import NoReturn, cast from telegram import Chat as TGChat -from telegram import Message, MessageEntity, Update +from telegram import ( + Message, + MessageEntity, + MessageOriginChannel, + MessageOriginChat, + MessageOriginUser, + Update, +) from telegram import User as TGUser from telegram._utils.types import SCT from telegram.constants import DiceEmoji as DiceEmojiEnum +from telegram.ext._utils._update_parsing import parse_chat_id, parse_username from telegram.ext._utils.types import FilterDataDict @@ -175,47 +187,66 @@ class variable. (depends on the handler). """ - __slots__ = ("_name", "_data_filter") + __slots__ = ("_data_filter", "_name") - def __init__(self, name: Optional[str] = None, data_filter: bool = False): + def __init__(self, name: str | None = None, data_filter: bool = False): self._name = self.__class__.__name__ if name is None else name self._data_filter = data_filter - def check_update( # skipcq: PYL-R0201 - self, update: Update - ) -> Optional[Union[bool, FilterDataDict]]: - """Checks if the specified update should be handled by this filter. - - Args: - update (:class:`telegram.Update`): The update to check. + def __and__(self, other: "BaseFilter") -> "BaseFilter": + """Defines `AND` bitwise operator for :class:`BaseFilter` object. + The combined filter accepts an update only if it is accepted by both filters. + For example, ``filters.PHOTO & filters.CAPTION`` will only accept messages that contain + both a photo and a caption. Returns: - :obj:`bool`: :obj:`True` if the update contains one of - :attr:`~telegram.Update.channel_post`, :attr:`~telegram.Update.message`, - :attr:`~telegram.Update.edited_channel_post` or - :attr:`~telegram.Update.edited_message`, :obj:`False` otherwise. + :obj:`BaseFilter` """ - if ( # Only message updates should be handled. - update.channel_post - or update.message - or update.edited_channel_post - or update.edited_message - ): - return True - return False - - def __and__(self, other: "BaseFilter") -> "BaseFilter": return _MergedFilter(self, and_filter=other) def __or__(self, other: "BaseFilter") -> "BaseFilter": + """Defines `OR` bitwise operator for :class:`BaseFilter` object. + The combined filter accepts an update only if it is accepted by any of the filters. + For example, ``filters.PHOTO | filters.CAPTION`` will only accept messages that contain + photo or caption or both. + + Returns: + :obj:`BaseFilter` + """ return _MergedFilter(self, or_filter=other) def __xor__(self, other: "BaseFilter") -> "BaseFilter": + """Defines `XOR` bitwise operator for :class:`BaseFilter` object. + The combined filter accepts an update only if it is accepted by any of the filters and + not both of them. For example, ``filters.PHOTO ^ filters.CAPTION`` will only accept + messages that contain photo or caption, not both of them. + + Returns: + :obj:`BaseFilter` + """ return _XORFilter(self, other) def __invert__(self) -> "BaseFilter": + """Defines `NOT` bitwise operator for :class:`BaseFilter` object. + The combined filter accepts an update only if it is accepted by any of the filters. + For example, ``~ filters.PHOTO`` will only accept messages that do not contain photo. + + Returns: + :obj:`BaseFilter` + """ return _InvertedFilter(self) + def __repr__(self) -> str: + """Gives name for this filter. + + .. seealso:: + :meth:`name` + + Returns: + :obj:`str`: + """ + return self.name + @property def data_filter(self) -> bool: """:obj:`bool`: Whether this filter is a data filter.""" @@ -234,13 +265,37 @@ def name(self) -> str: def name(self, name: str) -> None: self._name = name - def __repr__(self) -> str: - return self.name + def check_update(self, update: Update) -> bool | FilterDataDict | None: + """Checks if the specified update should be handled by this filter. + + .. versionchanged:: 21.1 + This filter now also returns :obj:`True` if the update contains + :attr:`~telegram.Update.business_message` + or :attr:`~telegram.Update.edited_business_message`. + + Args: + update (:class:`telegram.Update`): The update to check. + + Returns: + :obj:`bool`: :obj:`True` if the update contains one of + :attr:`~telegram.Update.channel_post`, :attr:`~telegram.Update.message`, + :attr:`~telegram.Update.edited_channel_post`, + :attr:`~telegram.Update.edited_message`, :attr:`telegram.Update.business_message`, + :attr:`telegram.Update.edited_business_message`, or :obj:`False` otherwise. + """ + return bool( # Only message updates should be handled. + update.channel_post + or update.message + or update.edited_channel_post + or update.edited_message + or update.business_message + or update.edited_business_message + ) class MessageFilter(BaseFilter): """Base class for all Message Filters. In contrast to :class:`UpdateFilter`, the object passed - to :meth:`filter` is :obj:`telegram.Update.effective_message`. + to :meth:`filter` is :attr:`telegram.Update.effective_message`. Please see :class:`BaseFilter` for details on how to create custom filters. @@ -250,7 +305,7 @@ class MessageFilter(BaseFilter): __slots__ = () - def check_update(self, update: Update) -> Optional[Union[bool, FilterDataDict]]: + def check_update(self, update: Update) -> bool | FilterDataDict | None: """Checks if the specified update should be handled by this filter by passing :attr:`~telegram.Update.effective_message` to :meth:`filter`. @@ -258,7 +313,7 @@ def check_update(self, update: Update) -> Optional[Union[bool, FilterDataDict]]: update (:class:`telegram.Update`): The update to check. Returns: - :obj:`bool` | Dict[:obj:`str`, :obj:`list`] | :obj:`None`: If the update should be + :obj:`bool` | dict[:obj:`str`, :obj:`list`] | :obj:`None`: If the update should be handled by this filter, returns :obj:`True` or a dict with lists, in case the filter is a data filter. If the update should not be handled by this filter, :obj:`False` or :obj:`None`. @@ -268,7 +323,7 @@ def check_update(self, update: Update) -> Optional[Union[bool, FilterDataDict]]: return False @abstractmethod - def filter(self, message: Message) -> Optional[Union[bool, FilterDataDict]]: + def filter(self, message: Message) -> bool | FilterDataDict | None: """This method must be overwritten. Args: @@ -292,14 +347,14 @@ class UpdateFilter(BaseFilter): __slots__ = () - def check_update(self, update: Update) -> Optional[Union[bool, FilterDataDict]]: + def check_update(self, update: Update) -> bool | FilterDataDict | None: """Checks if the specified update should be handled by this filter. Args: update (:class:`telegram.Update`): The update to check. Returns: - :obj:`bool` | Dict[:obj:`str`, :obj:`list`] | :obj:`None`: If the update should be + :obj:`bool` | dict[:obj:`str`, :obj:`list`] | :obj:`None`: If the update should be handled by this filter, returns :obj:`True` or a dict with lists, in case the filter is a data filter. If the update should not be handled by this filter, :obj:`False` or :obj:`None`. @@ -307,7 +362,7 @@ def check_update(self, update: Update) -> Optional[Union[bool, FilterDataDict]]: return self.filter(update) if super().check_update(update) else False @abstractmethod - def filter(self, update: Update) -> Optional[Union[bool, FilterDataDict]]: + def filter(self, update: Update) -> bool | FilterDataDict | None: """This method must be overwritten. Args: @@ -341,7 +396,7 @@ def name(self) -> str: return f"" @name.setter - def name(self, name: str) -> NoReturn: + def name(self, _: str) -> NoReturn: raise RuntimeError("Cannot set name for combined filters.") @@ -355,13 +410,13 @@ class _MergedFilter(UpdateFilter): """ - __slots__ = ("base_filter", "and_filter", "or_filter") + __slots__ = ("and_filter", "base_filter", "or_filter") def __init__( self, base_filter: BaseFilter, - and_filter: Optional[BaseFilter] = None, - or_filter: Optional[BaseFilter] = None, + and_filter: BaseFilter | None = None, + or_filter: BaseFilter | None = None, ): super().__init__() self.base_filter = base_filter @@ -379,7 +434,7 @@ def __init__( self.data_filter = True @staticmethod - def _merge(base_output: Union[bool, Dict], comp_output: Union[bool, Dict]) -> FilterDataDict: + def _merge(base_output: bool | dict, comp_output: bool | dict) -> FilterDataDict: base = base_output if isinstance(base_output, dict) else {} comp = comp_output if isinstance(comp_output, dict) else {} for k in comp: @@ -396,7 +451,7 @@ def _merge(base_output: Union[bool, Dict], comp_output: Union[bool, Dict]) -> Fi return base # pylint: disable=too-many-return-statements - def filter(self, update: Update) -> Union[bool, FilterDataDict]: + def filter(self, update: Update) -> bool | FilterDataDict: base_output = self.base_filter.check_update(update) # We need to check if the filters are data filters and if so return the merged data. # If it's not a data filter or an or_filter but no matches return bool @@ -432,7 +487,7 @@ def name(self) -> str: ) @name.setter - def name(self, name: str) -> NoReturn: + def name(self, _: str) -> NoReturn: raise RuntimeError("Cannot set name for combined filters.") @@ -446,7 +501,7 @@ class _XORFilter(UpdateFilter): """ - __slots__ = ("base_filter", "xor_filter", "merged_filter") + __slots__ = ("base_filter", "merged_filter", "xor_filter") def __init__(self, base_filter: BaseFilter, xor_filter: BaseFilter): super().__init__() @@ -454,7 +509,7 @@ def __init__(self, base_filter: BaseFilter, xor_filter: BaseFilter): self.xor_filter = xor_filter self.merged_filter = (base_filter & ~xor_filter) | (~base_filter & xor_filter) - def filter(self, update: Update) -> Optional[Union[bool, FilterDataDict]]: + def filter(self, update: Update) -> bool | FilterDataDict | None: return self.merged_filter.check_update(update) @property @@ -462,18 +517,18 @@ def name(self) -> str: return f"<{self.base_filter} xor {self.xor_filter}>" @name.setter - def name(self, name: str) -> NoReturn: + def name(self, _: str) -> NoReturn: raise RuntimeError("Cannot set name for combined filters.") class _All(MessageFilter): __slots__ = () - def filter(self, message: Message) -> bool: + def filter(self, message: Message) -> bool: # noqa: ARG002 return True -ALL = _All(name="filters.ALL") +ALL = _All(name="filters.ALL") # pylint: disable=invalid-name """All Messages.""" @@ -508,7 +563,7 @@ def filter(self, message: Message) -> bool: return bool(message.audio) -AUDIO = _Audio(name="filters.AUDIO") +AUDIO = _Audio(name="filters.AUDIO") # pylint: disable=invalid-name """Messages that contain :attr:`telegram.Message.audio`.""" @@ -523,14 +578,14 @@ class Caption(MessageFilter): :attr:`telegram.ext.filters.CAPTION` Args: - strings (List[:obj:`str`] | Tuple[:obj:`str`], optional): Which captions to allow. Only + strings (list[:obj:`str`] | tuple[:obj:`str`], optional): Which captions to allow. Only exact matches are allowed. If not specified, will allow any message with a caption. """ __slots__ = ("strings",) - def __init__(self, strings: Optional[Union[List[str], Tuple[str, ...]]] = None): - self.strings: Optional[Sequence[str]] = strings + def __init__(self, strings: list[str] | tuple[str, ...] | None = None): + self.strings: Sequence[str] | None = strings super().__init__(name=f"filters.Caption({strings})" if strings else "filters.CAPTION") def filter(self, message: Message) -> bool: @@ -592,33 +647,31 @@ class CaptionRegex(MessageFilter): __slots__ = ("pattern",) - def __init__(self, pattern: Union[str, Pattern[str]]): + def __init__(self, pattern: str | Pattern[str]): if isinstance(pattern, str): pattern = re.compile(pattern) self.pattern: Pattern[str] = pattern super().__init__(name=f"filters.CaptionRegex({self.pattern})", data_filter=True) - def filter(self, message: Message) -> Optional[Dict[str, List[Match[str]]]]: - if message.caption: - match = self.pattern.search(message.caption) - if match: - return {"matches": [match]} + def filter(self, message: Message) -> dict[str, list[Match[str]]] | None: + if message.caption and (match := self.pattern.search(message.caption)): + return {"matches": [match]} return {} class _ChatUserBaseFilter(MessageFilter, ABC): __slots__ = ( "_chat_id_name", - "_username_name", - "allow_empty", "_chat_ids", + "_username_name", "_usernames", + "allow_empty", ) def __init__( self, - chat_id: Optional[SCT[int]] = None, - username: Optional[SCT[str]] = None, + chat_id: SCT[int] | None = None, + username: SCT[str] | None = None, allow_empty: bool = False, ): super().__init__() @@ -626,50 +679,33 @@ def __init__( self._username_name: str = "username" self.allow_empty: bool = allow_empty - self._chat_ids: Set[int] = set() - self._usernames: Set[str] = set() + self._chat_ids: set[int] = set() + self._usernames: set[str] = set() self._set_chat_ids(chat_id) self._set_usernames(username) @abstractmethod - def _get_chat_or_user(self, message: Message) -> Union[TGChat, TGUser, None]: - ... + def _get_chat_or_user(self, message: Message) -> TGChat | TGUser | None: ... - @staticmethod - def _parse_chat_id(chat_id: Optional[SCT[int]]) -> Set[int]: - if chat_id is None: - return set() - if isinstance(chat_id, int): - return {chat_id} - return set(chat_id) - - @staticmethod - def _parse_username(username: Optional[SCT[str]]) -> Set[str]: - if username is None: - return set() - if isinstance(username, str): - return {username[1:] if username.startswith("@") else username} - return {chat[1:] if chat.startswith("@") else chat for chat in username} - - def _set_chat_ids(self, chat_id: Optional[SCT[int]]) -> None: + def _set_chat_ids(self, chat_id: SCT[int] | None) -> None: if chat_id and self._usernames: raise RuntimeError( f"Can't set {self._chat_id_name} in conjunction with (already set) " f"{self._username_name}s." ) - self._chat_ids = self._parse_chat_id(chat_id) + self._chat_ids = set(parse_chat_id(chat_id)) - def _set_usernames(self, username: Optional[SCT[str]]) -> None: + def _set_usernames(self, username: SCT[str] | None) -> None: if username and self._chat_ids: raise RuntimeError( f"Can't set {self._username_name} in conjunction with (already set) " f"{self._chat_id_name}s." ) - self._usernames = self._parse_username(username) + self._usernames = set(parse_username(username)) @property - def chat_ids(self) -> FrozenSet[int]: + def chat_ids(self) -> frozenset[int]: return frozenset(self._chat_ids) @chat_ids.setter @@ -677,7 +713,7 @@ def chat_ids(self, chat_id: SCT[int]) -> None: self._set_chat_ids(chat_id) @property - def usernames(self) -> FrozenSet[str]: + def usernames(self) -> frozenset[str]: """Which username(s) to allow through. Warning: @@ -710,7 +746,7 @@ def add_usernames(self, username: SCT[str]) -> None: f"{self._chat_id_name}s." ) - parsed_username = self._parse_username(username) + parsed_username = set(parse_username(username)) self._usernames |= parsed_username def _add_chat_ids(self, chat_id: SCT[int]) -> None: @@ -720,7 +756,7 @@ def _add_chat_ids(self, chat_id: SCT[int]) -> None: f"{self._username_name}s." ) - parsed_chat_id = self._parse_chat_id(chat_id) + parsed_chat_id = set(parse_chat_id(chat_id)) self._chat_ids |= parsed_chat_id @@ -738,7 +774,7 @@ def remove_usernames(self, username: SCT[str]) -> None: f"{self._chat_id_name}s." ) - parsed_username = self._parse_username(username) + parsed_username = set(parse_username(username)) self._usernames -= parsed_username def _remove_chat_ids(self, chat_id: SCT[int]) -> None: @@ -747,7 +783,7 @@ def _remove_chat_ids(self, chat_id: SCT[int]) -> None: f"Can't set {self._chat_id_name} in conjunction with (already set) " f"{self._username_name}s." ) - parsed_chat_id = self._parse_chat_id(chat_id) + parsed_chat_id = set(parse_chat_id(chat_id)) self._chat_ids -= parsed_chat_id def filter(self, message: Message) -> bool: @@ -764,11 +800,11 @@ def filter(self, message: Message) -> bool: def name(self) -> str: return ( f"filters.{self.__class__.__name__}(" - f'{", ".join(str(s) for s in (self.usernames or self.chat_ids))})' + f"{', '.join(str(s) for s in (self.usernames or self.chat_ids))})" ) @name.setter - def name(self, name: str) -> NoReturn: + def name(self, _: str) -> NoReturn: raise RuntimeError(f"Cannot set name for filters.{self.__class__.__name__}") @@ -805,7 +841,7 @@ class Chat(_ChatUserBaseFilter): __slots__ = () - def _get_chat_or_user(self, message: Message) -> Optional[TGChat]: + def _get_chat_or_user(self, message: Message) -> TGChat | None: return message.chat def add_chat_ids(self, chat_id: SCT[int]) -> None: @@ -829,17 +865,6 @@ def remove_chat_ids(self, chat_id: SCT[int]) -> None: return super()._remove_chat_ids(chat_id) -class _Chat(MessageFilter): - __slots__ = () - - def filter(self, message: Message) -> bool: - return bool(message.chat) - - -CHAT = _Chat(name="filters.CHAT") -"""This filter filters *any* message that has a :attr:`telegram.Message.chat`.""" - - class ChatType: # A convenience namespace for Chat types. """Subset for filtering the type of chat. @@ -899,6 +924,20 @@ def filter(self, message: Message) -> bool: """Updates from supergroup.""" +class _Checklist(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.checklist) + + +CHECKLIST = _Checklist(name="filters.CHECKLIST") +"""Messages that contain :attr:`telegram.Message.checklist`. + +.. versionadded:: 22.3 +""" + + class Command(MessageFilter): """ Messages with a :attr:`telegram.MessageEntity.BOT_COMMAND`. By default, only allows @@ -959,10 +998,10 @@ def filter(self, message: Message) -> bool: class _Dice(MessageFilter): __slots__ = ("emoji", "values") - def __init__(self, values: Optional[SCT[int]] = None, emoji: Optional[DiceEmojiEnum] = None): + def __init__(self, values: SCT[int] | None = None, emoji: DiceEmojiEnum | None = None): super().__init__() - self.emoji: Optional[DiceEmojiEnum] = emoji - self.values: Optional[Collection[int]] = [values] if isinstance(values, int) else values + self.emoji: DiceEmojiEnum | None = emoji + self.values: Collection[int] | None = [values] if isinstance(values, int) else values if emoji: # for filters.Dice.BASKETBALL self.name = f"filters.Dice.{emoji.name}" @@ -974,15 +1013,15 @@ def __init__(self, values: Optional[SCT[int]] = None, emoji: Optional[DiceEmojiE self.name = "filters.Dice.ALL" def filter(self, message: Message) -> bool: - if not message.dice: # no dice + if not (dice := message.dice): # no dice return False if self.emoji: - emoji_match = message.dice.emoji == self.emoji + emoji_match = dice.emoji == self.emoji if self.values: - return message.dice.value in self.values and emoji_match # emoji and value + return dice.value in self.values and emoji_match # emoji and value return emoji_match # emoji, no value - return message.dice.value in self.values if self.values else True # no emoji, only value + return dice.value in self.values if self.values else True # no emoji, only value class Dice(_Dice): @@ -1078,7 +1117,7 @@ class Dice(_Dice): def __init__(self, values: SCT[int]): super().__init__(values, emoji=DiceEmojiEnum.DICE) - DICE = _Dice(emoji=DiceEmojiEnum.DICE) # skipcq: PTC-W0052 + DICE = _Dice(emoji=DiceEmojiEnum.DICE) """Dice messages with the emoji 🎲. Matches any dice value.""" class Football(_Dice): @@ -1112,6 +1151,22 @@ def __init__(self, values: SCT[int]): """Dice messages with the emoji 🎰. Matches any dice value.""" +class _DirectMessages(UpdateFilter): + __slots__ = () + + def filter(self, update: Update) -> bool: + return bool(update.effective_chat and update.effective_chat.is_direct_messages) + + +DIRECT_MESSAGES = _DirectMessages(name="filters.DIRECT_MESSAGES") +"""Filter chats which are the direct messages for a channel. + +.. seealso:: :attr:`telegram.Chat.is_direct_messages` + +.. versionadded:: 22.4 +""" + + class Document: """ Subset for messages containing a document/file. @@ -1207,7 +1262,7 @@ class FileExtension(MessageFilter): __slots__ = ("_file_extension", "is_case_sensitive") - def __init__(self, file_extension: Optional[str], case_sensitive: bool = False): + def __init__(self, file_extension: str | None, case_sensitive: bool = False): super().__init__() self.is_case_sensitive: bool = case_sensitive if file_extension is None: @@ -1251,7 +1306,7 @@ class MimeType(MessageFilter): __slots__ = ("mimetype",) def __init__(self, mimetype: str): - self.mimetype: str = mimetype # skipcq: PTC-W0052 + self.mimetype: str = mimetype super().__init__(name=f"filters.Document.MimeType('{self.mimetype}')") def filter(self, message: Message) -> bool: @@ -1293,6 +1348,19 @@ def filter(self, message: Message) -> bool: """Use as ``filters.Document.ZIP``.""" +class _EffectId(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.effect_id) + + +EFFECT_ID = _EffectId(name="filters.EFFECT_ID") +"""Messages that contain :attr:`telegram.Message.effect_id`. + +.. versionadded:: 21.3""" + + class Entity(MessageFilter): """ Filters messages to only allow those which have a :class:`telegram.MessageEntity` @@ -1317,32 +1385,58 @@ def filter(self, message: Message) -> bool: return any(entity.type == self.entity_type for entity in message.entities) +class _Forum(UpdateFilter): + __slots__ = () + + def filter(self, update: Update) -> bool: + return bool(update.effective_chat and update.effective_chat.is_forum) + + +FORUM = _Forum(name="filters.FORUM") +"""Messages that are from a forum (topics enabled) chat. + +.. versionadded:: 22.4 +""" + + class _Forwarded(MessageFilter): __slots__ = () def filter(self, message: Message) -> bool: - return bool(message.forward_date) + return bool(message.forward_origin) FORWARDED = _Forwarded(name="filters.FORWARDED") -"""Messages that contain :attr:`telegram.Message.forward_date`.""" +"""Messages that contain :attr:`telegram.Message.forward_origin`. + +.. versionchanged:: 20.8 + Now based on :attr:`telegram.Message.forward_origin` instead of + ``telegram.Message.forward_date``. +""" class ForwardedFrom(_ChatUserBaseFilter): """Filters messages to allow only those which are forwarded from the specified chat ID(s) - or username(s) based on :attr:`telegram.Message.forward_from` and - :attr:`telegram.Message.forward_from_chat`. + or username(s) based on :attr:`telegram.Message.forward_origin` and in particular + + * :attr:`telegram.MessageOriginUser.sender_user` + * :attr:`telegram.MessageOriginChat.sender_chat` + * :attr:`telegram.MessageOriginChannel.chat` .. versionadded:: 13.5 + .. versionchanged:: 20.8 + Was previously based on ``telegram.Message.forward_from`` and + ``telegram.Message.forward_from_chat``. + Examples: ``MessageHandler(filters.ForwardedFrom(chat_id=1234), callback_method)`` Note: When a user has disallowed adding a link to their account while forwarding their - messages, this filter will *not* work since both - :attr:`telegram.Message.forward_from` and - :attr:`telegram.Message.forward_from_chat` are :obj:`None`. However, this behaviour + messages, this filter will *not* work since + :attr:`telegram.Message.forward_origin` will be of type + :class:`telegram.MessageOriginHiddenUser`. However, this behaviour is undocumented and might be changed by Telegram. Warning: @@ -1372,8 +1466,18 @@ class ForwardedFrom(_ChatUserBaseFilter): __slots__ = () - def _get_chat_or_user(self, message: Message) -> Union[TGUser, TGChat, None]: - return message.forward_from or message.forward_from_chat + def _get_chat_or_user(self, message: Message) -> TGUser | TGChat | None: + if (forward_origin := message.forward_origin) is None: + return None + + if isinstance(forward_origin, MessageOriginUser): + return forward_origin.sender_user + if isinstance(forward_origin, MessageOriginChat): + return forward_origin.sender_chat + if isinstance(forward_origin, MessageOriginChannel): + return forward_origin.chat + + return None def add_chat_ids(self, chat_id: SCT[int]) -> None: """ @@ -1407,6 +1511,28 @@ def filter(self, message: Message) -> bool: """Messages that contain :attr:`telegram.Message.game`.""" +class _Giveaway(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.giveaway) + + +GIVEAWAY = _Giveaway(name="filters.GIVEAWAY") +"""Messages that contain :attr:`telegram.Message.giveaway`.""" + + +class _GiveawayWinners(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.giveaway_winners) + + +GIVEAWAY_WINNERS = _GiveawayWinners(name="filters.GIVEAWAY_WINNERS") +"""Messages that contain :attr:`telegram.Message.giveaway_winners`.""" + + class _HasMediaSpoiler(MessageFilter): __slots__ = () @@ -1474,6 +1600,20 @@ def filter(self, message: Message) -> bool: """ +class _IsFromOffline(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.is_from_offline) + + +IS_FROM_OFFLINE = _IsFromOffline(name="filters.IS_FROM_OFFLINE") +"""Messages that contain :attr:`telegram.Message.is_from_offline`. + + .. versionadded:: 21.1 +""" + + class Language(MessageFilter): """Filters messages to only allow those which are from users with a certain language code. @@ -1496,10 +1636,10 @@ class Language(MessageFilter): def __init__(self, lang: SCT[str]): if isinstance(lang, str): - lang = cast(str, lang) + lang = cast("str", lang) self.lang: Sequence[str] = [lang] else: - lang = cast(List[str], lang) + lang = cast("list[str]", lang) self.lang = lang super().__init__(name=f"filters.Language({self.lang})") @@ -1522,6 +1662,87 @@ def filter(self, message: Message) -> bool: """Messages that contain :attr:`telegram.Message.location`.""" +class Mention(MessageFilter): + """Messages containing mentions of specified users or chats. + + Examples: + .. code-block:: python + + MessageHandler(filters.Mention("username"), callback) + MessageHandler(filters.Mention(["@username", 123456]), callback) + + .. versionadded:: 20.7 + + Args: + mentions (:obj:`int` | :obj:`str` | :class:`telegram.User` | Collection[:obj:`int` | \ + :obj:`str` | :class:`telegram.User`]): + Specifies the users and chats to filter for. Messages that do not mention at least one + of the specified users or chats will not be handled. Leading ``'@'`` s in usernames + will be discarded. + """ + + __slots__ = ("_mentions",) + + def __init__(self, mentions: SCT[int | str | TGUser]): + super().__init__(name=f"filters.Mention({mentions})") + if isinstance(mentions, Iterable) and not isinstance(mentions, str): + self._mentions = {self._fix_mention_username(mention) for mention in mentions} + else: + self._mentions = {self._fix_mention_username(mentions)} + + @staticmethod + def _fix_mention_username(mention: int | str | TGUser) -> int | str | TGUser: + if not isinstance(mention, str): + return mention + return mention.lstrip("@") + + @classmethod + def _check_mention(cls, message: Message, mention: int | str | TGUser) -> bool: + if not message.entities: + return False + + entity_texts = message.parse_entities( + types=[MessageEntity.MENTION, MessageEntity.TEXT_MENTION] + ) + + if isinstance(mention, TGUser): + return any( + mention.id == entity.user.id + or mention.username == entity.user.username + or mention.username == cls._fix_mention_username(entity_texts[entity]) + for entity in message.entities + if entity.user + ) or any( + mention.username == cls._fix_mention_username(entity_text) + for entity_text in entity_texts.values() + ) + if isinstance(mention, int): + return bool( + any(mention == entity.user.id for entity in message.entities if entity.user) + ) + return any( + mention == cls._fix_mention_username(entity_text) + for entity_text in entity_texts.values() + ) + + 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__ = () @@ -1590,17 +1811,15 @@ class Regex(MessageFilter): __slots__ = ("pattern",) - def __init__(self, pattern: Union[str, Pattern[str]]): + def __init__(self, pattern: str | Pattern[str]): if isinstance(pattern, str): pattern = re.compile(pattern) self.pattern: Pattern[str] = pattern super().__init__(name=f"filters.Regex({self.pattern})", data_filter=True) - def filter(self, message: Message) -> Optional[Dict[str, List[Match[str]]]]: - if message.text: - match = self.pattern.search(message.text) - if match: - return {"matches": [match]} + def filter(self, message: Message) -> dict[str, list[Match[str]]] | None: + if message.text and (match := self.pattern.search(message.text)): + return {"matches": [match]} return {} @@ -1708,7 +1927,7 @@ def add_chat_ids(self, chat_id: SCT[int]) -> None: """ return super()._add_chat_ids(chat_id) - def _get_chat_or_user(self, message: Message) -> Optional[TGChat]: + def _get_chat_or_user(self, message: Message) -> TGChat | None: return message.sender_chat def remove_chat_ids(self, chat_id: SCT[int]) -> None: @@ -1731,6 +1950,9 @@ class StatusUpdate: Caution: ``filters.StatusUpdate`` itself is *not* a filter, but just a convenience namespace. + + .. versionchanged:: 22.0 + Removed deprecated attribute `USER_SHARED`. """ __slots__ = () @@ -1740,36 +1962,62 @@ class _All(UpdateFilter): def filter(self, update: Update) -> bool: return bool( - StatusUpdate.NEW_CHAT_MEMBERS.check_update(update) - or StatusUpdate.LEFT_CHAT_MEMBER.check_update(update) - or StatusUpdate.NEW_CHAT_TITLE.check_update(update) - or StatusUpdate.NEW_CHAT_PHOTO.check_update(update) - or StatusUpdate.DELETE_CHAT_PHOTO.check_update(update) + # keep this alphabetically sorted for easier maintenance + StatusUpdate.CHAT_BACKGROUND_SET.check_update(update) or StatusUpdate.CHAT_CREATED.check_update(update) + or StatusUpdate.CHAT_SHARED.check_update(update) + or StatusUpdate.CHECKLIST_TASKS_ADDED.check_update(update) + or StatusUpdate.CHECKLIST_TASKS_DONE.check_update(update) + or StatusUpdate.CONNECTED_WEBSITE.check_update(update) + or StatusUpdate.DIRECT_MESSAGE_PRICE_CHANGED.check_update(update) + or StatusUpdate.DELETE_CHAT_PHOTO.check_update(update) + or StatusUpdate.FORUM_TOPIC_CLOSED.check_update(update) + or StatusUpdate.FORUM_TOPIC_CREATED.check_update(update) + or StatusUpdate.FORUM_TOPIC_EDITED.check_update(update) + or StatusUpdate.FORUM_TOPIC_REOPENED.check_update(update) + or StatusUpdate.GENERAL_FORUM_TOPIC_HIDDEN.check_update(update) + or StatusUpdate.GENERAL_FORUM_TOPIC_UNHIDDEN.check_update(update) + or StatusUpdate.GIFT.check_update(update) + or StatusUpdate.GIFT_UPGRADE_SENT.check_update(update) + or StatusUpdate.GIVEAWAY_COMPLETED.check_update(update) + or StatusUpdate.GIVEAWAY_CREATED.check_update(update) + or StatusUpdate.LEFT_CHAT_MEMBER.check_update(update) or StatusUpdate.MESSAGE_AUTO_DELETE_TIMER_CHANGED.check_update(update) or StatusUpdate.MIGRATE.check_update(update) + or StatusUpdate.NEW_CHAT_MEMBERS.check_update(update) + or StatusUpdate.NEW_CHAT_PHOTO.check_update(update) + or StatusUpdate.NEW_CHAT_TITLE.check_update(update) + or StatusUpdate.PAID_MESSAGE_PRICE_CHANGED.check_update(update) or StatusUpdate.PINNED_MESSAGE.check_update(update) - or StatusUpdate.CONNECTED_WEBSITE.check_update(update) or StatusUpdate.PROXIMITY_ALERT_TRIGGERED.check_update(update) - or StatusUpdate.VIDEO_CHAT_SCHEDULED.check_update(update) - or StatusUpdate.VIDEO_CHAT_STARTED.check_update(update) + or StatusUpdate.REFUNDED_PAYMENT.check_update(update) + or StatusUpdate.SUGGESTED_POST_APPROVAL_FAILED.check_update(update) + or StatusUpdate.SUGGESTED_POST_APPROVED.check_update(update) + or StatusUpdate.SUGGESTED_POST_DECLINED.check_update(update) + or StatusUpdate.SUGGESTED_POST_PAID.check_update(update) + or StatusUpdate.SUGGESTED_POST_REFUNDED.check_update(update) + or StatusUpdate.UNIQUE_GIFT.check_update(update) + or StatusUpdate.USERS_SHARED.check_update(update) or StatusUpdate.VIDEO_CHAT_ENDED.check_update(update) or StatusUpdate.VIDEO_CHAT_PARTICIPANTS_INVITED.check_update(update) + or StatusUpdate.VIDEO_CHAT_SCHEDULED.check_update(update) + or StatusUpdate.VIDEO_CHAT_STARTED.check_update(update) or StatusUpdate.WEB_APP_DATA.check_update(update) - or StatusUpdate.FORUM_TOPIC_CREATED.check_update(update) - or StatusUpdate.FORUM_TOPIC_CLOSED.check_update(update) - or StatusUpdate.FORUM_TOPIC_REOPENED.check_update(update) - or StatusUpdate.FORUM_TOPIC_EDITED.check_update(update) - or StatusUpdate.GENERAL_FORUM_TOPIC_HIDDEN.check_update(update) - or StatusUpdate.GENERAL_FORUM_TOPIC_UNHIDDEN.check_update(update) or StatusUpdate.WRITE_ACCESS_ALLOWED.check_update(update) - or StatusUpdate.USER_SHARED.check_update(update) - or StatusUpdate.CHAT_SHARED.check_update(update) ) ALL = _All(name="filters.StatusUpdate.ALL") """Messages that contain any of the below.""" + class _ChatBackgroundSet(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.chat_background_set) + + CHAT_BACKGROUND_SET = _ChatBackgroundSet(name="filters.StatusUpdate.CHAT_BACKGROUND_SET") + """Messages that contain :attr:`telegram.Message.chat_background_set`.""" + class _ChatCreated(MessageFilter): __slots__ = () @@ -1797,6 +2045,30 @@ def filter(self, message: Message) -> bool: .. versionadded:: 20.1 """ + class _ChecklistTasksAdded(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.checklist_tasks_added) + + CHECKLIST_TASKS_ADDED = _ChecklistTasksAdded(name="filters.StatusUpdate.CHECKLIST_TASKS_ADDED") + """Messages that contain :attr:`telegram.Message.checklist_tasks_added`. + + .. versionadded:: 22.3 + """ + + class _ChecklistTasksDone(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.checklist_tasks_done) + + CHECKLIST_TASKS_DONE = _ChecklistTasksDone(name="filters.StatusUpdate.CHECKLIST_TASKS_DONE") + """Messages that contain :attr:`telegram.Message.checklist_tasks_done`. + + .. versionadded:: 22.3 + """ + class _ConnectedWebsite(MessageFilter): __slots__ = () @@ -1806,6 +2078,20 @@ def filter(self, message: Message) -> bool: CONNECTED_WEBSITE = _ConnectedWebsite(name="filters.StatusUpdate.CONNECTED_WEBSITE") """Messages that contain :attr:`telegram.Message.connected_website`.""" + class _DirectMessagePriceChanged(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.direct_message_price_changed) + + DIRECT_MESSAGE_PRICE_CHANGED = _DirectMessagePriceChanged( + name="filters.StatusUpdate.DIRECT_MESSAGE_PRICE_CHANGED" + ) + """Messages that contain :attr:`telegram.Message.direct_message_price_changed`. + + .. versionadded:: 22.3 + """ + class _DeleteChatPhoto(MessageFilter): __slots__ = () @@ -1891,6 +2177,53 @@ def filter(self, message: Message) -> bool: .. versionadded:: 20.0 """ + class _Gift(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.gift) + + GIFT = _Gift(name="filters.StatusUpdate.GIFT") + """Messages that contain :attr:`telegram.Message.gift`. + + .. versionadded:: 22.1 + """ + + class _GiftUpgradeSent(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.gift_upgrade_sent) + + GIFT_UPGRADE_SENT = _GiftUpgradeSent(name="filters.StatusUpdate.GIFT_UPGRADE_SENT") + """Messages that contain :attr:`telegram.Message.gift_upgrade_sent`. + + .. versionadded:: 22.6 + """ + + class _GiveawayCreated(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.giveaway_created) + + GIVEAWAY_CREATED = _GiveawayCreated(name="filters.StatusUpdate.GIVEAWAY_CREATED") + """Messages that contain :attr:`telegram.Message.giveaway_created`. + + .. versionadded:: 20.8 + """ + + class _GiveawayCompleted(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.giveaway_completed) + + GIVEAWAY_COMPLETED = _GiveawayCompleted(name="filters.StatusUpdate.GIVEAWAY_COMPLETED") + """Messages that contain :attr:`telegram.Message.giveaway_completed`. + .. versionadded:: 20.8 + """ + class _LeftChatMember(MessageFilter): __slots__ = () @@ -1951,6 +2284,20 @@ def filter(self, message: Message) -> bool: NEW_CHAT_TITLE = _NewChatTitle(name="filters.StatusUpdate.NEW_CHAT_TITLE") """Messages that contain :attr:`telegram.Message.new_chat_title`.""" + class _PaidMessagePriceChanged(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.paid_message_price_changed) + + PAID_MESSAGE_PRICE_CHANGED = _PaidMessagePriceChanged( + name="filters.StatusUpdate.PAID_MESSAGE_PRICE_CHANGED" + ) + """Messages that contain :attr:`telegram.Message.paid_message_price_changed`. + + .. versionadded:: 22.1 + """ + class _PinnedMessage(MessageFilter): __slots__ = () @@ -1971,16 +2318,102 @@ def filter(self, message: Message) -> bool: ) """Messages that contain :attr:`telegram.Message.proximity_alert_triggered`.""" - class _UserShared(MessageFilter): + class _RefundedPayment(MessageFilter): __slots__ = () def filter(self, message: Message) -> bool: - return bool(message.user_shared) + return bool(message.refunded_payment) - USER_SHARED = _UserShared(name="filters.StatusUpdate.USER_SHARED") - """Messages that contain :attr:`telegram.Message.user_shared`. + REFUNDED_PAYMENT = _RefundedPayment("filters.StatusUpdate.REFUNDED_PAYMENT") + """Messages that contain :attr:`telegram.Message.refunded_payment`. + .. versionadded:: 21.4 + """ - .. versionadded:: 20.1 + class _SuggestedPostApprovalFailed(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.suggested_post_approval_failed) + + SUGGESTED_POST_APPROVAL_FAILED = _SuggestedPostApprovalFailed( + "filters.StatusUpdate.SUGGESTED_POST_APPROVAL_FAILED" + ) + """Messages that contain :attr:`telegram.Message.suggested_post_approval_failed`. + .. versionadded:: 22.4 + """ + + class _SuggestedPostApproved(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.suggested_post_approved) + + SUGGESTED_POST_APPROVED = _SuggestedPostApproved( + "filters.StatusUpdate.SUGGESTED_POST_APPROVED" + ) + """Messages that contain :attr:`telegram.Message.suggested_post_approved`. + .. versionadded:: 22.4 + """ + + class _SuggestedPostDeclined(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.suggested_post_declined) + + SUGGESTED_POST_DECLINED = _SuggestedPostDeclined( + "filters.StatusUpdate.SUGGESTED_POST_DECLINED" + ) + """Messages that contain :attr:`telegram.Message.suggested_post_declined`. + .. versionadded:: 22.4 + """ + + class _SuggestedPostPaid(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.suggested_post_paid) + + SUGGESTED_POST_PAID = _SuggestedPostPaid("filters.StatusUpdate.SUGGESTED_POST_PAID") + """Messages that contain :attr:`telegram.Message.suggested_post_paid`. + .. versionadded:: 22.4 + """ + + class _SuggestedPostRefunded(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.suggested_post_refunded) + + SUGGESTED_POST_REFUNDED = _SuggestedPostRefunded( + "filters.StatusUpdate.SUGGESTED_POST_REFUNDED" + ) + """Messages that contain :attr:`telegram.Message.suggested_post_refunded`. + .. versionadded:: 22.4 + """ + + class _UniqueGift(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.unique_gift) + + UNIQUE_GIFT = _UniqueGift(name="filters.StatusUpdate.UNIQUE_GIFT") + """Messages that contain :attr:`telegram.Message.unique_gift`. + + .. versionadded:: 22.1 + """ + + class _UsersShared(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.users_shared) + + USERS_SHARED = _UsersShared(name="filters.StatusUpdate.USERS_SHARED") + """Messages that contain :attr:`telegram.Message.users_shared`. + + .. versionadded:: 20.8 """ class _VideoChatEnded(MessageFilter): @@ -2147,17 +2580,78 @@ def filter(self, message: Message) -> bool: # neither mask nor emoji can be a message.sticker, so no filters for them -class _SuccessfulPayment(MessageFilter): +class _Story(MessageFilter): __slots__ = () def filter(self, message: Message) -> bool: - return bool(message.successful_payment) + return bool(message.story) + + +STORY = _Story(name="filters.STORY") +"""Messages that contain :attr:`telegram.Message.story`. + +.. versionadded:: 20.5 +""" + + +class SuccessfulPayment(MessageFilter): + """Successful Payment Messages. If a list of invoice payloads is passed, it filters + messages to only allow those whose `invoice_payload` is appearing in the given list. + Examples: + `MessageHandler(filters.SuccessfulPayment(['Custom-Payload']), callback_method)` + + .. seealso:: + :attr:`telegram.ext.filters.SUCCESSFUL_PAYMENT` + + Args: + invoice_payloads (list[:obj:`str`] | tuple[:obj:`str`], optional): Which + invoice payloads to allow. Only exact matches are allowed. If not + specified, will allow any invoice payload. + + .. versionadded:: 20.8 + """ -SUCCESSFUL_PAYMENT = _SuccessfulPayment(name="filters.SUCCESSFUL_PAYMENT") + __slots__ = ("invoice_payloads",) + + def __init__(self, invoice_payloads: list[str] | tuple[str, ...] | None = None): + self.invoice_payloads: Sequence[str] | None = invoice_payloads + super().__init__( + name=( + f"filters.SuccessfulPayment({invoice_payloads})" + if invoice_payloads + else "filters.SUCCESSFUL_PAYMENT" + ) + ) + + def filter(self, message: Message) -> bool: + if self.invoice_payloads is None: + return bool(message.successful_payment) + return ( + payment.invoice_payload in self.invoice_payloads + if (payment := message.successful_payment) + else False + ) + + +SUCCESSFUL_PAYMENT = SuccessfulPayment() """Messages that contain :attr:`telegram.Message.successful_payment`.""" +class _SuggestedPostInfo(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.suggested_post_info) + + +SUGGESTED_POST_INFO = _SuggestedPostInfo(name="filters.SUGGESTED_POST_INFO") +"""Messages that contain :attr:`telegram.Message.suggested_post_info`. + +.. versionadded:: 22.4 +""" + + class Text(MessageFilter): """Text Messages. If a list of strings is passed, it filters messages to only allow those whose text is appearing in the given list. @@ -2183,14 +2677,14 @@ class Text(MessageFilter): commands. Args: - strings (List[:obj:`str`] | Tuple[:obj:`str`], optional): Which messages to allow. Only + strings (list[:obj:`str`] | tuple[:obj:`str`], optional): Which messages to allow. Only exact matches are allowed. If not specified, will allow any text message. """ __slots__ = ("strings",) - def __init__(self, strings: Optional[Union[List[str], Tuple[str, ...]]] = None): - self.strings: Optional[Sequence[str]] = strings + def __init__(self, strings: list[str] | tuple[str, ...] | None = None): + self.strings: Sequence[str] | None = strings super().__init__(name=f"filters.Text({strings})" if strings else "filters.TEXT") def filter(self, message: Message) -> bool: @@ -2199,7 +2693,7 @@ def filter(self, message: Message) -> bool: return message.text in self.strings if message.text else False -TEXT = Text() +TEXT = Text() # pylint: disable=invalid-name """ Shortcut for :class:`telegram.ext.filters.Text()`. @@ -2245,13 +2739,21 @@ class _Edited(UpdateFilter): __slots__ = () def filter(self, update: Update) -> bool: - return update.edited_message is not None or update.edited_channel_post is not None + return ( + update.edited_message is not None + or update.edited_channel_post is not None + or update.edited_business_message is not None + ) EDITED = _Edited(name="filters.UpdateType.EDITED") - """Updates with either :attr:`telegram.Update.edited_message` or - :attr:`telegram.Update.edited_channel_post`. + """Updates with :attr:`telegram.Update.edited_message`, + :attr:`telegram.Update.edited_channel_post`, or + :attr:`telegram.Update.edited_business_message`. .. versionadded:: 20.0 + + .. versionchanged:: 21.1 + Added :attr:`telegram.Update.edited_business_message` to the filter. """ class _EditedChannelPost(UpdateFilter): @@ -2289,7 +2791,48 @@ def filter(self, update: Update) -> bool: MESSAGES = _Messages(name="filters.UpdateType.MESSAGES") """Updates with either :attr:`telegram.Update.message` or - :attr:`telegram.Update.edited_message`.""" + :attr:`telegram.Update.edited_message`. + """ + + class _BusinessMessage(UpdateFilter): + __slots__ = () + + def filter(self, update: Update) -> bool: + return update.business_message is not None + + BUSINESS_MESSAGE = _BusinessMessage(name="filters.UpdateType.BUSINESS_MESSAGE") + """Updates with :attr:`telegram.Update.business_message`. + + .. versionadded:: 21.1""" + + class _EditedBusinessMessage(UpdateFilter): + __slots__ = () + + def filter(self, update: Update) -> bool: + return update.edited_business_message is not None + + EDITED_BUSINESS_MESSAGE = _EditedBusinessMessage( + name="filters.UpdateType.EDITED_BUSINESS_MESSAGE" + ) + """Updates with :attr:`telegram.Update.edited_business_message`. + + .. versionadded:: 21.1 + """ + + class _BusinessMessages(UpdateFilter): + __slots__ = () + + def filter(self, update: Update) -> bool: + return ( + update.business_message is not None or update.edited_business_message is not None + ) + + BUSINESS_MESSAGES = _BusinessMessages(name="filters.UpdateType.BUSINESS_MESSAGES") + """Updates with either :attr:`telegram.Update.business_message` or + :attr:`telegram.Update.edited_business_message`. + + .. versionadded:: 21.1 + """ class User(_ChatUserBaseFilter): @@ -2319,18 +2862,18 @@ class User(_ChatUserBaseFilter): def __init__( self, - user_id: Optional[SCT[int]] = None, - username: Optional[SCT[str]] = None, + user_id: SCT[int] | None = None, + username: SCT[str] | None = None, allow_empty: bool = False, ): super().__init__(chat_id=user_id, username=username, allow_empty=allow_empty) self._chat_id_name = "user_id" - def _get_chat_or_user(self, message: Message) -> Optional[TGUser]: + def _get_chat_or_user(self, message: Message) -> TGUser | None: return message.from_user @property - def user_ids(self) -> FrozenSet[int]: + def user_ids(self) -> frozenset[int]: """ Which user ID(s) to allow through. @@ -2348,7 +2891,7 @@ def user_ids(self) -> FrozenSet[int]: @user_ids.setter def user_ids(self, user_id: SCT[int]) -> None: - self.chat_ids = user_id # type: ignore[assignment] + self.chat_ids = user_id def add_user_ids(self, user_id: SCT[int]) -> None: """ @@ -2434,6 +2977,8 @@ class ViaBot(_ChatUserBaseFilter): Examples: ``MessageHandler(filters.ViaBot(1234), callback_method)`` + .. seealso:: :attr:`~telegram.ext.filters.VIA_BOT` + Args: bot_id(:obj:`int` | Collection[:obj:`int`], optional): Which bot ID(s) to allow through. @@ -2455,18 +3000,18 @@ class ViaBot(_ChatUserBaseFilter): def __init__( self, - bot_id: Optional[SCT[int]] = None, - username: Optional[SCT[str]] = None, + bot_id: SCT[int] | None = None, + username: SCT[str] | None = None, allow_empty: bool = False, ): super().__init__(chat_id=bot_id, username=username, allow_empty=allow_empty) self._chat_id_name = "bot_id" - def _get_chat_or_user(self, message: Message) -> Optional[TGUser]: + def _get_chat_or_user(self, message: Message) -> TGUser | None: return message.via_bot @property - def bot_ids(self) -> FrozenSet[int]: + def bot_ids(self) -> frozenset[int]: """ Which bot ID(s) to allow through. @@ -2484,7 +3029,7 @@ def bot_ids(self) -> FrozenSet[int]: @bot_ids.setter def bot_ids(self, bot_id: SCT[int]) -> None: - self.chat_ids = bot_id # type: ignore[assignment] + self.chat_ids = bot_id def add_bot_ids(self, bot_id: SCT[int]) -> None: """ @@ -2515,7 +3060,9 @@ def filter(self, message: Message) -> bool: VIA_BOT = _ViaBot(name="filters.VIA_BOT") -"""This filter filters for message that were sent via *any* bot.""" +"""This filter filters for message that were sent via *any* bot. + +.. seealso:: :class:`~telegram.ext.filters.ViaBot`""" class _Video(MessageFilter): @@ -2525,7 +3072,7 @@ def filter(self, message: Message) -> bool: return bool(message.video) -VIDEO = _Video(name="filters.VIDEO") +VIDEO = _Video(name="filters.VIDEO") # pylint: disable=invalid-name """Messages that contain :attr:`telegram.Message.video`.""" @@ -2549,3 +3096,36 @@ def filter(self, message: Message) -> bool: VOICE = _Voice("filters.VOICE") """Messages that contain :attr:`telegram.Message.voice`.""" + + +class _ReplyToStory(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.reply_to_story) + + +REPLY_TO_STORY = _ReplyToStory(name="filters.REPLY_TO_STORY") +"""Messages that contain :attr:`telegram.Message.reply_to_story`.""" + + +class _BoostAdded(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.boost_added) + + +BOOST_ADDED = _BoostAdded(name="filters.BOOST_ADDED") +"""Messages that contain :attr:`telegram.Message.boost_added`.""" + + +class _SenderBoostCount(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.sender_boost_count) + + +SENDER_BOOST_COUNT = _SenderBoostCount(name="filters.SENDER_BOOST_COUNT") +"""Messages that contain :attr:`telegram.Message.sender_boost_count`.""" diff --git a/telegram/helpers.py b/src/telegram/helpers.py similarity index 86% rename from telegram/helpers.py rename to src/telegram/helpers.py index 7ce0a76c07b..1c30a303afc 100644 --- a/telegram/helpers.py +++ b/src/telegram/helpers.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -33,15 +33,18 @@ import re from html import escape -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING -from telegram.constants import MessageType +from telegram._utils.types import MarkdownVersion +from telegram.constants import MessageLimit, MessageType if TYPE_CHECKING: from telegram import Message, Update -def escape_markdown(text: str, version: int = 1, entity_type: Optional[str] = None) -> str: +def escape_markdown( + text: str, version: MarkdownVersion = 1, entity_type: str | None = None +) -> str: """Helper function to escape telegram markup symbols. .. versionchanged:: 20.3 @@ -74,7 +77,7 @@ def escape_markdown(text: str, version: int = 1, entity_type: Optional[str] = No return re.sub(f"([{re.escape(escape_chars)}])", r"\\\1", text) -def mention_html(user_id: Union[int, str], name: str) -> str: +def mention_html(user_id: int | str, name: str) -> str: """ Helper function to create a user mention as HTML tag. @@ -88,7 +91,7 @@ def mention_html(user_id: Union[int, str], name: str) -> str: return f'{escape(name)}' -def mention_markdown(user_id: Union[int, str], name: str, version: int = 1) -> str: +def mention_markdown(user_id: int | str, name: str, version: MarkdownVersion = 1) -> str: """ Helper function to create a user mention in Markdown syntax. @@ -107,7 +110,7 @@ def mention_markdown(user_id: Union[int, str], name: str, version: int = 1) -> s return f"[{escape_markdown(name, version=version)}]({tg_link})" -def effective_message_type(entity: Union["Message", "Update"]) -> Optional[str]: +def effective_message_type(entity: "Message | Update") -> str | None: """ Extracts the type of message as a string identifier from a :class:`telegram.Message` or a :class:`telegram.Update`. @@ -122,7 +125,10 @@ def effective_message_type(entity: Union["Message", "Update"]) -> Optional[str]: """ # Importing on file-level yields cyclic Import Errors - from telegram import Message, Update # pylint: disable=import-outside-toplevel + from telegram import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + Message, + Update, + ) if isinstance(entity, Message): message = entity @@ -141,7 +147,7 @@ def effective_message_type(entity: Union["Message", "Update"]) -> Optional[str]: def create_deep_linked_url( - bot_username: str, payload: Optional[str] = None, group: bool = False + bot_username: str, payload: str | None = None, group: bool = False ) -> str: """ Creates a deep-linked URL for this :paramref:`~create_deep_linked_url.bot_username` with the @@ -170,7 +176,8 @@ def create_deep_linked_url( :obj:`str`: An URL to start the bot with specific parameters. Raises: - :exc:`ValueError`: If the length of the :paramref:`payload` exceeds 64 characters, + :exc:`ValueError`: If the length of the :paramref:`payload` exceeds \ + :tg-const:`telegram.constants.MessageLimit.DEEP_LINK_LENGTH` characters, contains invalid characters, or if the :paramref:`bot_username` is less than 4 characters. """ @@ -181,8 +188,10 @@ def create_deep_linked_url( if not payload: return base_url - if len(payload) > 64: - raise ValueError("The deep-linking payload must not exceed 64 characters.") + if len(payload) > MessageLimit.DEEP_LINK_LENGTH: + raise ValueError( + f"The deep-linking payload must not exceed {MessageLimit.DEEP_LINK_LENGTH} characters." + ) if not re.match(r"^[A-Za-z0-9_-]+$", payload): raise ValueError( diff --git a/src/telegram/py.typed b/src/telegram/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/telegram/request/__init__.py b/src/telegram/request/__init__.py similarity index 97% rename from telegram/request/__init__.py rename to src/telegram/request/__init__.py index 72b57d9b825..dd3639c81a2 100644 --- a/telegram/request/__init__.py +++ b/src/telegram/request/__init__.py @@ -1,6 +1,6 @@ # !/usr/bin/env python # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/telegram/request/_baserequest.py b/src/telegram/request/_baserequest.py similarity index 78% rename from telegram/request/_baserequest.py rename to src/telegram/request/_baserequest.py index 53db6c7fa94..d12a03ff6c4 100644 --- a/telegram/request/_baserequest.py +++ b/src/telegram/request/_baserequest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,16 +17,18 @@ # 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 abstract class to make POST and GET requests.""" + import abc -import asyncio import json +from contextlib import AbstractAsyncContextManager from http import HTTPStatus from types import TracebackType -from typing import AsyncContextManager, ClassVar, List, Optional, Tuple, Type, TypeVar, Union +from typing import Final, TypeVar, final from telegram._utils.defaultvalue import DEFAULT_NONE as _DEFAULT_NONE from telegram._utils.defaultvalue import DefaultValue from telegram._utils.logging import get_logger +from telegram._utils.strings import TextEncoding from telegram._utils.types import JSONDict, ODVInput from telegram._version import __version__ as ptb_ver from telegram.error import ( @@ -47,7 +49,7 @@ class BaseRequest( - AsyncContextManager["BaseRequest"], + AbstractAsyncContextManager["BaseRequest"], abc.ABC, ): """Abstract interface class that allows python-telegram-bot to make requests to the Bot API. @@ -71,6 +73,8 @@ class BaseRequest( finally: await request_object.shutdown() + .. seealso:: :meth:`__aenter__` and :meth:`__aexit__`. + Tip: JSON encoding and decoding is done with the standard library's :mod:`json` by default. To use a custom library for this, you can override :meth:`parse_json_payload` and implement @@ -84,10 +88,10 @@ class BaseRequest( __slots__ = () - USER_AGENT: ClassVar[str] = f"python-telegram-bot v{ptb_ver} (https://python-telegram-bot.org)" + USER_AGENT: Final[str] = f"python-telegram-bot v{ptb_ver} (https://python-telegram-bot.org)" """:obj:`str`: A description that can be used as user agent for requests made to the Bot API. """ - DEFAULT_NONE: ClassVar[DefaultValue[None]] = _DEFAULT_NONE + DEFAULT_NONE: Final[DefaultValue[None]] = _DEFAULT_NONE """:class:`object`: A special object that indicates that an argument of a function was not explicitly passed. Used for the timeout parameters of :meth:`post` and :meth:`do_request`. @@ -100,23 +104,48 @@ class BaseRequest( """ async def __aenter__(self: RT) -> RT: + """|async_context_manager| :meth:`initializes ` the Request. + + Returns: + The initialized Request instance. + + Raises: + :exc:`Exception`: If an exception is raised during initialization, :meth:`shutdown` + is called in this case. + """ try: await self.initialize() - return self - except Exception as exc: + except Exception: await self.shutdown() - raise exc + raise + return self async def __aexit__( self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, ) -> None: + """|async_context_manager| :meth:`shuts down ` the Request.""" # Make sure not to return `True` so that exceptions are not suppressed # https://docs.python.org/3/reference/datamodel.html?#object.__aexit__ await self.shutdown() + @property + @abc.abstractmethod + def read_timeout(self) -> float | None: + """This property must return the default read timeout in seconds used by this class. + More precisely, the returned value should be the one used when + :paramref:`post.read_timeout` of :meth:post` is not passed/equal to :attr:`DEFAULT_NONE`. + + .. versionadded:: 20.7 + .. versionchanged:: 22.0 + This property is now required to be implemented by subclasses. + + Returns: + :obj:`float` | :obj:`None`: The read timeout in seconds. + """ + @abc.abstractmethod async def initialize(self) -> None: """Initialize resources used by this class. Must be implemented by a subclass.""" @@ -125,15 +154,16 @@ async def initialize(self) -> None: async def shutdown(self) -> None: """Stop & clear resources used by this class. Must be implemented by a subclass.""" + @final async def post( self, url: str, - request_data: Optional[RequestData] = None, + request_data: RequestData | None = 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, - ) -> Union[JSONDict, List[JSONDict], bool]: + ) -> JSONDict | list[JSONDict] | bool: """Makes a request to the Bot API handles the return code and parses the answer. Warning: @@ -179,6 +209,7 @@ async def post( # see https://core.telegram.org/bots/api#making-requests return json_data["result"] + @final async def retrieve( self, url: str, @@ -229,7 +260,7 @@ async def _request_wrapper( self, url: str, method: str, - request_data: Optional[RequestData] = None, + request_data: RequestData | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, @@ -270,9 +301,6 @@ async def _request_wrapper( TelegramError """ - # TGs response also has the fields 'ok' and 'error_code'. - # However, we rather rely on the HTTP status code for now. - try: code, payload = await self.do_request( url=url, @@ -283,54 +311,68 @@ async def _request_wrapper( connect_timeout=connect_timeout, pool_timeout=pool_timeout, ) - except asyncio.CancelledError as exc: - # TODO: in py3.8+, CancelledError is a subclass of BaseException, so we can drop this - # clause when we drop py3.7 - raise exc - except TelegramError as exc: - raise exc + except TelegramError: + raise except Exception as exc: raise NetworkError(f"Unknown error in HTTP implementation: {exc!r}") from exc if HTTPStatus.OK <= code <= 299: # 200-299 range are HTTP success statuses + # starting with Py 3.12 we can use `HTTPStatus.is_success` return payload - response_data = self.parse_json_payload(payload) - - description = response_data.get("description") - message = description if description else "Unknown HTTPError" + try: + message = f"{HTTPStatus(code).phrase} ({code})" + except ValueError: + message = f"Unknown HTTPError ({code})" - # In some special cases, we can raise more informative exceptions: - # see https://core.telegram.org/bots/api#responseparameters and - # https://core.telegram.org/bots/api#making-requests - parameters = response_data.get("parameters") - if parameters: - migrate_to_chat_id = parameters.get("migrate_to_chat_id") - if migrate_to_chat_id: - raise ChatMigrated(migrate_to_chat_id) - retry_after = parameters.get("retry_after") - if retry_after: - raise RetryAfter(retry_after) + parsing_exception: TelegramError | None = None - message += f"\nThe server response contained unknown parameters: {parameters}" + try: + response_data = self.parse_json_payload(payload) + except TelegramError as exc: + message += f". Parsing the server response {payload!r} failed" + parsing_exception = exc + else: + message = response_data.get("description") or message + + # In some special cases, we can raise more informative exceptions: + # see https://core.telegram.org/bots/api#responseparameters and + # https://core.telegram.org/bots/api#making-requests + # TGs response also has the fields 'ok' and 'error_code'. + # However, we rather rely on the HTTP status code for now. + parameters = response_data.get("parameters") + if parameters: + migrate_to_chat_id = parameters.get("migrate_to_chat_id") + if migrate_to_chat_id: + raise ChatMigrated(migrate_to_chat_id) + retry_after = parameters.get("retry_after") + if retry_after: + raise RetryAfter(retry_after) + + message += f". The server response contained unknown parameters: {parameters}" if code == HTTPStatus.FORBIDDEN: # 403 - raise Forbidden(message) - if code in (HTTPStatus.NOT_FOUND, HTTPStatus.UNAUTHORIZED): # 404 and 401 + exception: TelegramError = Forbidden(message) + elif code in (HTTPStatus.NOT_FOUND, HTTPStatus.UNAUTHORIZED): # 404 and 401 # TG returns 404 Not found for # 1) malformed tokens # 2) correct tokens but non-existing method, e.g. api.tg.org/botTOKEN/unkonwnMethod - # We can basically rule out 2) since we don't let users make requests manually + # 2) is relevant only for Bot.do_api_request, where we have special handing for it. # TG returns 401 Unauthorized for correctly formatted tokens that are not valid - raise InvalidToken(message) - if code == HTTPStatus.BAD_REQUEST: # 400 - raise BadRequest(message) - if code == HTTPStatus.CONFLICT: # 409 - raise Conflict(message) - if code == HTTPStatus.BAD_GATEWAY: # 502 - raise NetworkError(description or "Bad Gateway") - raise NetworkError(f"{message} ({code})") + exception = InvalidToken(message) + elif code == HTTPStatus.BAD_REQUEST: # 400 + exception = BadRequest(message) + elif code == HTTPStatus.CONFLICT: # 409 + exception = Conflict(message) + elif code == HTTPStatus.BAD_GATEWAY: # 502 + exception = NetworkError(message) + else: + exception = NetworkError(message) + + if parsing_exception: + raise exception from parsing_exception + raise exception @staticmethod def parse_json_payload(payload: bytes) -> JSONDict: @@ -350,11 +392,11 @@ def parse_json_payload(payload: bytes) -> JSONDict: Raises: TelegramError: If loading the JSON data failed """ - decoded_s = payload.decode("utf-8", "replace") + decoded_s = payload.decode(TextEncoding.UTF_8, "replace") try: return json.loads(decoded_s) except ValueError as exc: - _LOGGER.error('Can not load invalid JSON data: "%s"', decoded_s) + _LOGGER.exception('Can not load invalid JSON data: "%s"', decoded_s) raise TelegramError("Invalid server response") from exc @abc.abstractmethod @@ -362,12 +404,12 @@ async def do_request( self, url: str, method: str, - request_data: Optional[RequestData] = None, + request_data: RequestData | None = 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, - ) -> Tuple[int, bytes]: + ) -> tuple[int, bytes]: """Makes a request to the Bot API. Must be implemented by a subclass. Warning: @@ -397,6 +439,6 @@ async def do_request( :attr:`DEFAULT_NONE`. Returns: - Tuple[:obj:`int`, :obj:`bytes`]: The HTTP return code & the payload part of the server + tuple[:obj:`int`, :obj:`bytes`]: The HTTP return code & the payload part of the server response. """ diff --git a/telegram/request/_httpxrequest.py b/src/telegram/request/_httpxrequest.py similarity index 62% rename from telegram/request/_httpxrequest.py rename to src/telegram/request/_httpxrequest.py index a2eb25a77d9..080fd3d0735 100644 --- a/telegram/request/_httpxrequest.py +++ b/src/telegram/request/_httpxrequest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,13 +17,15 @@ # 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 methods to make POST and GET requests using the httpx library.""" -from typing import Optional, Tuple + +from collections.abc import Collection +from typing import Any import httpx from telegram._utils.defaultvalue import DefaultValue from telegram._utils.logging import get_logger -from telegram._utils.types import ODVInput +from telegram._utils.types import HTTPVersion, ODVInput, SocketOpt from telegram.error import NetworkError, TimedOut from telegram.request._baserequest import BaseRequest from telegram.request._requestdata import RequestData @@ -42,24 +44,18 @@ class HTTPXRequest(BaseRequest): .. versionadded:: 20.0 + .. versionchanged:: 22.0 + Removed the deprecated parameter ``proxy_url``. Use :paramref:`proxy` instead. + Args: connection_pool_size (:obj:`int`, optional): Number of connections to keep in the - connection pool. Defaults to ``1``. - - Note: - Independent of the value, one additional connection will be reserved for - :meth:`telegram.Bot.get_updates`. - proxy_url (:obj:`str`, optional): The URL to the proxy server. For example - ``'http://127.0.0.1:3128'`` or ``'socks5://127.0.0.1:3128'``. Defaults to :obj:`None`. - - Note: - * The proxy URL can also be set via the environment variables ``HTTPS_PROXY`` or - ``ALL_PROXY``. See `the docs of httpx`_ for more info. - * For Socks5 support, additional dependencies are required. Make sure to install - PTB via :command:`pip install python-telegram-bot[socks]` in this case. - * Socks5 proxies can not be set via environment variables. + connection pool. Defaults to ``256``. - .. _the docs of httpx: https://www.python-httpx.org/environment_variables/#proxies + .. versionchanged:: 22.4 + Set the default to ``256``. + Stopped applying to ``httpx.Limits.max_keepalive_connections``. Now only applies to + ``httpx.Limits.max_connections``. See `Resource Limits + `_ read_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum amount of time (in seconds) to wait for a response from Telegram's server. This value is used unless a different value is passed to :meth:`do_request`. @@ -69,6 +65,10 @@ class HTTPXRequest(BaseRequest): a network socket; i.e. POSTing a request or uploading a file). This value is used unless a different value is passed to :meth:`do_request`. Defaults to ``5``. + + Hint: + This timeout is used for all requests except for those that upload media/files. + For the latter, :paramref:`media_write_timeout` is used. connect_timeout (:obj:`float` | :obj:`None`, optional): If passed, specifies the maximum amount of time (in seconds) to wait for a connection attempt to a server to succeed. This value is used unless a different value is passed to @@ -82,28 +82,81 @@ class HTTPXRequest(BaseRequest): With a finite pool timeout, you must expect :exc:`telegram.error.TimedOut` exceptions to be thrown when more requests are made simultaneously than there are connections in the connection pool! - http_version (:obj:`str`, optional): If ``"2"``, HTTP/2 will be used instead of HTTP/1.1. - Defaults to ``"1.1"``. + http_version (:obj:`str`, optional): If ``"2"`` or ``"2.0"``, HTTP/2 will be used instead + of HTTP/1.1. Defaults to ``"1.1"``. .. versionadded:: 20.1 .. versionchanged:: 20.2 Reset the default version to 1.1. + .. versionchanged:: 20.5 + Accept ``"2"`` as a valid value. + socket_options (Collection[:obj:`tuple`], optional): Socket options to be passed to the + underlying `library \ + `_. + + Note: + The values accepted by this parameter depend on the operating system. + This is a low-level parameter and should only be used if you are familiar with + these concepts. + + .. versionadded:: 20.7 + proxy (:obj:`str` | ``httpx.Proxy`` | ``httpx.URL``, optional): The URL to a proxy server, + a ``httpx.Proxy`` object or a ``httpx.URL`` object. For example + ``'http://127.0.0.1:3128'`` or ``'socks5://127.0.0.1:3128'``. Defaults to :obj:`None`. + + Note: + * The proxy URL can also be set via the environment variables ``HTTPS_PROXY`` or + ``ALL_PROXY``. See `the docs of httpx`_ for more info. + * HTTPS proxies can be configured by passing a ``httpx.Proxy`` object with + a corresponding ``ssl_context``. + * For Socks5 support, additional dependencies are required. Make sure to install + PTB via :command:`pip install "python-telegram-bot[socks]"` in this case. + * Socks5 proxies can not be set via environment variables. + + .. _the docs of httpx: https://www.python-httpx.org/environment_variables/#proxies + + .. versionadded:: 20.7 + media_write_timeout (:obj:`float` | :obj:`None`, optional): Like :paramref:`write_timeout`, + but used only for requests that upload media/files. This value is used unless a + different value is passed to :paramref:`do_request.write_timeout` of + :meth:`do_request`. Defaults to ``20`` seconds. + + .. versionadded:: 21.0 + httpx_kwargs (dict[:obj:`str`, Any], optional): Additional keyword arguments to be passed + to the `httpx.AsyncClient `_ + constructor. + + Warning: + This parameter is intended for advanced users that want to fine-tune the behavior + of the underlying ``httpx`` client. The values passed here will override all the + defaults set by ``python-telegram-bot`` and all other parameters passed to + :class:`HTTPXRequest`. The only exception is the :paramref:`media_write_timeout` + parameter, which is not passed to the client constructor. + No runtime warnings will be issued about parameters that are overridden in this + way. + + .. versionadded:: 21.6 + """ - __slots__ = ("_client", "_client_kwargs", "_http_version") + __slots__ = ("_client", "_client_kwargs", "_http_version", "_media_write_timeout") def __init__( self, - connection_pool_size: int = 1, - proxy_url: Optional[str] = None, - read_timeout: Optional[float] = 5.0, - write_timeout: Optional[float] = 5.0, - connect_timeout: Optional[float] = 5.0, - pool_timeout: Optional[float] = 1.0, - http_version: str = "1.1", + connection_pool_size: int = 256, + read_timeout: float | None = 5.0, + write_timeout: float | None = 5.0, + connect_timeout: float | None = 5.0, + pool_timeout: float | None = 1.0, + http_version: HTTPVersion = "1.1", + socket_options: Collection[SocketOpt] | None = None, + proxy: str | httpx.Proxy | httpx.URL | None = None, + media_write_timeout: float | None = 20.0, + httpx_kwargs: dict[str, Any] | None = None, ): self._http_version = http_version + self._media_write_timeout = media_write_timeout timeout = httpx.Timeout( connect=connect_timeout, read=read_timeout, @@ -112,38 +165,43 @@ def __init__( ) limits = httpx.Limits( max_connections=connection_pool_size, - max_keepalive_connections=connection_pool_size, ) - if http_version not in ("1.1", "2"): - raise ValueError("`http_version` must be either '1.1' or '2'.") + if http_version not in ("1.1", "2", "2.0"): + raise ValueError("`http_version` must be either '1.1', '2.0' or '2'.") http1 = http_version == "1.1" - - # See https://github.com/python-telegram-bot/python-telegram-bot/pull/3542 - # for why we need to use `dict()` here. - self._client_kwargs = dict( # pylint: disable=use-dict-literal # noqa: C408 - timeout=timeout, - proxies=proxy_url, - limits=limits, - http1=http1, - http2=not http1, + http_kwargs = {"http1": http1, "http2": not http1} + transport = ( + httpx.AsyncHTTPTransport( + socket_options=socket_options, + ) + if socket_options + else None ) + self._client_kwargs = { + "timeout": timeout, + "proxy": proxy, + "limits": limits, + "transport": transport, + **http_kwargs, + **(httpx_kwargs or {}), + } try: self._client = self._build_client() except ImportError as exc: if "httpx[http2]" not in str(exc) and "httpx[socks]" not in str(exc): - raise exc + raise if "httpx[socks]" in str(exc): raise RuntimeError( "To use Socks5 proxies, PTB must be installed via `pip install " - "python-telegram-bot[socks]`." + '"python-telegram-bot[socks]"`.' ) from exc raise RuntimeError( "To use HTTP/2, PTB must be installed via `pip install " - "python-telegram-bot[http2]`." + '"python-telegram-bot[http2]"`.' ) from exc @property @@ -155,8 +213,18 @@ def http_version(self) -> str: """ return self._http_version + @property + def read_timeout(self) -> float | None: + """See :attr:`BaseRequest.read_timeout`. + + Returns: + :obj:`float` | :obj:`None`: The default read timeout in seconds as passed to + :paramref:`HTTPXRequest.read_timeout`. + """ + return self._client.timeout.read + def _build_client(self) -> httpx.AsyncClient: - return httpx.AsyncClient(**self._client_kwargs) # type: ignore[arg-type] + return httpx.AsyncClient(**self._client_kwargs) async def initialize(self) -> None: """See :meth:`BaseRequest.initialize`.""" @@ -175,27 +243,31 @@ async def do_request( self, url: str, method: str, - request_data: Optional[RequestData] = None, + request_data: RequestData | None = None, read_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE, write_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE, connect_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE, pool_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE, - ) -> Tuple[int, bytes]: + ) -> tuple[int, bytes]: """See :meth:`BaseRequest.do_request`.""" if self._client.is_closed: raise RuntimeError("This HTTPXRequest is not initialized!") + files = request_data.multipart_data if request_data else None + data = request_data.json_parameters if request_data else None + # If user did not specify timeouts (for e.g. in a bot method), use the default ones when we # created this instance. if isinstance(read_timeout, DefaultValue): read_timeout = self._client.timeout.read - if isinstance(write_timeout, DefaultValue): - write_timeout = self._client.timeout.write if isinstance(connect_timeout, DefaultValue): connect_timeout = self._client.timeout.connect if isinstance(pool_timeout, DefaultValue): pool_timeout = self._client.timeout.pool + if isinstance(write_timeout, DefaultValue): + write_timeout = self._client.timeout.write if not files else self._media_write_timeout + timeout = httpx.Timeout( connect=connect_timeout, read=read_timeout, @@ -203,15 +275,6 @@ async def do_request( pool=pool_timeout, ) - # TODO p0: On Linux, use setsockopt to properly set socket level keepalive. - # (socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 120) - # (socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 30) - # (socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 8) - # TODO p4: Support setsockopt on lesser platforms than Linux. - - files = request_data.multipart_data if request_data else None - data = request_data.json_parameters if request_data else None - try: res = await self._client.request( method=method, diff --git a/telegram/request/_requestdata.py b/src/telegram/request/_requestdata.py similarity index 75% rename from telegram/request/_requestdata.py rename to src/telegram/request/_requestdata.py index 550bcd7983a..91517d9455a 100644 --- a/telegram/request/_requestdata.py +++ b/src/telegram/request/_requestdata.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,14 +17,17 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains a class that holds the parameters of a request to the Bot API.""" + import json -from typing import Any, Dict, List, Optional, Union +from typing import Any, final from urllib.parse import urlencode +from telegram._utils.strings import TextEncoding from telegram._utils.types import UploadFileDict from telegram.request._requestparameter import RequestParameter +@final class RequestData: """Instances of this class collect the data needed for one request to the Bot API, including all parameters and files to be sent along with the request. @@ -32,8 +35,8 @@ class RequestData: .. versionadded:: 20.0 Warning: - How exactly instances of this will are created should be considered an implementation - detail and not part of PTBs public API. Users should exclusively rely on the documented + How exactly instances of this are created should be considered an implementation detail + and not part of PTBs public API. Users should exclusively rely on the documented attributes, properties and methods. Attributes: @@ -43,16 +46,19 @@ class RequestData: __slots__ = ("_parameters", "contains_files") - def __init__(self, parameters: Optional[List[RequestParameter]] = None): - self._parameters: List[RequestParameter] = parameters or [] + def __init__(self, parameters: list[RequestParameter] | None = None): + self._parameters: list[RequestParameter] = parameters or [] self.contains_files: bool = any(param.input_files for param in self._parameters) @property - def parameters(self) -> Dict[str, Union[str, int, List[Any], Dict[Any, Any]]]: + def parameters(self) -> dict[str, str | int | list[Any] | dict[Any, Any]]: """Gives the parameters as mapping of parameter name to the parameter value, which can be a single object of type :obj:`int`, :obj:`float`, :obj:`str` or :obj:`bool` or any (possibly nested) composition of lists, tuples and dictionaries, where each entry, key and value is of one of the mentioned types. + + Returns: + dict[:obj:`str`, :obj:`str` | :obj:`int` | list[any] | dict[any, any]] """ return { param.name: param.value # type: ignore[misc] @@ -61,7 +67,7 @@ def parameters(self) -> Dict[str, Union[str, int, List[Any], Dict[Any, Any]]]: } @property - def json_parameters(self) -> Dict[str, str]: + def json_parameters(self) -> dict[str, str]: """Gives the parameters as mapping of parameter name to the respective JSON encoded value. @@ -69,6 +75,9 @@ def json_parameters(self) -> Dict[str, str]: By default, this property uses the standard library's :func:`json.dumps`. To use a custom library for JSON encoding, you can directly encode the keys of :attr:`parameters` - note that string valued keys should not be JSON encoded. + + Returns: + dict[:obj:`str`, :obj:`str`] """ return { param.name: param.json_value @@ -76,25 +85,31 @@ def json_parameters(self) -> Dict[str, str]: if param.json_value is not None } - def url_encoded_parameters(self, encode_kwargs: Optional[Dict[str, Any]] = None) -> str: + def url_encoded_parameters(self, encode_kwargs: dict[str, Any] | None = None) -> str: """Encodes the parameters with :func:`urllib.parse.urlencode`. Args: - encode_kwargs (Dict[:obj:`str`, any], optional): Additional keyword arguments to pass + encode_kwargs (dict[:obj:`str`, any], optional): Additional keyword arguments to pass along to :func:`urllib.parse.urlencode`. + + Returns: + :obj:`str` """ if encode_kwargs: return urlencode(self.json_parameters, **encode_kwargs) return urlencode(self.json_parameters) - def parametrized_url(self, url: str, encode_kwargs: Optional[Dict[str, Any]] = None) -> str: + def parametrized_url(self, url: str, encode_kwargs: dict[str, Any] | None = None) -> str: """Shortcut for attaching the return value of :meth:`url_encoded_parameters` to the :paramref:`url`. Args: url (:obj:`str`): The URL the parameters will be attached to. - encode_kwargs (Dict[:obj:`str`, any], optional): Additional keyword arguments to pass + encode_kwargs (dict[:obj:`str`, any], optional): Additional keyword arguments to pass along to :func:`urllib.parse.urlencode`. + + Returns: + :obj:`str` """ url_parameters = self.url_encoded_parameters(encode_kwargs=encode_kwargs) return f"{url}?{url_parameters}" @@ -107,12 +122,19 @@ def json_payload(self) -> bytes: By default, this property uses the standard library's :func:`json.dumps`. To use a custom library for JSON encoding, you can directly encode the keys of :attr:`parameters` - note that string valued keys should not be JSON encoded. + + Returns: + :obj:`bytes` """ - return json.dumps(self.json_parameters).encode("utf-8") + return json.dumps(self.json_parameters).encode(TextEncoding.UTF_8) @property def multipart_data(self) -> UploadFileDict: - """Gives the files contained in this object as mapping of part name to encoded content.""" + """Gives the files contained in this object as mapping of part name to encoded content. + + .. versionchanged:: 21.5 + Content may now be a file handle. + """ multipart_data: UploadFileDict = {} for param in self._parameters: m_data = param.multipart_data diff --git a/telegram/request/_requestparameter.py b/src/telegram/request/_requestparameter.py similarity index 71% rename from telegram/request/_requestparameter.py rename to src/telegram/request/_requestparameter.py index 40fdcd2c713..45e3bb02110 100644 --- a/telegram/request/_requestparameter.py +++ b/src/telegram/request/_requestparameter.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,13 +17,17 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains a class that describes a single parameter of a request to the Bot API.""" + +import datetime as dtm import json +from collections.abc import Sequence from dataclasses import dataclass -from datetime import datetime -from typing import List, Optional, Sequence, Tuple +from typing import final +from telegram._files._inputstorycontent import InputStoryContent from telegram._files.inputfile import InputFile -from telegram._files.inputmedia import InputMedia +from telegram._files.inputmedia import InputMedia, InputPaidMedia +from telegram._files.inputprofilephoto import InputProfilePhoto, InputProfilePhotoStatic from telegram._files.inputsticker import InputSticker from telegram._telegramobject import TelegramObject from telegram._utils.datetime import to_timestamp @@ -31,6 +35,7 @@ from telegram._utils.types import UploadFileDict +@final @dataclass(repr=True, eq=False, order=False, frozen=True) class RequestParameter: """Instances of this class represent a single parameter to be sent along with a request to @@ -46,24 +51,24 @@ class RequestParameter: Args: name (:obj:`str`): The name of the parameter. value (:obj:`object` | :obj:`None`): The value of the parameter. Must be JSON-dumpable. - input_files (List[:class:`telegram.InputFile`], optional): A list of files that should be + input_files (list[:class:`telegram.InputFile`], optional): A list of files that should be uploaded along with this parameter. Attributes: name (:obj:`str`): The name of the parameter. value (:obj:`object` | :obj:`None`): The value of the parameter. - input_files (List[:class:`telegram.InputFile` | :obj:`None`): A list of files that should + input_files (list[:class:`telegram.InputFile` | :obj:`None`): A list of files that should be uploaded along with this parameter. """ - __slots__ = ("name", "value", "input_files") + __slots__ = ("input_files", "name", "value") name: str value: object - input_files: Optional[List[InputFile]] + input_files: list[InputFile] | None @property - def json_value(self) -> Optional[str]: + def json_value(self) -> str | None: """The JSON dumped :attr:`value` or :obj:`None` if :attr:`value` is :obj:`None`. The latter can currently only happen if :attr:`input_files` has exactly one element that must not be uploaded via an attach:// URI. @@ -75,8 +80,12 @@ def json_value(self) -> Optional[str]: return json.dumps(self.value) @property - def multipart_data(self) -> Optional[UploadFileDict]: - """A dict with the file data to upload, if any.""" + def multipart_data(self) -> UploadFileDict | None: + """A dict with the file data to upload, if any. + + .. versionchanged:: 21.5 + Content may now be a file handle. + """ if not self.input_files: return None return { @@ -87,9 +96,9 @@ def multipart_data(self) -> Optional[UploadFileDict]: @staticmethod def _value_and_input_files_from_input( # pylint: disable=too-many-return-statements value: object, - ) -> Tuple[object, List[InputFile]]: + ) -> 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 @@ -107,8 +116,16 @@ def _value_and_input_files_from_input( # pylint: disable=too-many-return-statem * if a user passes a custom enum, it's unlikely that we can actually properly handle it even with some special casing. """ - if isinstance(value, datetime): + if isinstance(value, dtm.datetime): return to_timestamp(value), [] + if isinstance(value, dtm.timedelta): + seconds = value.total_seconds() + # We convert to int for completeness for whole seconds + if seconds.is_integer(): + return int(seconds), [] + # The Bot API doesn't document behavior for fractions of seconds so far, but we don't + # want to silently drop them + return seconds, [] if isinstance(value, StringEnum): return value.value, [] if isinstance(value, InputFile): @@ -116,7 +133,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() @@ -134,6 +151,31 @@ def _value_and_input_files_from_input( # pylint: disable=too-many-return-statem return data, [value.media, thumbnail] return data, [value.media] + + if isinstance(value, InputProfilePhoto): + attr = "photo" if isinstance(value, InputProfilePhotoStatic) else "animation" + if not isinstance(media := getattr(value, attr), InputFile): + # We don't have to upload anything + return value.to_dict(), [] + + # We call to_dict and change the returned dict instead of overriding + # value.photo in case the same value is reused for another request + data = value.to_dict() + data[attr] = media.attach_uri + return data, [media] + + if isinstance(value, InputStoryContent): + attr = value.type + if not isinstance(media := getattr(value, attr), InputFile): + # We don't have to upload anything + return value.to_dict(), [] + + # We call to_dict and change the returned dict instead of overriding + # value.photo in case the same value is reused for another request + data = value.to_dict() + data[attr] = media.attach_uri + return data, [media] + if isinstance(value, InputSticker) and isinstance(value.sticker, InputFile): # We call to_dict and change the returned dict instead of overriding # value.sticker in case the same value is reused for another request @@ -151,7 +193,7 @@ def from_input(cls, key: str, value: object) -> "RequestParameter": """Builds an instance of this class for a given key-value pair that represents the raw input as passed along from a method of :class:`telegram.Bot`. """ - if not isinstance(value, (str, bytes)) and isinstance(value, Sequence): + if not isinstance(value, str | bytes) and isinstance(value, Sequence): param_values = [] input_files = [] for obj in value: diff --git a/telegram/warnings.py b/src/telegram/warnings.py similarity index 66% rename from telegram/warnings.py rename to src/telegram/warnings.py index 8fef6a6c396..53d93db4271 100644 --- a/telegram/warnings.py +++ b/src/telegram/warnings.py @@ -1,7 +1,6 @@ -#!/usr/bin/env python -# +#! /usr/bin/env python # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -55,6 +54,34 @@ class PTBDeprecationWarning(PTBUserWarning, DeprecationWarning): .. versionchanged:: 20.0 Renamed TelegramDeprecationWarning to PTBDeprecationWarning. + + Args: + version (:obj:`str`): The version in which the feature was deprecated. + + .. versionadded:: 21.2 + message (:obj:`str`): The message to display. + + .. versionadded:: 21.2 + + Attributes: + version (:obj:`str`): The version in which the feature was deprecated. + + .. versionadded:: 21.2 + message (:obj:`str`): The message to display. + + .. versionadded:: 21.2 """ - __slots__ = () + __slots__ = ("message", "version") + + def __init__(self, version: str, message: str) -> None: + self.version: str = version + self.message: str = message + + def __str__(self) -> str: + """Returns a string representation of the warning, using :attr:`message` and + :attr:`version`. + + .. versionadded:: 21.2 + """ + return f"Deprecated since version {self.version}: {self.message}" diff --git a/telegram/_files/video.py b/telegram/_files/video.py deleted file mode 100644 index b77acc71b19..00000000000 --- a/telegram/_files/video.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env python -# -# A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 -# 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 Video.""" -from typing import Optional - -from telegram._files._basethumbedmedium import _BaseThumbedMedium -from telegram._files.photosize import PhotoSize -from telegram._utils.types import JSONDict - - -class Video(_BaseThumbedMedium): - """This object represents a video file. - - Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`file_unique_id` is equal. - - Args: - file_id (:obj:`str`): Identifier for this file, which can be used to download - 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. - 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. - thumb (:class:`telegram.PhotoSize`, optional): Video thumbnail. - - .. deprecated:: 20.2 - |thumbargumentdeprecation| :paramref:`thumbnail`. - 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. - file_size (:obj:`int`, optional): File size in bytes. - thumbnail (:class:`telegram.PhotoSize`, optional): Video thumbnail. - - .. versionadded:: 20.2 - - Attributes: - file_id (:obj:`str`): Identifier for this file, which can be used to download - 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. - 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. - file_size (:obj:`int`): Optional. File size in bytes. - thumbnail (:class:`telegram.PhotoSize`): Optional. Video thumbnail. - - .. versionadded:: 20.2 - """ - - __slots__ = ("duration", "file_name", "height", "mime_type", "width") - - def __init__( - self, - file_id: str, - file_unique_id: str, - width: int, - height: int, - duration: int, - thumb: Optional[PhotoSize] = None, - mime_type: Optional[str] = None, - file_size: Optional[int] = None, - file_name: Optional[str] = None, - thumbnail: Optional[PhotoSize] = None, - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__( - file_id=file_id, - file_unique_id=file_unique_id, - file_size=file_size, - thumb=thumb, - thumbnail=thumbnail, - api_kwargs=api_kwargs, - ) - with self._unfrozen(): - # Required - self.width: int = width - self.height: int = height - self.duration: int = duration - # Optional - self.mime_type: Optional[str] = mime_type - self.file_name: Optional[str] = file_name diff --git a/telegram/_message.py b/telegram/_message.py deleted file mode 100644 index e854142eb4c..00000000000 --- a/telegram/_message.py +++ /dev/null @@ -1,3781 +0,0 @@ -#!/usr/bin/env python -# pylint: disable=too-many-instance-attributes, too-many-arguments -# -# A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 -# 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 Message.""" -import datetime -from html import escape -from typing import TYPE_CHECKING, Dict, List, Optional, Sequence, Tuple, Union - -from telegram._chat import Chat -from telegram._dice import Dice -from telegram._files.animation import Animation -from telegram._files.audio import Audio -from telegram._files.contact import Contact -from telegram._files.document import Document -from telegram._files.location import Location -from telegram._files.photosize import PhotoSize -from telegram._files.sticker import Sticker -from telegram._files.venue import Venue -from telegram._files.video import Video -from telegram._files.videonote import VideoNote -from telegram._files.voice import Voice -from telegram._forumtopic import ( - ForumTopicClosed, - ForumTopicCreated, - ForumTopicEdited, - ForumTopicReopened, - GeneralForumTopicHidden, - GeneralForumTopicUnhidden, -) -from telegram._games.game import Game -from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup -from telegram._messageautodeletetimerchanged import MessageAutoDeleteTimerChanged -from telegram._messageentity import MessageEntity -from telegram._passport.passportdata import PassportData -from telegram._payment.invoice import Invoice -from telegram._payment.successfulpayment import SuccessfulPayment -from telegram._poll import Poll -from telegram._proximityalerttriggered import ProximityAlertTriggered -from telegram._shared import ChatShared, UserShared -from telegram._telegramobject import TelegramObject -from telegram._user import User -from telegram._utils.argumentparsing import parse_sequence_arg -from telegram._utils.datetime import extract_tzinfo_from_defaults, from_timestamp -from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue -from telegram._utils.types import DVInput, FileInput, JSONDict, ODVInput, ReplyMarkup -from telegram._utils.warnings import warn -from telegram._videochat import ( - VideoChatEnded, - VideoChatParticipantsInvited, - VideoChatScheduled, - VideoChatStarted, -) -from telegram._webappdata import WebAppData -from telegram._writeaccessallowed import WriteAccessAllowed -from telegram.constants import MessageAttachmentType, ParseMode -from telegram.helpers import escape_markdown -from telegram.warnings import PTBDeprecationWarning - -if TYPE_CHECKING: - from telegram import ( - Bot, - GameHighScore, - InputMedia, - InputMediaAudio, - InputMediaDocument, - InputMediaPhoto, - InputMediaVideo, - LabeledPrice, - MessageId, - ) - - -class Message(TelegramObject): - # fmt: off - """This object represents a message. - - Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`message_id` and :attr:`chat` are equal. - - Note: - In Python :keyword:`from` is a reserved word. Use :paramref:`from_user` instead. - - .. versionchanged:: 20.0 - - * The arguments and attributes ``voice_chat_scheduled``, ``voice_chat_started`` and - ``voice_chat_ended``, ``voice_chat_participants_invited`` were renamed to - :paramref:`video_chat_scheduled`/:attr:`video_chat_scheduled`, - :paramref:`video_chat_started`/:attr:`video_chat_started`, - :paramref:`video_chat_ended`/:attr:`video_chat_ended` and - :paramref:`video_chat_participants_invited`/:attr:`video_chat_participants_invited`, - respectively, in accordance to Bot API 6.0. - * The following are now keyword-only arguments in Bot methods: - ``{read, write, connect, pool}_timeout``, ``api_kwargs``, ``contact``, ``quote``, - ``filename``, ``loaction``, ``venue``. Use a named argument for those, - and notice that some positional arguments changed position as a result. - - Args: - message_id (:obj:`int`): Unique message identifier inside this chat. - from_user (:class:`telegram.User`, optional): Sender of the message; empty for messages - sent to channels. For backward compatibility, this will contain a fake sender user in - non-channel chats, if the message was sent on behalf of a chat. - sender_chat (:class:`telegram.Chat`, optional): Sender of the message, sent on behalf of a - chat. For example, the channel itself for channel posts, the supergroup itself for - messages from anonymous group administrators, the linked channel for messages - automatically forwarded to the discussion group. For backward compatibility, - :attr:`from_user` contains a fake sender user in non-channel chats, if the message was - sent on behalf of a chat. - date (:class:`datetime.datetime`): Date the message was sent in Unix time. Converted to - :class:`datetime.datetime`. - - .. versionchanged:: 20.3 - |datetime_localization| - chat (:class:`telegram.Chat`): Conversation the message belongs to. - forward_from (:class:`telegram.User`, optional): For forwarded messages, sender of - the original message. - forward_from_chat (:class:`telegram.Chat`, optional): For messages forwarded from channels - or from anonymous administrators, information about the original sender chat. - forward_from_message_id (:obj:`int`, optional): For forwarded channel posts, identifier of - the original message in the channel. - forward_sender_name (:obj:`str`, optional): Sender's name for messages forwarded from - users who disallow adding a link to their account in forwarded messages. - forward_date (:class:`datetime.datetime`, optional): For forwarded messages, date the - original message was sent in Unix time. Converted to :class:`datetime.datetime`. - - .. versionchanged:: 20.3 - |datetime_localization| - is_automatic_forward (:obj:`bool`, optional): :obj:`True`, if the message is a channel - post that was automatically forwarded to the connected discussion group. - - .. versionadded:: 13.9 - reply_to_message (:class:`telegram.Message`, optional): For replies, the original message. - Note that the Message object in this field will not contain further - ``reply_to_message`` fields even if it itself is a reply. - edit_date (:class:`datetime.datetime`, optional): Date the message was last edited in Unix - time. Converted to :class:`datetime.datetime`. - - .. versionchanged:: 20.3 - |datetime_localization| - has_protected_content (:obj:`bool`, optional): :obj:`True`, if the message can't be - forwarded. - - .. versionadded:: 13.9 - media_group_id (:obj:`str`, optional): The unique identifier of a media message group this - message belongs to. - text (:obj:`str`, optional): For text messages, the actual UTF-8 text of the message, - 0-:tg-const:`telegram.constants.MessageLimit.MAX_TEXT_LENGTH` characters. - entities (Sequence[:class:`telegram.MessageEntity`], optional): For text messages, special - entities like usernames, URLs, bot commands, etc. that appear in the text. See - :attr:`parse_entity` and :attr:`parse_entities` methods for how to use properly. - This list is empty if the message does not contain entities. - - .. versionchanged:: 20.0 - |sequenceclassargs| - - caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): For messages with a - Caption. Special entities like usernames, URLs, bot commands, etc. that appear in the - caption. See :attr:`Message.parse_caption_entity` and :attr:`parse_caption_entities` - methods for how to use properly. This list is empty if the message does not contain - caption entities. - - .. versionchanged:: 20.0 - |sequenceclassargs| - - audio (:class:`telegram.Audio`, optional): Message is an audio file, information - about the file. - document (:class:`telegram.Document`, optional): Message is a general file, information - about the file. - animation (:class:`telegram.Animation`, optional): Message is an animation, information - 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. - 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. - - .. versionchanged:: 20.0 - |sequenceclassargs| - - sticker (:class:`telegram.Sticker`, optional): Message is a sticker, information - about the sticker. - video (:class:`telegram.Video`, optional): Message is a video, information about the - 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 - 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 - these members). This list is empty if the message does not contain new chat members. - - .. versionchanged:: 20.0 - |sequenceclassargs| - - caption (:obj:`str`, optional): Caption for the animation, audio, document, 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. - location (:class:`telegram.Location`, optional): Message is a shared location, information - about the location. - venue (:class:`telegram.Venue`, optional): Message is a venue, information about the - venue. For backward compatibility, when this field is set, the location field will - also be set. - left_chat_member (:class:`telegram.User`, optional): A member was removed from the group, - information about them (this member may be the bot itself). - new_chat_title (:obj:`str`, optional): A chat title was changed to this value. - new_chat_photo (Sequence[:class:`telegram.PhotoSize`], optional): A chat photo was changed - to this value. This list is empty if the message does not contain a new chat photo. - - .. versionchanged:: 20.0 - |sequenceclassargs| - - delete_chat_photo (:obj:`bool`, optional): Service message: The chat photo was deleted. - group_chat_created (:obj:`bool`, optional): Service message: The group has been created. - supergroup_chat_created (:obj:`bool`, optional): Service message: The supergroup has been - created. This field can't be received in a message coming through updates, because bot - can't be a member of a supergroup when it is created. It can only be found in - :attr:`reply_to_message` if someone replies to a very first message in a directly - created supergroup. - channel_chat_created (:obj:`bool`, optional): Service message: The channel has been - created. This field can't be received in a message coming through updates, because bot - can't be a member of a channel when it is created. It can only be found in - :attr:`reply_to_message` if someone replies to a very first message in a channel. - message_auto_delete_timer_changed (:class:`telegram.MessageAutoDeleteTimerChanged`, \ - optional): Service message: auto-delete timer settings changed in the chat. - - .. versionadded:: 13.4 - migrate_to_chat_id (:obj:`int`, optional): The group has been migrated to a supergroup - with the specified identifier. - migrate_from_chat_id (:obj:`int`, optional): The supergroup has been migrated from a group - with the specified identifier. - pinned_message (:class:`telegram.Message`, optional): Specified message was pinned. Note - that the Message object in this field will not contain further - :attr:`reply_to_message` fields even if it is itself a reply. - invoice (:class:`telegram.Invoice`, optional): Message is an invoice for a payment, - information about the invoice. - successful_payment (:class:`telegram.SuccessfulPayment`, optional): Message is a service - message about a successful payment, information about the payment. - connected_website (:obj:`str`, optional): The domain name of the website on which the user - has logged in. - forward_signature (:obj:`str`, optional): For messages forwarded from channels, signature - of the post author if present. - 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. - poll (:class:`telegram.Poll`, optional): Message is a native poll, - information about the poll. - dice (:class:`telegram.Dice`, optional): Message is a dice with random value. - via_bot (:class:`telegram.User`, optional): Bot through which message was sent. - proximity_alert_triggered (:class:`telegram.ProximityAlertTriggered`, optional): Service - message. A user in the chat triggered another user's proximity alert while sharing - Live Location. - video_chat_scheduled (:class:`telegram.VideoChatScheduled`, optional): Service message: - video chat scheduled. - - .. versionadded:: 20.0 - video_chat_started (:class:`telegram.VideoChatStarted`, optional): Service message: video - chat started. - - .. versionadded:: 20.0 - video_chat_ended (:class:`telegram.VideoChatEnded`, optional): Service message: video chat - ended. - - .. versionadded:: 20.0 - video_chat_participants_invited (:class:`telegram.VideoChatParticipantsInvited` optional): - Service message: new participants invited to a video chat. - - .. versionadded:: 20.0 - web_app_data (:class:`telegram.WebAppData`, optional): Service message: data sent by a Web - App. - - .. versionadded:: 20.0 - reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): Inline keyboard attached - to the message. :paramref:`~telegram.InlineKeyboardButton.login_url` buttons are - represented as ordinary url buttons. - is_topic_message (:obj:`bool`, optional): :obj:`True`, if the message is sent to a forum - topic. - - .. versionadded:: 20.0 - message_thread_id (:obj:`int`, optional): Unique identifier of a message thread to which - the message belongs; for supergroups only. - - .. versionadded:: 20.0 - forum_topic_created (:class:`telegram.ForumTopicCreated`, optional): Service message: - forum topic created. - - .. versionadded:: 20.0 - forum_topic_closed (:class:`telegram.ForumTopicClosed`, optional): Service message: - forum topic closed. - - .. versionadded:: 20.0 - forum_topic_reopened (:class:`telegram.ForumTopicReopened`, optional): Service message: - forum topic reopened. - - .. versionadded:: 20.0 - forum_topic_edited (:class:`telegram.ForumTopicEdited`, optional): Service message: - forum topic edited. - - .. versionadded:: 20.0 - general_forum_topic_hidden (:class:`telegram.GeneralForumTopicHidden`, optional): - Service message: General forum topic hidden. - - .. versionadded:: 20.0 - general_forum_topic_unhidden (:class:`telegram.GeneralForumTopicUnhidden`, optional): - Service message: General forum topic unhidden. - - .. versionadded:: 20.0 - write_access_allowed (:class:`telegram.WriteAccessAllowed`, optional): Service message: - the user allowed the bot added to the attachment menu to write messages. - - .. versionadded:: 20.0 - has_media_spoiler (:obj:`bool`, optional): :obj:`True`, if the message media is covered - by a spoiler animation. - - .. versionadded:: 20.0 - user_shared (:class:`telegram.UserShared`, optional): Service message: a user was shared - with the bot. - - .. versionadded:: 20.1 - chat_shared (:class:`telegram.ChatShared`, optional):Service message: a chat was shared - with the bot. - - .. versionadded:: 20.1 - - Attributes: - message_id (:obj:`int`): Unique message identifier inside this chat. - from_user (:class:`telegram.User`): Optional. Sender of the message; empty for messages - sent to channels. For backward compatibility, this will contain a fake sender user in - non-channel chats, if the message was sent on behalf of a chat. - sender_chat (:class:`telegram.Chat`): Optional. Sender of the message, sent on behalf of a - chat. For example, the channel itself for channel posts, the supergroup itself for - messages from anonymous group administrators, the linked channel for messages - automatically forwarded to the discussion group. For backward compatibility, - :attr:`from_user` contains a fake sender user in non-channel chats, if the message was - sent on behalf of a chat. - date (:class:`datetime.datetime`): Date the message was sent in Unix time. Converted to - :class:`datetime.datetime`. - - .. versionchanged:: 20.3 - |datetime_localization| - chat (:class:`telegram.Chat`): Conversation the message belongs to. - forward_from (:class:`telegram.User`): Optional. For forwarded messages, sender of the - original message. - forward_from_chat (:class:`telegram.Chat`): Optional. For messages forwarded from channels - or from anonymous administrators, information about the original sender chat. - forward_from_message_id (:obj:`int`): Optional. For forwarded channel posts, identifier of - the original message in the channel. - forward_date (:class:`datetime.datetime`): Optional. For forwarded messages, date the - original message was sent in Unix time. Converted to :class:`datetime.datetime`. - - .. versionchanged:: 20.3 - |datetime_localization| - is_automatic_forward (:obj:`bool`): Optional. :obj:`True`, if the message is a channel - post that was automatically forwarded to the connected discussion group. - - .. versionadded:: 13.9 - reply_to_message (:class:`telegram.Message`): Optional. For replies, the original message. - Note that the Message object in this field will not contain further - ``reply_to_message`` fields even if it itself is a reply. - edit_date (:class:`datetime.datetime`): Optional. Date the message was last edited in Unix - time. Converted to :class:`datetime.datetime`. - - .. versionchanged:: 20.3 - |datetime_localization| - has_protected_content (:obj:`bool`): Optional. :obj:`True`, if the message can't be - forwarded. - - .. versionadded:: 13.9 - media_group_id (:obj:`str`): Optional. The unique identifier of a media message group this - message belongs to. - text (:obj:`str`): Optional. For text messages, the actual UTF-8 text of the message, - 0-:tg-const:`telegram.constants.MessageLimit.MAX_TEXT_LENGTH` characters. - entities (Tuple[:class:`telegram.MessageEntity`]): Optional. For text messages, special - entities like usernames, URLs, bot commands, etc. that appear in the text. See - :attr:`parse_entity` and :attr:`parse_entities` methods for how to use properly. - This list is empty if the message does not contain entities. - - .. versionchanged:: 20.0 - |tupleclassattrs| - - caption_entities (Tuple[:class:`telegram.MessageEntity`]): Optional. For messages with a - Caption. Special entities like usernames, URLs, bot commands, etc. that appear in the - caption. See :attr:`Message.parse_caption_entity` and :attr:`parse_caption_entities` - methods for how to use properly. This list is empty if the message does not contain - caption entities. - - .. versionchanged:: 20.0 - |tupleclassattrs| - - audio (:class:`telegram.Audio`): Optional. Message is an audio file, information - about the file. - - .. seealso:: :wiki:`Working with Files and Media ` - document (:class:`telegram.Document`): Optional. Message is a general file, information - about the file. - - .. seealso:: :wiki:`Working with Files and Media ` - animation (:class:`telegram.Animation`): Optional. Message is an animation, information - about the animation. For backward compatibility, when this field is set, the document - field will also be set. - - .. seealso:: :wiki:`Working with Files and Media ` - game (:class:`telegram.Game`): Optional. Message is a game, information about the game. - 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. - - .. seealso:: :wiki:`Working with Files and Media ` - - .. versionchanged:: 20.0 - |tupleclassattrs| - - sticker (:class:`telegram.Sticker`): Optional. Message is a sticker, information - about the sticker. - - .. seealso:: :wiki:`Working with Files and Media ` - video (:class:`telegram.Video`): Optional. Message is a video, information about the - video. - - .. seealso:: :wiki:`Working with Files and Media ` - voice (:class:`telegram.Voice`): Optional. Message is a voice message, information about - the file. - - .. seealso:: :wiki:`Working with Files and Media ` - video_note (:class:`telegram.VideoNote`): Optional. Message is a video note, information - about the video message. - - .. seealso:: :wiki:`Working with Files and Media ` - new_chat_members (Tuple[: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 - these members). This list is empty if the message does not contain new chat members. - - .. versionchanged:: 20.0 - |tupleclassattrs| - caption (:obj:`str`): Optional. Caption for the animation, audio, document, 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. - location (:class:`telegram.Location`): Optional. Message is a shared location, information - about the location. - venue (:class:`telegram.Venue`): Optional. Message is a venue, information about the - venue. For backward compatibility, when this field is set, the location field will - also be set. - left_chat_member (:class:`telegram.User`): Optional. A member was removed from the group, - information about them (this member may be the bot itself). - new_chat_title (:obj:`str`): Optional. A chat title was changed to this value. - new_chat_photo (Tuple[:class:`telegram.PhotoSize`]): A chat photo was changed to - this value. This list is empty if the message does not contain a new chat photo. - - .. versionchanged:: 20.0 - |tupleclassattrs| - - delete_chat_photo (:obj:`bool`): Optional. Service message: The chat photo was deleted. - group_chat_created (:obj:`bool`): Optional. Service message: The group has been created. - supergroup_chat_created (:obj:`bool`): Optional. Service message: The supergroup has been - created. This field can't be received in a message coming through updates, because bot - can't be a member of a supergroup when it is created. It can only be found in - :attr:`reply_to_message` if someone replies to a very first message in a directly - created supergroup. - channel_chat_created (:obj:`bool`): Optional. Service message: The channel has been - created. This field can't be received in a message coming through updates, because bot - can't be a member of a channel when it is created. It can only be found in - :attr:`reply_to_message` if someone replies to a very first message in a channel. - message_auto_delete_timer_changed (:class:`telegram.MessageAutoDeleteTimerChanged`): - Optional. Service message: auto-delete timer settings changed in the chat. - - .. versionadded:: 13.4 - migrate_to_chat_id (:obj:`int`): Optional. The group has been migrated to a supergroup - with the specified identifier. - migrate_from_chat_id (:obj:`int`): Optional. The supergroup has been migrated from a group - with the specified identifier. - pinned_message (:class:`telegram.Message`): Optional. Specified message was pinned. Note - that the Message object in this field will not contain further - :attr:`reply_to_message` fields even if it is itself a reply. - invoice (:class:`telegram.Invoice`): Optional. Message is an invoice for a payment, - information about the invoice. - successful_payment (:class:`telegram.SuccessfulPayment`): Optional. Message is a service - message about a successful payment, information about the payment. - connected_website (:obj:`str`): Optional. The domain name of the website on which the user - has logged in. - forward_signature (:obj:`str`): Optional. For messages forwarded from channels, signature - of the post author if present. - author_signature (:obj:`str`): Optional. Signature of the post author for messages in - channels, or the custom title of an anonymous group administrator. - forward_sender_name (:obj:`str`): Optional. Sender's name for messages forwarded from - users who disallow adding a link to their account in forwarded messages. - passport_data (:class:`telegram.PassportData`): Optional. Telegram Passport data. - - Examples: - :any:`Passport Bot ` - poll (:class:`telegram.Poll`): Optional. Message is a native poll, - information about the poll. - dice (:class:`telegram.Dice`): Optional. Message is a dice with random value. - via_bot (:class:`telegram.User`): Optional. Bot through which message was sent. - proximity_alert_triggered (:class:`telegram.ProximityAlertTriggered`): Optional. Service - message. A user in the chat triggered another user's proximity alert while sharing - Live Location. - video_chat_scheduled (:class:`telegram.VideoChatScheduled`): Optional. Service message: - video chat scheduled. - - .. versionadded:: 20.0 - video_chat_started (:class:`telegram.VideoChatStarted`): Optional. Service message: video - chat started. - - .. versionadded:: 20.0 - video_chat_ended (:class:`telegram.VideoChatEnded`): Optional. Service message: video chat - ended. - - .. versionadded:: 20.0 - video_chat_participants_invited (:class:`telegram.VideoChatParticipantsInvited`): Optional. - Service message: new participants invited to a video chat. - - .. versionadded:: 20.0 - web_app_data (:class:`telegram.WebAppData`): Optional. Service message: data sent by a Web - App. - - .. versionadded:: 20.0 - reply_markup (:class:`telegram.InlineKeyboardMarkup`): Optional. Inline keyboard attached - to the message. :paramref:`~telegram.InlineKeyboardButton.login_url` buttons are - represented as ordinary url buttons. - is_topic_message (:obj:`bool`): Optional. :obj:`True`, if the message is sent to a forum - topic. - - .. versionadded:: 20.0 - message_thread_id (:obj:`int`): Optional. Unique identifier of a message thread to which - the message belongs; for supergroups only. - - .. versionadded:: 20.0 - forum_topic_created (:class:`telegram.ForumTopicCreated`): Optional. Service message: - forum topic created. - - .. versionadded:: 20.0 - forum_topic_closed (:class:`telegram.ForumTopicClosed`): Optional. Service message: - forum topic closed. - - .. versionadded:: 20.0 - forum_topic_reopened (:class:`telegram.ForumTopicReopened`): Optional. Service message: - forum topic reopened. - - .. versionadded:: 20.0 - forum_topic_edited (:class:`telegram.ForumTopicEdited`): Optional. Service message: - forum topic edited. - - .. versionadded:: 20.0 - general_forum_topic_hidden (:class:`telegram.GeneralForumTopicHidden`): Optional. - Service message: General forum topic hidden. - - .. versionadded:: 20.0 - general_forum_topic_unhidden (:class:`telegram.GeneralForumTopicUnhidden`): Optional. - Service message: General forum topic unhidden. - - .. versionadded:: 20.0 - write_access_allowed (:class:`telegram.WriteAccessAllowed`): Optional. Service message: - the user allowed the bot added to the attachment menu to write messages. - - .. versionadded:: 20.0 - has_media_spoiler (:obj:`bool`): Optional. :obj:`True`, if the message media is covered - by a spoiler animation. - - .. versionadded:: 20.0 - user_shared (:class:`telegram.UserShared`): Optional. Service message: a user was shared - with the bot. - - .. versionadded:: 20.1 - chat_shared (:class:`telegram.ChatShared`): Optional. Service message: a chat was shared - with the bot. - - .. versionadded:: 20.1 - - .. |custom_emoji_formatting_note| replace:: Custom emoji entities will be ignored by this - function. Instead, the supplied replacement for the emoji will be used. - - .. |custom_emoji_md1_deprecation| replace:: Since custom emoji entities are not supported by - :attr:`~telegram.constants.ParseMode.MARKDOWN`, this method will raise a - :exc:`ValueError` in future versions instead of falling back to the supplied replacement - for the emoji. - """ - - # fmt: on - __slots__ = ( - "reply_markup", - "audio", - "contact", - "migrate_to_chat_id", - "forward_signature", - "chat", - "successful_payment", - "game", - "text", - "forward_sender_name", - "document", - "new_chat_title", - "forward_date", - "group_chat_created", - "media_group_id", - "caption", - "video", - "entities", - "via_bot", - "new_chat_members", - "connected_website", - "animation", - "migrate_from_chat_id", - "forward_from", - "sticker", - "location", - "venue", - "edit_date", - "reply_to_message", - "passport_data", - "pinned_message", - "forward_from_chat", - "new_chat_photo", - "message_id", - "delete_chat_photo", - "from_user", - "author_signature", - "proximity_alert_triggered", - "sender_chat", - "dice", - "forward_from_message_id", - "caption_entities", - "voice", - "date", - "supergroup_chat_created", - "poll", - "left_chat_member", - "photo", - "channel_chat_created", - "invoice", - "video_note", - "_effective_attachment", - "message_auto_delete_timer_changed", - "video_chat_ended", - "video_chat_participants_invited", - "video_chat_started", - "video_chat_scheduled", - "is_automatic_forward", - "has_protected_content", - "web_app_data", - "is_topic_message", - "message_thread_id", - "forum_topic_created", - "forum_topic_closed", - "forum_topic_reopened", - "forum_topic_edited", - "general_forum_topic_hidden", - "general_forum_topic_unhidden", - "write_access_allowed", - "has_media_spoiler", - "user_shared", - "chat_shared", - ) - - def __init__( - self, - message_id: int, - date: datetime.datetime, - chat: Chat, - from_user: Optional[User] = None, - forward_from: Optional[User] = None, - forward_from_chat: Optional[Chat] = None, - forward_from_message_id: Optional[int] = None, - forward_date: Optional[datetime.datetime] = None, - reply_to_message: Optional["Message"] = None, - edit_date: Optional[datetime.datetime] = None, - text: Optional[str] = None, - entities: Optional[Sequence["MessageEntity"]] = None, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - audio: Optional[Audio] = None, - document: Optional[Document] = None, - game: Optional[Game] = None, - photo: Optional[Sequence[PhotoSize]] = None, - sticker: Optional[Sticker] = None, - video: Optional[Video] = None, - voice: Optional[Voice] = None, - video_note: Optional[VideoNote] = None, - new_chat_members: Optional[Sequence[User]] = None, - caption: Optional[str] = None, - contact: Optional[Contact] = None, - location: Optional[Location] = None, - venue: Optional[Venue] = None, - left_chat_member: Optional[User] = None, - new_chat_title: Optional[str] = None, - new_chat_photo: Optional[Sequence[PhotoSize]] = None, - delete_chat_photo: Optional[bool] = None, - group_chat_created: Optional[bool] = None, - supergroup_chat_created: Optional[bool] = None, - channel_chat_created: Optional[bool] = None, - migrate_to_chat_id: Optional[int] = None, - migrate_from_chat_id: Optional[int] = None, - pinned_message: Optional["Message"] = None, - invoice: Optional[Invoice] = None, - successful_payment: Optional[SuccessfulPayment] = None, - forward_signature: Optional[str] = None, - author_signature: Optional[str] = None, - media_group_id: Optional[str] = None, - connected_website: Optional[str] = None, - animation: Optional[Animation] = None, - passport_data: Optional[PassportData] = None, - poll: Optional[Poll] = None, - forward_sender_name: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - dice: Optional[Dice] = None, - via_bot: Optional[User] = None, - proximity_alert_triggered: Optional[ProximityAlertTriggered] = None, - sender_chat: Optional[Chat] = None, - video_chat_started: Optional[VideoChatStarted] = None, - video_chat_ended: Optional[VideoChatEnded] = None, - video_chat_participants_invited: Optional[VideoChatParticipantsInvited] = None, - message_auto_delete_timer_changed: Optional[MessageAutoDeleteTimerChanged] = None, - video_chat_scheduled: Optional[VideoChatScheduled] = None, - is_automatic_forward: Optional[bool] = None, - has_protected_content: Optional[bool] = None, - web_app_data: Optional[WebAppData] = None, - is_topic_message: Optional[bool] = None, - message_thread_id: Optional[int] = None, - forum_topic_created: Optional[ForumTopicCreated] = None, - forum_topic_closed: Optional[ForumTopicClosed] = None, - forum_topic_reopened: Optional[ForumTopicReopened] = None, - forum_topic_edited: Optional[ForumTopicEdited] = None, - general_forum_topic_hidden: Optional[GeneralForumTopicHidden] = None, - general_forum_topic_unhidden: Optional[GeneralForumTopicUnhidden] = None, - write_access_allowed: Optional[WriteAccessAllowed] = None, - has_media_spoiler: Optional[bool] = None, - user_shared: Optional[UserShared] = None, - chat_shared: Optional[ChatShared] = None, - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - - # Required - self.message_id: int = message_id - # Optionals - self.from_user: Optional[User] = from_user - self.sender_chat: Optional[Chat] = sender_chat - self.date: datetime.datetime = date - self.chat: Chat = chat - self.forward_from: Optional[User] = forward_from - self.forward_from_chat: Optional[Chat] = forward_from_chat - self.forward_date: Optional[datetime.datetime] = forward_date - self.is_automatic_forward: Optional[bool] = is_automatic_forward - self.reply_to_message: Optional[Message] = reply_to_message - self.edit_date: Optional[datetime.datetime] = edit_date - self.has_protected_content: Optional[bool] = has_protected_content - self.text: Optional[str] = text - self.entities: Tuple["MessageEntity", ...] = parse_sequence_arg(entities) - self.caption_entities: Tuple["MessageEntity", ...] = parse_sequence_arg(caption_entities) - self.audio: Optional[Audio] = audio - self.game: Optional[Game] = game - self.document: Optional[Document] = document - self.photo: Tuple[PhotoSize, ...] = parse_sequence_arg(photo) - self.sticker: Optional[Sticker] = sticker - self.video: Optional[Video] = video - self.voice: Optional[Voice] = voice - self.video_note: Optional[VideoNote] = video_note - self.caption: Optional[str] = caption - self.contact: Optional[Contact] = contact - self.location: Optional[Location] = location - self.venue: Optional[Venue] = venue - self.new_chat_members: Tuple[User, ...] = parse_sequence_arg(new_chat_members) - self.left_chat_member: Optional[User] = left_chat_member - self.new_chat_title: Optional[str] = new_chat_title - self.new_chat_photo: Tuple[PhotoSize, ...] = parse_sequence_arg(new_chat_photo) - self.delete_chat_photo: Optional[bool] = bool(delete_chat_photo) - self.group_chat_created: Optional[bool] = bool(group_chat_created) - self.supergroup_chat_created: Optional[bool] = bool(supergroup_chat_created) - self.migrate_to_chat_id: Optional[int] = migrate_to_chat_id - self.migrate_from_chat_id: Optional[int] = migrate_from_chat_id - self.channel_chat_created: Optional[bool] = bool(channel_chat_created) - self.message_auto_delete_timer_changed: Optional[ - MessageAutoDeleteTimerChanged - ] = message_auto_delete_timer_changed - self.pinned_message: Optional[Message] = pinned_message - self.forward_from_message_id: Optional[int] = forward_from_message_id - self.invoice: Optional[Invoice] = invoice - self.successful_payment: Optional[SuccessfulPayment] = successful_payment - self.connected_website: Optional[str] = connected_website - self.forward_signature: Optional[str] = forward_signature - self.forward_sender_name: Optional[str] = forward_sender_name - self.author_signature: Optional[str] = author_signature - self.media_group_id: Optional[str] = media_group_id - self.animation: Optional[Animation] = animation - self.passport_data: Optional[PassportData] = passport_data - self.poll: Optional[Poll] = poll - self.dice: Optional[Dice] = dice - self.via_bot: Optional[User] = via_bot - self.proximity_alert_triggered: Optional[ - ProximityAlertTriggered - ] = proximity_alert_triggered - self.video_chat_scheduled: Optional[VideoChatScheduled] = video_chat_scheduled - self.video_chat_started: Optional[VideoChatStarted] = video_chat_started - self.video_chat_ended: Optional[VideoChatEnded] = video_chat_ended - self.video_chat_participants_invited: Optional[ - VideoChatParticipantsInvited - ] = video_chat_participants_invited - self.reply_markup: Optional[InlineKeyboardMarkup] = reply_markup - self.web_app_data: Optional[WebAppData] = web_app_data - self.is_topic_message: Optional[bool] = is_topic_message - self.message_thread_id: Optional[int] = message_thread_id - self.forum_topic_created: Optional[ForumTopicCreated] = forum_topic_created - self.forum_topic_closed: Optional[ForumTopicClosed] = forum_topic_closed - self.forum_topic_reopened: Optional[ForumTopicReopened] = forum_topic_reopened - self.forum_topic_edited: Optional[ForumTopicEdited] = forum_topic_edited - self.general_forum_topic_hidden: Optional[ - GeneralForumTopicHidden - ] = general_forum_topic_hidden - self.general_forum_topic_unhidden: Optional[ - GeneralForumTopicUnhidden - ] = general_forum_topic_unhidden - self.write_access_allowed: Optional[WriteAccessAllowed] = write_access_allowed - self.has_media_spoiler: Optional[bool] = has_media_spoiler - self.user_shared: Optional[UserShared] = user_shared - self.chat_shared: Optional[ChatShared] = chat_shared - - self._effective_attachment = DEFAULT_NONE - - self._id_attrs = (self.message_id, self.chat) - - self._freeze() - - @property - def chat_id(self) -> int: - """:obj:`int`: Shortcut for :attr:`telegram.Chat.id` for :attr:`chat`.""" - return self.chat.id - - @property - def id(self) -> int: # pylint: disable=invalid-name - """ - :obj:`int`: Shortcut for :attr:`message_id`. - - .. versionadded:: 20.0 - """ - return self.message_id - - @property - def link(self) -> Optional[str]: - """:obj:`str`: Convenience property. If the chat of the message is not - a private chat or normal group, returns a t.me link of the message. - - .. versionchanged:: 20.3 - For messages that are replies or part of a forum topic, the link now points - to the corresponding thread view. - """ - if self.chat.type not in [Chat.PRIVATE, Chat.GROUP]: - # the else block gets rid of leading -100 for supergroups: - to_link = self.chat.username if self.chat.username else f"c/{str(self.chat.id)[4:]}" - baselink = f"https://t.me/{to_link}/{self.message_id}" - - # adds the thread for topics and replies - if (self.is_topic_message and self.message_thread_id) or self.reply_to_message: - baselink = f"{baselink}?thread={self.message_thread_id}" - return baselink - return None - - @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Message"]: - """See :meth:`telegram.TelegramObject.de_json`.""" - 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["from_user"] = User.de_json(data.pop("from", None), bot) - data["sender_chat"] = Chat.de_json(data.get("sender_chat"), bot) - data["date"] = from_timestamp(data["date"], tzinfo=loc_tzinfo) - data["chat"] = Chat.de_json(data.get("chat"), bot) - data["entities"] = MessageEntity.de_list(data.get("entities"), bot) - data["caption_entities"] = MessageEntity.de_list(data.get("caption_entities"), bot) - data["forward_from"] = User.de_json(data.get("forward_from"), bot) - data["forward_from_chat"] = Chat.de_json(data.get("forward_from_chat"), bot) - data["forward_date"] = from_timestamp(data.get("forward_date"), tzinfo=loc_tzinfo) - data["reply_to_message"] = Message.de_json(data.get("reply_to_message"), bot) - data["edit_date"] = from_timestamp(data.get("edit_date"), tzinfo=loc_tzinfo) - data["audio"] = Audio.de_json(data.get("audio"), bot) - data["document"] = Document.de_json(data.get("document"), bot) - data["animation"] = Animation.de_json(data.get("animation"), bot) - data["game"] = Game.de_json(data.get("game"), bot) - data["photo"] = PhotoSize.de_list(data.get("photo"), bot) - data["sticker"] = Sticker.de_json(data.get("sticker"), bot) - data["video"] = Video.de_json(data.get("video"), bot) - data["voice"] = Voice.de_json(data.get("voice"), bot) - data["video_note"] = VideoNote.de_json(data.get("video_note"), bot) - data["contact"] = Contact.de_json(data.get("contact"), bot) - data["location"] = Location.de_json(data.get("location"), bot) - data["venue"] = Venue.de_json(data.get("venue"), bot) - data["new_chat_members"] = User.de_list(data.get("new_chat_members"), bot) - data["left_chat_member"] = User.de_json(data.get("left_chat_member"), bot) - data["new_chat_photo"] = PhotoSize.de_list(data.get("new_chat_photo"), bot) - data["message_auto_delete_timer_changed"] = MessageAutoDeleteTimerChanged.de_json( - data.get("message_auto_delete_timer_changed"), bot - ) - data["pinned_message"] = Message.de_json(data.get("pinned_message"), bot) - data["invoice"] = Invoice.de_json(data.get("invoice"), bot) - data["successful_payment"] = SuccessfulPayment.de_json(data.get("successful_payment"), bot) - data["passport_data"] = PassportData.de_json(data.get("passport_data"), bot) - data["poll"] = Poll.de_json(data.get("poll"), bot) - data["dice"] = Dice.de_json(data.get("dice"), bot) - data["via_bot"] = User.de_json(data.get("via_bot"), bot) - data["proximity_alert_triggered"] = ProximityAlertTriggered.de_json( - data.get("proximity_alert_triggered"), bot - ) - data["reply_markup"] = InlineKeyboardMarkup.de_json(data.get("reply_markup"), bot) - data["video_chat_scheduled"] = VideoChatScheduled.de_json( - data.get("video_chat_scheduled"), bot - ) - data["video_chat_started"] = VideoChatStarted.de_json(data.get("video_chat_started"), bot) - data["video_chat_ended"] = VideoChatEnded.de_json(data.get("video_chat_ended"), bot) - data["video_chat_participants_invited"] = VideoChatParticipantsInvited.de_json( - data.get("video_chat_participants_invited"), bot - ) - data["web_app_data"] = WebAppData.de_json(data.get("web_app_data"), bot) - data["forum_topic_closed"] = ForumTopicClosed.de_json(data.get("forum_topic_closed"), bot) - data["forum_topic_created"] = ForumTopicCreated.de_json( - data.get("forum_topic_created"), bot - ) - data["forum_topic_reopened"] = ForumTopicReopened.de_json( - data.get("forum_topic_reopened"), bot - ) - data["forum_topic_edited"] = ForumTopicEdited.de_json(data.get("forum_topic_edited"), bot) - data["general_forum_topic_hidden"] = GeneralForumTopicHidden.de_json( - data.get("general_forum_topic_hidden"), bot - ) - data["general_forum_topic_unhidden"] = GeneralForumTopicUnhidden.de_json( - data.get("general_forum_topic_unhidden"), bot - ) - data["write_access_allowed"] = WriteAccessAllowed.de_json( - data.get("write_access_allowed"), bot - ) - data["user_shared"] = UserShared.de_json(data.get("user_shared"), bot) - data["chat_shared"] = ChatShared.de_json(data.get("chat_shared"), bot) - - return super().de_json(data=data, bot=bot) - - @property - def effective_attachment( - self, - ) -> Union[ - Animation, - Audio, - Contact, - Dice, - Document, - Game, - Invoice, - Location, - PassportData, - Sequence[PhotoSize], - Poll, - Sticker, - SuccessfulPayment, - Venue, - Video, - VideoNote, - 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 - - * :class:`telegram.Audio` - * :class:`telegram.Dice` - * :class:`telegram.Contact` - * :class:`telegram.Document` - * :class:`telegram.Animation` - * :class:`telegram.Game` - * :class:`telegram.Invoice` - * :class:`telegram.Location` - * :class:`telegram.PassportData` - * List[:class:`telegram.PhotoSize`] - * :class:`telegram.Poll` - * :class:`telegram.Sticker` - * :class:`telegram.SuccessfulPayment` - * :class:`telegram.Venue` - * :class:`telegram.Video` - * :class:`telegram.VideoNote` - * :class:`telegram.Voice` - - Otherwise :obj:`None` is returned. - - .. seealso:: :wiki:`Working with Files and Media ` - - .. versionchanged:: 20.0 - :attr:`dice`, :attr:`passport_data` and :attr:`poll` are now also considered to be an - attachment. - - """ - if not isinstance(self._effective_attachment, DefaultValue): - return self._effective_attachment - - for attachment_type in MessageAttachmentType: - if self[attachment_type]: - self._effective_attachment = self[attachment_type] # type: ignore[assignment] - break - else: - self._effective_attachment = None - - return self._effective_attachment # type: ignore[return-value] - - def _quote(self, quote: Optional[bool], reply_to_message_id: Optional[int]) -> Optional[int]: - """Modify kwargs for replying with or without quoting.""" - if reply_to_message_id is not None: - return reply_to_message_id - - if quote is not None: - if quote: - return self.message_id - - else: - # Unfortunately we need some ExtBot logic here because it's hard to move shortcut - # logic into ExtBot - if hasattr(self.get_bot(), "defaults") and self.get_bot().defaults: # type: ignore - default_quote = self.get_bot().defaults.quote # type: ignore[attr-defined] - else: - default_quote = None - if (default_quote is None and self.chat.type != Chat.PRIVATE) or default_quote: - return self.message_id - - return None - - async def reply_text( - self, - text: str, - parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_message(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_message`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the message is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_message( - chat_id=self.chat_id, - text=text, - parse_mode=parse_mode, - disable_web_page_preview=disable_web_page_preview, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - allow_sending_without_reply=allow_sending_without_reply, - entities=entities, - protect_content=protect_content, - message_thread_id=message_thread_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def reply_markdown( - self, - text: str, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_message( - update.effective_message.chat_id, - parse_mode=ParseMode.MARKDOWN, - *args, - **kwargs, - ) - - Sends a message with Markdown version 1 formatting. - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_message`. - - Note: - :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by - Telegram for backward compatibility. You should use :meth:`reply_markdown_v2` instead. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the message is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_message( - chat_id=self.chat_id, - text=text, - parse_mode=ParseMode.MARKDOWN, - disable_web_page_preview=disable_web_page_preview, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - allow_sending_without_reply=allow_sending_without_reply, - entities=entities, - protect_content=protect_content, - message_thread_id=message_thread_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def reply_markdown_v2( - self, - text: str, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_message( - update.effective_message.chat_id, - parse_mode=ParseMode.MARKDOWN_V2, - *args, - **kwargs, - ) - - Sends a message with markdown version 2 formatting. - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_message`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the message is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_message( - chat_id=self.chat_id, - text=text, - parse_mode=ParseMode.MARKDOWN_V2, - disable_web_page_preview=disable_web_page_preview, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - allow_sending_without_reply=allow_sending_without_reply, - entities=entities, - protect_content=protect_content, - message_thread_id=message_thread_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def reply_html( - self, - text: str, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_message( - update.effective_message.chat_id, - parse_mode=ParseMode.HTML, - *args, - **kwargs, - ) - - Sends a message with HTML formatting. - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_message`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the message is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_message( - chat_id=self.chat_id, - text=text, - parse_mode=ParseMode.HTML, - disable_web_page_preview=disable_web_page_preview, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - allow_sending_without_reply=allow_sending_without_reply, - entities=entities, - protect_content=protect_content, - message_thread_id=message_thread_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def reply_media_group( - self, - media: Sequence[ - Union["InputMediaAudio", "InputMediaDocument", "InputMediaPhoto", "InputMediaVideo"] - ], - disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - caption: Optional[str] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - ) -> Tuple["Message", ...]: - """Shortcut for:: - - await bot.send_media_group(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_media_group`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the media group is sent as an - actual reply to this message. If ``reply_to_message_id`` is passed, this parameter - will be ignored. Default: :obj:`True` in group chats and :obj:`False` in private - chats. - - Returns: - Tuple[:class:`telegram.Message`]: An array of the sent Messages. - - Raises: - :class:`telegram.error.TelegramError` - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_media_group( - chat_id=self.chat_id, - media=media, - disable_notification=disable_notification, - 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, - allow_sending_without_reply=allow_sending_without_reply, - protect_content=protect_content, - message_thread_id=message_thread_id, - caption=caption, - parse_mode=parse_mode, - caption_entities=caption_entities, - ) - - async def reply_photo( - self, - photo: Union[FileInput, "PhotoSize"], - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - *, - filename: Optional[str] = None, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_photo(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_photo`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the photo is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_photo( - chat_id=self.chat_id, - photo=photo, - caption=caption, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - parse_mode=parse_mode, - allow_sending_without_reply=allow_sending_without_reply, - caption_entities=caption_entities, - filename=filename, - protect_content=protect_content, - message_thread_id=message_thread_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - has_spoiler=has_spoiler, - ) - - async def reply_audio( - self, - audio: Union[FileInput, "Audio"], - duration: Optional[int] = None, - performer: Optional[str] = None, - title: Optional[str] = None, - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - thumb: Optional[FileInput] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - *, - filename: Optional[str] = None, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_audio(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_audio`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the audio is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_audio( - chat_id=self.chat_id, - audio=audio, - duration=duration, - performer=performer, - title=title, - caption=caption, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - parse_mode=parse_mode, - thumb=thumb, - allow_sending_without_reply=allow_sending_without_reply, - caption_entities=caption_entities, - filename=filename, - protect_content=protect_content, - message_thread_id=message_thread_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - thumbnail=thumbnail, - ) - - async def reply_document( - self, - document: Union[FileInput, "Document"], - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - thumb: Optional[FileInput] = None, - disable_content_type_detection: Optional[bool] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - *, - filename: Optional[str] = None, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_document(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_document`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the document is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_document( - chat_id=self.chat_id, - document=document, - filename=filename, - caption=caption, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - parse_mode=parse_mode, - thumb=thumb, - api_kwargs=api_kwargs, - disable_content_type_detection=disable_content_type_detection, - allow_sending_without_reply=allow_sending_without_reply, - caption_entities=caption_entities, - protect_content=protect_content, - message_thread_id=message_thread_id, - thumbnail=thumbnail, - ) - - async def reply_animation( - self, - animation: Union[FileInput, "Animation"], - duration: Optional[int] = None, - width: Optional[int] = None, - height: Optional[int] = None, - thumb: Optional[FileInput] = None, - caption: Optional[str] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, - *, - filename: Optional[str] = None, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_animation(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_animation`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the animation is sent as an - actual reply to this message. If ``reply_to_message_id`` is passed, this parameter - will be ignored. Default: :obj:`True` in group chats and :obj:`False` in private - chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_animation( - chat_id=self.chat_id, - animation=animation, - duration=duration, - width=width, - height=height, - thumb=thumb, - caption=caption, - parse_mode=parse_mode, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - caption_entities=caption_entities, - filename=filename, - protect_content=protect_content, - message_thread_id=message_thread_id, - has_spoiler=has_spoiler, - thumbnail=thumbnail, - ) - - async def reply_sticker( - self, - sticker: Union[FileInput, "Sticker"], - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - emoji: Optional[str] = None, - *, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_sticker(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_sticker`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the sticker is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_sticker( - chat_id=self.chat_id, - sticker=sticker, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - protect_content=protect_content, - message_thread_id=message_thread_id, - emoji=emoji, - ) - - async def reply_video( - self, - video: Union[FileInput, "Video"], - duration: Optional[int] = None, - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - width: Optional[int] = None, - height: Optional[int] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - supports_streaming: Optional[bool] = None, - thumb: Optional[FileInput] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, - *, - filename: Optional[str] = None, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_video(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_video`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the video is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_video( - chat_id=self.chat_id, - video=video, - duration=duration, - caption=caption, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - width=width, - height=height, - parse_mode=parse_mode, - supports_streaming=supports_streaming, - thumb=thumb, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - caption_entities=caption_entities, - filename=filename, - protect_content=protect_content, - message_thread_id=message_thread_id, - has_spoiler=has_spoiler, - thumbnail=thumbnail, - ) - - async def reply_video_note( - self, - video_note: Union[FileInput, "VideoNote"], - duration: Optional[int] = None, - length: Optional[int] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - thumb: Optional[FileInput] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - *, - filename: Optional[str] = None, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_video_note(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_video_note`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the video note is sent as an - actual reply to this message. If ``reply_to_message_id`` is passed, this parameter - will be ignored. Default: :obj:`True` in group chats and :obj:`False` in private - chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_video_note( - chat_id=self.chat_id, - video_note=video_note, - duration=duration, - length=length, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - thumb=thumb, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - filename=filename, - protect_content=protect_content, - message_thread_id=message_thread_id, - thumbnail=thumbnail, - ) - - async def reply_voice( - self, - voice: Union[FileInput, "Voice"], - duration: Optional[int] = None, - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - filename: Optional[str] = None, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_voice(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_voice`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the voice note is sent as an - actual reply to this message. If ``reply_to_message_id`` is passed, this parameter - will be ignored. Default: :obj:`True` in group chats and :obj:`False` in private - chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_voice( - chat_id=self.chat_id, - voice=voice, - duration=duration, - caption=caption, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - parse_mode=parse_mode, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - caption_entities=caption_entities, - filename=filename, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def reply_location( - self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - live_period: Optional[int] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - location: Optional[Location] = None, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_location(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_location`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the location is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_location( - chat_id=self.chat_id, - latitude=latitude, - longitude=longitude, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - location=location, - live_period=live_period, - api_kwargs=api_kwargs, - horizontal_accuracy=horizontal_accuracy, - heading=heading, - proximity_alert_radius=proximity_alert_radius, - allow_sending_without_reply=allow_sending_without_reply, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def reply_venue( - self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - title: Optional[str] = None, - address: Optional[str] = None, - foursquare_id: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - foursquare_type: Optional[str] = None, - google_place_id: Optional[str] = None, - google_place_type: Optional[str] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - venue: Optional[Venue] = None, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_venue(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_venue`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the venue is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_venue( - chat_id=self.chat_id, - latitude=latitude, - longitude=longitude, - title=title, - address=address, - foursquare_id=foursquare_id, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - venue=venue, - foursquare_type=foursquare_type, - api_kwargs=api_kwargs, - google_place_id=google_place_id, - google_place_type=google_place_type, - allow_sending_without_reply=allow_sending_without_reply, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def reply_contact( - self, - phone_number: Optional[str] = None, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - vcard: Optional[str] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - contact: Optional[Contact] = None, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_contact(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_contact`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the contact is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_contact( - chat_id=self.chat_id, - phone_number=phone_number, - first_name=first_name, - last_name=last_name, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - contact=contact, - vcard=vcard, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def reply_poll( - self, - question: str, - options: Sequence[str], - is_anonymous: Optional[bool] = None, - type: Optional[str] = None, # pylint: disable=redefined-builtin - allows_multiple_answers: Optional[bool] = None, - correct_option_id: Optional[int] = None, - is_closed: Optional[bool] = None, - disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - explanation: Optional[str] = None, - explanation_parse_mode: ODVInput[str] = DEFAULT_NONE, - open_period: Optional[int] = None, - close_date: Optional[Union[int, datetime.datetime]] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - explanation_entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_poll(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_poll`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the poll is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_poll( - chat_id=self.chat_id, - question=question, - options=options, - is_anonymous=is_anonymous, - type=type, - allows_multiple_answers=allows_multiple_answers, - correct_option_id=correct_option_id, - is_closed=is_closed, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - explanation=explanation, - explanation_parse_mode=explanation_parse_mode, - open_period=open_period, - close_date=close_date, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - explanation_entities=explanation_entities, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def reply_dice( - self, - disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - emoji: Optional[str] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_dice(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_dice`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the dice is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_dice( - chat_id=self.chat_id, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - emoji=emoji, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def reply_chat_action( - self, - action: str, - message_thread_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, - ) -> bool: - """Shortcut for:: - - await bot.send_chat_action(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_chat_action`. - - .. versionadded:: 13.2 - - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - - """ - return await self.get_bot().send_chat_action( - chat_id=self.chat_id, - message_thread_id=message_thread_id, - action=action, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def reply_game( - self, - game_short_name: str, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_game(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_game`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the game is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - .. versionadded:: 13.2 - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_game( - chat_id=self.chat_id, - game_short_name=game_short_name, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def reply_invoice( - self, - title: str, - description: str, - payload: str, - provider_token: str, - currency: str, - prices: Sequence["LabeledPrice"], - start_parameter: Optional[str] = None, - photo_url: Optional[str] = None, - photo_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - need_name: Optional[bool] = None, - need_phone_number: Optional[bool] = None, - need_email: Optional[bool] = None, - need_shipping_address: Optional[bool] = None, - is_flexible: Optional[bool] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - provider_data: Optional[Union[str, object]] = None, - send_phone_number_to_provider: Optional[bool] = None, - send_email_to_provider: Optional[bool] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - max_tip_amount: Optional[int] = None, - suggested_tip_amounts: Optional[Sequence[int]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_invoice(update.effective_message.chat_id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_invoice`. - - Warning: - As of API 5.2 :paramref:`start_parameter ` - is an optional argument and therefore the - order of the arguments had to be changed. Use keyword arguments to make sure that the - arguments are passed correctly. - - .. versionadded:: 13.2 - - .. versionchanged:: 13.5 - As of Bot API 5.2, the parameter - :paramref:`start_parameter ` is optional. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the invoice is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().send_invoice( - chat_id=self.chat_id, - title=title, - description=description, - payload=payload, - provider_token=provider_token, - currency=currency, - prices=prices, - start_parameter=start_parameter, - photo_url=photo_url, - photo_size=photo_size, - photo_width=photo_width, - photo_height=photo_height, - need_name=need_name, - need_phone_number=need_phone_number, - need_email=need_email, - need_shipping_address=need_shipping_address, - is_flexible=is_flexible, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - provider_data=provider_data, - send_phone_number_to_provider=send_phone_number_to_provider, - send_email_to_provider=send_email_to_provider, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - max_tip_amount=max_tip_amount, - suggested_tip_amounts=suggested_tip_amounts, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def forward( - self, - chat_id: Union[int, str], - disable_notification: DVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_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.forward_message( - from_chat_id=update.effective_message.chat_id, - message_id=update.effective_message.message_id, - *args, - **kwargs - ) - - For the documentation of the arguments, please see :meth:`telegram.Bot.forward_message`. - - Note: - Since the release of Bot API 5.5 it can be impossible to forward messages from - some chats. Use the attributes :attr:`telegram.Message.has_protected_content` and - :attr:`telegram.Chat.has_protected_content` to check this. - - As a workaround, it is still possible to use :meth:`copy`. However, this - behaviour is undocumented and might be changed by Telegram. - - Returns: - :class:`telegram.Message`: On success, instance representing the message forwarded. - - """ - return await self.get_bot().forward_message( - chat_id=chat_id, - from_chat_id=self.chat_id, - message_id=self.message_id, - disable_notification=disable_notification, - protect_content=protect_content, - message_thread_id=message_thread_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def copy( - self, - chat_id: Union[int, str], - caption: Optional[str] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - allow_sending_without_reply: DVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_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, - ) -> "MessageId": - """Shortcut for:: - - await bot.copy_message( - chat_id=chat_id, - from_chat_id=update.effective_message.chat_id, - message_id=update.effective_message.message_id, - *args, - **kwargs - ) - - For the documentation of the arguments, please see :meth:`telegram.Bot.copy_message`. - - Returns: - :class:`telegram.MessageId`: On success, returns the MessageId of the sent message. - - """ - return await self.get_bot().copy_message( - chat_id=chat_id, - from_chat_id=self.chat_id, - message_id=self.message_id, - caption=caption, - parse_mode=parse_mode, - caption_entities=caption_entities, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - allow_sending_without_reply=allow_sending_without_reply, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def reply_copy( - self, - from_chat_id: Union[str, int], - message_id: int, - caption: Optional[str] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - allow_sending_without_reply: DVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - quote: Optional[bool] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "MessageId": - """Shortcut for:: - - await bot.copy_message( - chat_id=message.chat.id, - message_id=message_id, - *args, - **kwargs - ) - - For the documentation of the arguments, please see :meth:`telegram.Bot.copy_message`. - - Keyword Args: - quote (:obj:`bool`, optional): If set to :obj:`True`, the copy is sent as an actual - reply to this message. If ``reply_to_message_id`` is passed, this parameter will be - ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - - .. versionadded:: 13.1 - - Returns: - :class:`telegram.MessageId`: On success, returns the MessageId of the sent message. - - """ - reply_to_message_id = self._quote(quote, reply_to_message_id) - return await self.get_bot().copy_message( - chat_id=self.chat_id, - from_chat_id=from_chat_id, - message_id=message_id, - caption=caption, - parse_mode=parse_mode, - caption_entities=caption_entities, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - allow_sending_without_reply=allow_sending_without_reply, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def edit_text( - self, - text: str, - parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[InlineKeyboardMarkup] = None, - entities: Optional[Sequence["MessageEntity"]] = None, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union["Message", bool]: - """Shortcut for:: - - await bot.edit_message_text( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs - ) - - For the documentation of the arguments, please see :meth:`telegram.Bot.edit_message_text`. - - Note: - You can only edit messages that the bot sent itself (i.e. of the ``bot.send_*`` family - 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. - - Returns: - :class:`telegram.Message`: On success, if edited message is sent by the bot, the - edited Message is returned, otherwise ``True`` is returned. - - """ - return await self.get_bot().edit_message_text( - chat_id=self.chat_id, - message_id=self.message_id, - text=text, - parse_mode=parse_mode, - disable_web_page_preview=disable_web_page_preview, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - entities=entities, - inline_message_id=None, - ) - - async def edit_caption( - self, - caption: Optional[str] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union["Message", bool]: - """Shortcut for:: - - await bot.edit_message_caption( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs - ) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.edit_message_caption`. - - Note: - You can only edit messages that the bot sent itself (i.e. of the ``bot.send_*`` family - 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. - - Returns: - :class:`telegram.Message`: On success, if edited message is sent by the bot, the - edited Message is returned, otherwise ``True`` is returned. - - """ - return await self.get_bot().edit_message_caption( - chat_id=self.chat_id, - message_id=self.message_id, - caption=caption, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - parse_mode=parse_mode, - api_kwargs=api_kwargs, - caption_entities=caption_entities, - inline_message_id=None, - ) - - async def edit_media( - self, - media: "InputMedia", - reply_markup: Optional[InlineKeyboardMarkup] = None, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union["Message", bool]: - """Shortcut for:: - - await bot.edit_message_media( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs - ) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.edit_message_media`. - - Note: - You can only edit messages that the bot sent itself(i.e. of the ``bot.send_*`` family - 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. - - Returns: - :class:`telegram.Message`: On success, if edited message is not an inline message, the - edited Message is returned, otherwise ``True`` is returned. - - """ - return await self.get_bot().edit_message_media( - media=media, - chat_id=self.chat_id, - message_id=self.message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - inline_message_id=None, - ) - - async def edit_reply_markup( - self, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union["Message", bool]: - """Shortcut for:: - - await bot.edit_message_reply_markup( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs - ) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.edit_message_reply_markup`. - - Note: - You can only edit messages that the bot sent itself (i.e. of the ``bot.send_*`` family - 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. - - Returns: - :class:`telegram.Message`: On success, if edited message is sent by the bot, the - edited Message is returned, otherwise ``True`` is returned. - """ - return await self.get_bot().edit_message_reply_markup( - chat_id=self.chat_id, - message_id=self.message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - inline_message_id=None, - ) - - async def edit_live_location( - self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - reply_markup: Optional[InlineKeyboardMarkup] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, - *, - location: Optional[Location] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union["Message", bool]: - """Shortcut for:: - - await bot.edit_message_live_location( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs - ) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.edit_message_live_location`. - - Note: - You can only edit messages that the bot sent itself (i.e. of the ``bot.send_*`` family - 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. - - Returns: - :class:`telegram.Message`: On success, if edited message is sent by the bot, the - edited Message is returned, otherwise :obj:`True` is returned. - """ - return await self.get_bot().edit_message_live_location( - chat_id=self.chat_id, - message_id=self.message_id, - latitude=latitude, - longitude=longitude, - location=location, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - horizontal_accuracy=horizontal_accuracy, - heading=heading, - proximity_alert_radius=proximity_alert_radius, - inline_message_id=None, - ) - - async def stop_live_location( - self, - reply_markup: Optional[InlineKeyboardMarkup] = None, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union["Message", bool]: - """Shortcut for:: - - await bot.stop_message_live_location( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs - ) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.stop_message_live_location`. - - Note: - You can only edit messages that the bot sent itself (i.e. of the ``bot.send_*`` family - 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. - - Returns: - :class:`telegram.Message`: On success, if edited message is sent by the bot, the - edited Message is returned, otherwise :obj:`True` is returned. - """ - return await self.get_bot().stop_message_live_location( - chat_id=self.chat_id, - message_id=self.message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - inline_message_id=None, - ) - - async def set_game_score( - self, - user_id: Union[int, str], - score: int, - force: Optional[bool] = None, - disable_edit_message: Optional[bool] = None, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Union["Message", bool]: - """Shortcut for:: - - await bot.set_game_score( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs - ) - - For the documentation of the arguments, please see :meth:`telegram.Bot.set_game_score`. - - Note: - You can only edit messages that the bot sent itself (i.e. of the ``bot.send_*`` family - 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. - - Returns: - :class:`telegram.Message`: On success, if edited message is sent by the bot, the - edited Message is returned, otherwise :obj:`True` is returned. - """ - return await self.get_bot().set_game_score( - chat_id=self.chat_id, - message_id=self.message_id, - user_id=user_id, - score=score, - force=force, - disable_edit_message=disable_edit_message, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - inline_message_id=None, - ) - - async def get_game_high_scores( - self, - user_id: Union[int, str], - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Tuple["GameHighScore", ...]: - """Shortcut for:: - - await bot.get_game_high_scores( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs - ) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.get_game_high_scores`. - - Note: - You can only edit messages that the bot sent itself (i.e. of the ``bot.send_*`` family - 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. - - Returns: - Tuple[:class:`telegram.GameHighScore`] - """ - return await self.get_bot().get_game_high_scores( - chat_id=self.chat_id, - message_id=self.message_id, - user_id=user_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - inline_message_id=None, - ) - - async def delete( - self, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> bool: - """Shortcut for:: - - await bot.delete_message( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs - ) - - For the documentation of the arguments, please see :meth:`telegram.Bot.delete_message`. - - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - - """ - return await self.get_bot().delete_message( - chat_id=self.chat_id, - message_id=self.message_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def stop_poll( - self, - reply_markup: Optional[InlineKeyboardMarkup] = None, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> Poll: - """Shortcut for:: - - await bot.stop_poll( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs - ) - - For the documentation of the arguments, please see :meth:`telegram.Bot.stop_poll`. - - Returns: - :class:`telegram.Poll`: On success, the stopped Poll with the final results is - returned. - - """ - return await self.get_bot().stop_poll( - chat_id=self.chat_id, - message_id=self.message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def pin( - self, - disable_notification: ODVInput[bool] = DEFAULT_NONE, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> bool: - """Shortcut for:: - - await bot.pin_chat_message( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs - ) - - For the documentation of the arguments, please see :meth:`telegram.Bot.pin_chat_message`. - - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - - """ - return await self.get_bot().pin_chat_message( - chat_id=self.chat_id, - message_id=self.message_id, - disable_notification=disable_notification, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def unpin( - self, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> bool: - """Shortcut for:: - - await bot.unpin_chat_message( - chat_id=message.chat_id, message_id=message.message_id, *args, **kwargs - ) - - For the documentation of the arguments, please see :meth:`telegram.Bot.unpin_chat_message`. - - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - - """ - return await self.get_bot().unpin_chat_message( - chat_id=self.chat_id, - message_id=self.message_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def edit_forum_topic( - self, - name: Optional[str] = None, - icon_custom_emoji_id: Optional[str] = None, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> bool: - """Shortcut for:: - - await bot.edit_forum_topic( - chat_id=message.chat_id, message_thread_id=message.message_thread_id, *args, - **kwargs - ) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.edit_forum_topic`. - - .. versionadded:: 20.0 - - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - """ - return await self.get_bot().edit_forum_topic( - chat_id=self.chat_id, - message_thread_id=self.message_thread_id, - name=name, - icon_custom_emoji_id=icon_custom_emoji_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def close_forum_topic( - self, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> bool: - """Shortcut for:: - - await bot.close_forum_topic( - chat_id=message.chat_id, message_thread_id=message.message_thread_id, *args, - **kwargs - ) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.close_forum_topic`. - - .. versionadded:: 20.0 - - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - """ - return await self.get_bot().close_forum_topic( - chat_id=self.chat_id, - message_thread_id=self.message_thread_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def reopen_forum_topic( - self, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> bool: - """Shortcut for:: - - await bot.reopen_forum_topic( - chat_id=message.chat_id, message_thread_id=message.message_thread_id, *args, - **kwargs - ) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.reopen_forum_topic`. - - .. versionadded:: 20.0 - - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - """ - return await self.get_bot().reopen_forum_topic( - chat_id=self.chat_id, - message_thread_id=self.message_thread_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def delete_forum_topic( - self, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> bool: - """Shortcut for:: - - await bot.delete_forum_topic( - chat_id=message.chat_id, message_thread_id=message.message_thread_id, *args, - **kwargs - ) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.delete_forum_topic`. - - .. versionadded:: 20.0 - - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - """ - return await self.get_bot().delete_forum_topic( - chat_id=self.chat_id, - message_thread_id=self.message_thread_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def unpin_all_forum_topic_messages( - self, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> bool: - """Shortcut for:: - - await bot.unpin_all_forum_topic_messages( - chat_id=message.chat_id, message_thread_id=message.message_thread_id, *args, - **kwargs - ) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.unpin_all_forum_topic_messages`. - - .. versionadded:: 20.0 - - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - """ - return await self.get_bot().unpin_all_forum_topic_messages( - chat_id=self.chat_id, - message_thread_id=self.message_thread_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - def parse_entity(self, entity: MessageEntity) -> str: - """Returns the text from a given :class:`telegram.MessageEntity`. - - Note: - This method is present because Telegram calculates the offset and length in - UTF-16 codepoint pairs, which some versions of Python don't handle automatically. - (That is, you can't just slice ``Message.text`` with the offset and length.) - - Args: - entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must - be an entity that belongs to this message. - - Returns: - :obj:`str`: The text of the given entity. - - Raises: - RuntimeError: If the message has no text. - - """ - if not self.text: - raise RuntimeError("This Message has no 'text'.") - - entity_text = self.text.encode("utf-16-le") - entity_text = entity_text[entity.offset * 2 : (entity.offset + entity.length) * 2] - return entity_text.decode("utf-16-le") - - def parse_caption_entity(self, entity: MessageEntity) -> str: - """Returns the text from a given :class:`telegram.MessageEntity`. - - Note: - This method is present because Telegram calculates the offset and length in - UTF-16 codepoint pairs, which some versions of Python don't handle automatically. - (That is, you can't just slice ``Message.caption`` with the offset and length.) - - Args: - entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must - be an entity that belongs to this message. - - Returns: - :obj:`str`: The text of the given entity. - - Raises: - RuntimeError: If the message has no caption. - - """ - if not self.caption: - raise RuntimeError("This Message has no 'caption'.") - - entity_text = self.caption.encode("utf-16-le") - entity_text = entity_text[entity.offset * 2 : (entity.offset + entity.length) * 2] - return entity_text.decode("utf-16-le") - - def parse_entities(self, types: Optional[List[str]] = None) -> Dict[MessageEntity, str]: - """ - Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. - It contains entities from this message filtered by their - :attr:`telegram.MessageEntity.type` attribute as the key, and the text that each entity - belongs to as the value of the :obj:`dict`. - - Note: - This method should always be used instead of the :attr:`entities` attribute, since it - calculates the correct substring from the message text based on UTF-16 codepoints. - See :attr:`parse_entity` for more info. - - Args: - types (List[:obj:`str`], optional): List of :class:`telegram.MessageEntity` types as - strings. If the ``type`` attribute of an entity is contained in this list, it will - be returned. Defaults to a list of all types. All types can be found as constants - in :class:`telegram.MessageEntity`. - - Returns: - Dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to - the text that belongs to them, calculated based on UTF-16 codepoints. - - """ - if types is None: - types = MessageEntity.ALL_TYPES - - return { - entity: self.parse_entity(entity) for entity in self.entities if entity.type in types - } - - def parse_caption_entities( - self, types: Optional[List[str]] = None - ) -> Dict[MessageEntity, str]: - """ - Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. - It contains entities from this message's caption filtered by their - :attr:`telegram.MessageEntity.type` attribute as the key, and the text that each entity - belongs to as the value of the :obj:`dict`. - - Note: - This method should always be used instead of the :attr:`caption_entities` attribute, - since it calculates the correct substring from the message text based on UTF-16 - codepoints. See :attr:`parse_entity` for more info. - - Args: - types (List[:obj:`str`], optional): List of :class:`telegram.MessageEntity` types as - strings. If the ``type`` attribute of an entity is contained in this list, it will - be returned. Defaults to a list of all types. All types can be found as constants - in :class:`telegram.MessageEntity`. - - Returns: - Dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to - the text that belongs to them, calculated based on UTF-16 codepoints. - - """ - if types is None: - types = MessageEntity.ALL_TYPES - - return { - entity: self.parse_caption_entity(entity) - for entity in self.caption_entities - if entity.type in types - } - - @staticmethod - def _parse_html( - message_text: Optional[str], - entities: Dict[MessageEntity, str], - urled: bool = False, - offset: int = 0, - ) -> Optional[str]: - if message_text is None: - return None - - message_text = message_text.encode("utf-16-le") # type: ignore - - html_text = "" - last_offset = 0 - - sorted_entities = sorted(entities.items(), key=lambda item: item[0].offset) - parsed_entities = [] - - for entity, text in sorted_entities: - if entity not in parsed_entities: - nested_entities = { - e: t - for (e, t) in sorted_entities - if e.offset >= entity.offset - and e.offset + e.length <= entity.offset + entity.length - and e != entity - } - parsed_entities.extend(list(nested_entities.keys())) - - orig_text = text - escaped_text = escape(text) - - if nested_entities: - escaped_text = Message._parse_html( - orig_text, nested_entities, urled=urled, offset=entity.offset - ) - - if entity.type == MessageEntity.TEXT_LINK: - insert = f'{escaped_text}' - elif entity.type == MessageEntity.TEXT_MENTION and entity.user: - insert = f'{escaped_text}' - elif entity.type == MessageEntity.URL and urled: - insert = f'{escaped_text}' - elif entity.type == MessageEntity.BOLD: - insert = f"{escaped_text}" - elif entity.type == MessageEntity.ITALIC: - insert = f"{escaped_text}" - elif entity.type == MessageEntity.CODE: - insert = f"{escaped_text}" - elif entity.type == MessageEntity.PRE: - if entity.language: - insert = ( - f'
{escaped_text}
' - ) - else: - insert = f"
{escaped_text}
" - elif entity.type == MessageEntity.UNDERLINE: - insert = f"{escaped_text}" - elif entity.type == MessageEntity.STRIKETHROUGH: - insert = f"{escaped_text}" - elif entity.type == MessageEntity.SPOILER: - insert = f'{escaped_text}' - elif entity.type == MessageEntity.CUSTOM_EMOJI: - insert = ( - f'{escaped_text}' - ) - else: - insert = escaped_text - - if offset == 0: - html_text += ( - escape( - message_text[ # type: ignore - last_offset * 2 : (entity.offset - offset) * 2 - ].decode("utf-16-le") - ) - + insert - ) - else: - html_text += ( - message_text[ # type: ignore - last_offset * 2 : (entity.offset - offset) * 2 - ].decode("utf-16-le") - + insert - ) - - last_offset = entity.offset - offset + entity.length - - if offset == 0: - html_text += escape( - message_text[last_offset * 2 :].decode("utf-16-le") # type: ignore - ) - else: - html_text += message_text[last_offset * 2 :].decode("utf-16-le") # type: ignore - - return html_text - - @property - def text_html(self) -> str: - """Creates an HTML-formatted string from the markup entities found in the message. - - Use this if you want to retrieve the message text with the entities formatted as HTML in - the same way the original message was formatted. - - .. versionchanged:: 13.10 - Spoiler entities are now formatted as HTML. - - .. versionchanged:: 20.3 - Custom emoji entities are now supported. - - Returns: - :obj:`str`: Message text with entities formatted as HTML. - - """ - return self._parse_html(self.text, self.parse_entities(), urled=False) - - @property - def text_html_urled(self) -> str: - """Creates an HTML-formatted string from the markup entities found in the message. - - Use this if you want to retrieve the message text with the entities formatted as HTML. - This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. - - .. versionchanged:: 13.10 - Spoiler entities are now formatted as HTML. - - .. versionchanged:: 20.3 - Custom emoji entities are now supported. - - Returns: - :obj:`str`: Message text with entities formatted as HTML. - - """ - return self._parse_html(self.text, self.parse_entities(), urled=True) - - @property - def caption_html(self) -> str: - """Creates an HTML-formatted string from the markup entities found in the message's - caption. - - Use this if you want to retrieve the message caption with the caption entities formatted as - HTML in the same way the original message was formatted. - - .. versionchanged:: 13.10 - Spoiler entities are now formatted as HTML. - - .. versionchanged:: 20.3 - Custom emoji entities are now supported. - - Returns: - :obj:`str`: Message caption with caption entities formatted as HTML. - """ - return self._parse_html(self.caption, self.parse_caption_entities(), urled=False) - - @property - def caption_html_urled(self) -> str: - """Creates an HTML-formatted string from the markup entities found in the message's - caption. - - Use this if you want to retrieve the message caption with the caption entities formatted as - HTML. This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. - - .. versionchanged:: 13.10 - Spoiler entities are now formatted as HTML. - - .. versionchanged:: 20.3 - Custom emoji entities are now supported. - - Returns: - :obj:`str`: Message caption with caption entities formatted as HTML. - """ - return self._parse_html(self.caption, self.parse_caption_entities(), urled=True) - - @staticmethod - def _parse_markdown( - message_text: Optional[str], - entities: Dict[MessageEntity, str], - urled: bool = False, - version: int = 1, - offset: int = 0, - ) -> Optional[str]: - version = int(version) - - if message_text is None: - return None - - message_text = message_text.encode("utf-16-le") # type: ignore - - markdown_text = "" - last_offset = 0 - - sorted_entities = sorted(entities.items(), key=lambda item: item[0].offset) - parsed_entities = [] - - for entity, text in sorted_entities: - if entity not in parsed_entities: - nested_entities = { - e: t - for (e, t) in sorted_entities - if e.offset >= entity.offset - and e.offset + e.length <= entity.offset + entity.length - and e != entity - } - parsed_entities.extend(list(nested_entities.keys())) - - escaped_text = escape_markdown(text, version=version) - - if nested_entities: - if version < 2: - raise ValueError( - "Nested entities are not supported for Markdown version 1" - ) - - escaped_text = Message._parse_markdown( - text, - nested_entities, - urled=urled, - offset=entity.offset, - version=version, - ) - - if entity.type == MessageEntity.TEXT_LINK: - if version == 1: - url = entity.url - else: - # Links need special escaping. Also can't have entities nested within - url = escape_markdown( - entity.url, version=version, entity_type=MessageEntity.TEXT_LINK - ) - insert = f"[{escaped_text}]({url})" - elif entity.type == MessageEntity.TEXT_MENTION and entity.user: - insert = f"[{escaped_text}](tg://user?id={entity.user.id})" - elif entity.type == MessageEntity.URL and urled: - link = text if version == 1 else escaped_text - insert = f"[{link}]({text})" - elif entity.type == MessageEntity.BOLD: - insert = f"*{escaped_text}*" - elif entity.type == MessageEntity.ITALIC: - insert = f"_{escaped_text}_" - elif entity.type == MessageEntity.CODE: - # Monospace needs special escaping. Also can't have entities nested within - insert = f"`{escape_markdown(text, version, MessageEntity.CODE)}`" - - elif entity.type == MessageEntity.PRE: - # Monospace needs special escaping. Also can't have entities nested within - code = escape_markdown(text, version=version, entity_type=MessageEntity.PRE) - if entity.language: - prefix = f"```{entity.language}\n" - elif code.startswith("\\"): - prefix = "```" - else: - prefix = "```\n" - insert = f"{prefix}{code}```" - elif entity.type == MessageEntity.UNDERLINE: - if version == 1: - raise ValueError( - "Underline entities are not supported for Markdown version 1" - ) - insert = f"__{escaped_text}__" - elif entity.type == MessageEntity.STRIKETHROUGH: - if version == 1: - raise ValueError( - "Strikethrough entities are not supported for Markdown version 1" - ) - insert = f"~{escaped_text}~" - elif entity.type == MessageEntity.SPOILER: - if version == 1: - raise ValueError( - "Spoiler entities are not supported for Markdown version 1" - ) - insert = f"||{escaped_text}||" - elif entity.type == MessageEntity.CUSTOM_EMOJI: - if version == 1: - # this ensures compatibility to previous PTB versions - insert = escaped_text - warn( - "Custom emoji entities are not supported for Markdown version 1. " - "Future version of PTB will raise a ValueError instead of falling " - "back to the alternative standard emoji.", - stacklevel=3, - category=PTBDeprecationWarning, - ) - else: - # This should never be needed because ids are numeric but the documentation - # specifically mentions it so here we are - custom_emoji_id = escape_markdown( - entity.custom_emoji_id, - version=version, - entity_type=MessageEntity.CUSTOM_EMOJI, - ) - insert = f"![{escaped_text}](tg://emoji?id={custom_emoji_id})" - else: - insert = escaped_text - - if offset == 0: - markdown_text += ( - escape_markdown( - message_text[ # type: ignore - last_offset * 2 : (entity.offset - offset) * 2 - ].decode("utf-16-le"), - version=version, - ) - + insert - ) - else: - markdown_text += ( - message_text[ # type: ignore - last_offset * 2 : (entity.offset - offset) * 2 - ].decode("utf-16-le") - + insert - ) - - last_offset = entity.offset - offset + entity.length - - if offset == 0: - markdown_text += escape_markdown( - message_text[last_offset * 2 :].decode("utf-16-le"), # type: ignore - version=version, - ) - else: - markdown_text += message_text[last_offset * 2 :].decode("utf-16-le") # type: ignore - - return markdown_text - - @property - def text_markdown(self) -> str: - """Creates an Markdown-formatted string from the markup entities found in the message - using :class:`telegram.constants.ParseMode.MARKDOWN`. - - Use this if you want to retrieve the message text with the entities formatted as Markdown - in the same way the original message was formatted. - - Note: - * :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by - Telegram for backward compatibility. You should use - :meth:`text_markdown_v2` instead. - - * |custom_emoji_formatting_note| - - .. deprecated:: 20.3 - |custom_emoji_md1_deprecation| - - Returns: - :obj:`str`: Message text with entities formatted as Markdown. - - Raises: - :exc:`ValueError`: If the message contains underline, strikethrough, spoiler or nested - entities. - - """ - return self._parse_markdown(self.text, self.parse_entities(), urled=False) - - @property - def text_markdown_v2(self) -> str: - """Creates an Markdown-formatted string from the markup entities found in the message - using :class:`telegram.constants.ParseMode.MARKDOWN_V2`. - - Use this if you want to retrieve the message text with the entities formatted as Markdown - in the same way the original message was formatted. - - .. versionchanged:: 13.10 - Spoiler entities are now formatted as Markdown V2. - - .. versionchanged:: 20.3 - Custom emoji entities are now supported. - - Returns: - :obj:`str`: Message text with entities formatted as Markdown. - """ - return self._parse_markdown(self.text, self.parse_entities(), urled=False, version=2) - - @property - def text_markdown_urled(self) -> str: - """Creates an Markdown-formatted string from the markup entities found in the message - using :class:`telegram.constants.ParseMode.MARKDOWN`. - - Use this if you want to retrieve the message text with the entities formatted as Markdown. - This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. - - Note: - * :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by - Telegram for backward compatibility. You should use :meth:`text_markdown_v2_urled` - instead. - - * |custom_emoji_formatting_note| - - .. deprecated:: 20.3 - |custom_emoji_md1_deprecation| - - Returns: - :obj:`str`: Message text with entities formatted as Markdown. - - Raises: - :exc:`ValueError`: If the message contains underline, strikethrough, spoiler or nested - entities. - - """ - return self._parse_markdown(self.text, self.parse_entities(), urled=True) - - @property - def text_markdown_v2_urled(self) -> str: - """Creates an Markdown-formatted string from the markup entities found in the message - using :class:`telegram.constants.ParseMode.MARKDOWN_V2`. - - Use this if you want to retrieve the message text with the entities formatted as Markdown. - This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. - - .. versionchanged:: 13.10 - Spoiler entities are now formatted as Markdown V2. - - .. versionchanged:: 20.3 - Custom emoji entities are now supported. - - Returns: - :obj:`str`: Message text with entities formatted as Markdown. - """ - return self._parse_markdown(self.text, self.parse_entities(), urled=True, version=2) - - @property - def caption_markdown(self) -> str: - """Creates an Markdown-formatted string from the markup entities found in the message's - caption using :class:`telegram.constants.ParseMode.MARKDOWN`. - - Use this if you want to retrieve the message caption with the caption entities formatted as - Markdown in the same way the original message was formatted. - - Note: - * :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by - Telegram for backward compatibility. You should use :meth:`caption_markdown_v2` - instead. - - * |custom_emoji_formatting_note| - - .. deprecated:: 20.3 - |custom_emoji_md1_deprecation| - - Returns: - :obj:`str`: Message caption with caption entities formatted as Markdown. - - Raises: - :exc:`ValueError`: If the message contains underline, strikethrough, spoiler or nested - entities. - - """ - return self._parse_markdown(self.caption, self.parse_caption_entities(), urled=False) - - @property - def caption_markdown_v2(self) -> str: - """Creates an Markdown-formatted string from the markup entities found in the message's - caption using :class:`telegram.constants.ParseMode.MARKDOWN_V2`. - - Use this if you want to retrieve the message caption with the caption entities formatted as - Markdown in the same way the original message was formatted. - - .. versionchanged:: 13.10 - Spoiler entities are now formatted as Markdown V2. - - .. versionchanged:: 20.3 - Custom emoji entities are now supported. - - Returns: - :obj:`str`: Message caption with caption entities formatted as Markdown. - """ - return self._parse_markdown( - self.caption, self.parse_caption_entities(), urled=False, version=2 - ) - - @property - def caption_markdown_urled(self) -> str: - """Creates an Markdown-formatted string from the markup entities found in the message's - caption using :class:`telegram.constants.ParseMode.MARKDOWN`. - - Use this if you want to retrieve the message caption with the caption entities formatted as - Markdown. This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. - - Note: - * :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by - Telegram for backward compatibility. You should use - :meth:`caption_markdown_v2_urled` instead. - - * |custom_emoji_formatting_note| - - .. deprecated:: 20.3 - |custom_emoji_md1_deprecation| - - Returns: - :obj:`str`: Message caption with caption entities formatted as Markdown. - - Raises: - :exc:`ValueError`: If the message contains underline, strikethrough, spoiler or nested - entities. - - """ - return self._parse_markdown(self.caption, self.parse_caption_entities(), urled=True) - - @property - def caption_markdown_v2_urled(self) -> str: - """Creates an Markdown-formatted string from the markup entities found in the message's - caption using :class:`telegram.constants.ParseMode.MARKDOWN_V2`. - - Use this if you want to retrieve the message caption with the caption entities formatted as - Markdown. This also formats :attr:`telegram.MessageEntity.URL` as a hyperlink. - - .. versionchanged:: 13.10 - Spoiler entities are now formatted as Markdown V2. - - .. versionchanged:: 20.3 - Custom emoji entities are now supported. - - Returns: - :obj:`str`: Message caption with caption entities formatted as Markdown. - """ - return self._parse_markdown( - self.caption, self.parse_caption_entities(), urled=True, version=2 - ) diff --git a/telegram/_messageentity.py b/telegram/_messageentity.py deleted file mode 100644 index e95bf9d9656..00000000000 --- a/telegram/_messageentity.py +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env python -# -# A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 -# 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 MessageEntity.""" - -from typing import TYPE_CHECKING, ClassVar, List, Optional - -from telegram import constants -from telegram._telegramobject import TelegramObject -from telegram._user import User -from telegram._utils import enum -from telegram._utils.types import JSONDict - -if TYPE_CHECKING: - from telegram import Bot - - -class MessageEntity(TelegramObject): - """ - This object represents one special entity in a text message. For example, hashtags, - usernames, URLs, etc. - - Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`type`, :attr:`offset` and :attr:`length` are equal. - - Args: - type (:obj:`str`): Type of the entity. Can be :attr:`MENTION` (@username), - :attr:`HASHTAG`, :attr:`BOT_COMMAND`, - :attr:`URL`, :attr:`EMAIL`, :attr:`PHONE_NUMBER`, :attr:`BOLD` (bold text), - :attr:`ITALIC` (italic text), :attr:`STRIKETHROUGH`, :attr:`SPOILER` (spoiler message), - :attr:`CODE` (monowidth string), :attr:`PRE` (monowidth block), :attr:`TEXT_LINK` (for - clickable text URLs), :attr:`TEXT_MENTION` (for users without usernames), - :attr:`CUSTOM_EMOJI` (for inline custom emoji stickers). - - .. versionadded:: 20.0 - Added inline custom emoji - offset (:obj:`int`): Offset in UTF-16 code units to the start of the entity. - length (:obj:`int`): Length of the entity in UTF-16 code units. - url (:obj:`str`, optional): For :attr:`TEXT_LINK` only, url that will be opened after - user taps on the text. - user (:class:`telegram.User`, optional): For :attr:`TEXT_MENTION` only, the mentioned - user. - language (:obj:`str`, optional): For :attr:`PRE` only, the programming language of - the entity text. - custom_emoji_id (:obj:`str`, optional): For :attr:`CUSTOM_EMOJI` only, unique identifier - of the custom emoji. Use :meth:`telegram.Bot.get_custom_emoji_stickers` to get full - information about the sticker. - - .. versionadded:: 20.0 - Attributes: - type (:obj:`str`): Type of the entity. Can be :attr:`MENTION` (@username), - :attr:`HASHTAG`, :attr:`BOT_COMMAND`, - :attr:`URL`, :attr:`EMAIL`, :attr:`PHONE_NUMBER`, :attr:`BOLD` (bold text), - :attr:`ITALIC` (italic text), :attr:`STRIKETHROUGH`, :attr:`SPOILER` (spoiler message), - :attr:`CODE` (monowidth string), :attr:`PRE` (monowidth block), :attr:`TEXT_LINK` (for - clickable text URLs), :attr:`TEXT_MENTION` (for users without usernames), - :attr:`CUSTOM_EMOJI` (for inline custom emoji stickers). - - .. versionadded:: 20.0 - Added inline custom emoji - offset (:obj:`int`): Offset in UTF-16 code units to the start of the entity. - length (:obj:`int`): Length of the entity in UTF-16 code units. - url (:obj:`str`): Optional. For :attr:`TEXT_LINK` only, url that will be opened after - user taps on the text. - user (:class:`telegram.User`): Optional. For :attr:`TEXT_MENTION` only, the mentioned - user. - language (:obj:`str`): Optional. For :attr:`PRE` only, the programming language of - the entity text. - custom_emoji_id (:obj:`str`): Optional. For :attr:`CUSTOM_EMOJI` only, unique identifier - of the custom emoji. Use :meth:`telegram.Bot.get_custom_emoji_stickers` to get full - information about the sticker. - - .. versionadded:: 20.0 - - """ - - __slots__ = ("length", "url", "user", "type", "language", "offset", "custom_emoji_id") - - def __init__( - self, - type: str, # pylint: disable=redefined-builtin - offset: int, - length: int, - url: Optional[str] = None, - user: Optional[User] = None, - language: Optional[str] = None, - custom_emoji_id: Optional[str] = None, - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - # Required - self.type: str = enum.get_member(constants.MessageEntityType, type, type) - self.offset: int = offset - self.length: int = length - # Optionals - self.url: Optional[str] = url - self.user: Optional[User] = user - self.language: Optional[str] = language - self.custom_emoji_id: Optional[str] = custom_emoji_id - - self._id_attrs = (self.type, self.offset, self.length) - - self._freeze() - - @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["MessageEntity"]: - """See :meth:`telegram.TelegramObject.de_json`.""" - 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) - - MENTION: ClassVar[str] = constants.MessageEntityType.MENTION - """:const:`telegram.constants.MessageEntityType.MENTION`""" - HASHTAG: ClassVar[str] = constants.MessageEntityType.HASHTAG - """:const:`telegram.constants.MessageEntityType.HASHTAG`""" - CASHTAG: ClassVar[str] = constants.MessageEntityType.CASHTAG - """:const:`telegram.constants.MessageEntityType.CASHTAG`""" - PHONE_NUMBER: ClassVar[str] = constants.MessageEntityType.PHONE_NUMBER - """:const:`telegram.constants.MessageEntityType.PHONE_NUMBER`""" - BOT_COMMAND: ClassVar[str] = constants.MessageEntityType.BOT_COMMAND - """:const:`telegram.constants.MessageEntityType.BOT_COMMAND`""" - URL: ClassVar[str] = constants.MessageEntityType.URL - """:const:`telegram.constants.MessageEntityType.URL`""" - EMAIL: ClassVar[str] = constants.MessageEntityType.EMAIL - """:const:`telegram.constants.MessageEntityType.EMAIL`""" - BOLD: ClassVar[str] = constants.MessageEntityType.BOLD - """:const:`telegram.constants.MessageEntityType.BOLD`""" - ITALIC: ClassVar[str] = constants.MessageEntityType.ITALIC - """:const:`telegram.constants.MessageEntityType.ITALIC`""" - CODE: ClassVar[str] = constants.MessageEntityType.CODE - """:const:`telegram.constants.MessageEntityType.CODE`""" - PRE: ClassVar[str] = constants.MessageEntityType.PRE - """:const:`telegram.constants.MessageEntityType.PRE`""" - TEXT_LINK: ClassVar[str] = constants.MessageEntityType.TEXT_LINK - """:const:`telegram.constants.MessageEntityType.TEXT_LINK`""" - TEXT_MENTION: ClassVar[str] = constants.MessageEntityType.TEXT_MENTION - """:const:`telegram.constants.MessageEntityType.TEXT_MENTION`""" - UNDERLINE: ClassVar[str] = constants.MessageEntityType.UNDERLINE - """:const:`telegram.constants.MessageEntityType.UNDERLINE`""" - STRIKETHROUGH: ClassVar[str] = constants.MessageEntityType.STRIKETHROUGH - """:const:`telegram.constants.MessageEntityType.STRIKETHROUGH`""" - SPOILER: ClassVar[str] = constants.MessageEntityType.SPOILER - """:const:`telegram.constants.MessageEntityType.SPOILER` - - .. versionadded:: 13.10 - """ - CUSTOM_EMOJI: ClassVar[str] = constants.MessageEntityType.CUSTOM_EMOJI - """:const:`telegram.constants.MessageEntityType.CUSTOM_EMOJI` - - .. versionadded:: 20.0 - """ - ALL_TYPES: ClassVar[List[str]] = list(constants.MessageEntityType) - """List[:obj:`str`]: A list of all available message entity types.""" diff --git a/telegram/_poll.py b/telegram/_poll.py deleted file mode 100644 index 1c8018a64f2..00000000000 --- a/telegram/_poll.py +++ /dev/null @@ -1,406 +0,0 @@ -#!/usr/bin/env python -# -# A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 -# 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 Poll.""" -import datetime -from typing import TYPE_CHECKING, ClassVar, Dict, List, Optional, Sequence, Tuple - -from telegram import constants -from telegram._messageentity import MessageEntity -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 PollOption(TelegramObject): - """ - This object contains information about one answer option in a poll. - - Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`text` and :attr:`voter_count` are equal. - - Args: - text (:obj:`str`): Option text, - :tg-const:`telegram.PollOption.MIN_LENGTH`-:tg-const:`telegram.PollOption.MAX_LENGTH` - characters. - voter_count (:obj:`int`): Number of users that voted for this option. - - Attributes: - text (:obj:`str`): Option text, - :tg-const:`telegram.PollOption.MIN_LENGTH`-:tg-const:`telegram.PollOption.MAX_LENGTH` - characters. - voter_count (:obj:`int`): Number of users that voted for this option. - - """ - - __slots__ = ("voter_count", "text") - - def __init__(self, text: str, voter_count: int, *, api_kwargs: Optional[JSONDict] = None): - super().__init__(api_kwargs=api_kwargs) - self.text: str = text - self.voter_count: int = voter_count - - self._id_attrs = (self.text, self.voter_count) - - self._freeze() - - MIN_LENGTH: ClassVar[int] = constants.PollLimit.MIN_OPTION_LENGTH - """:const:`telegram.constants.PollLimit.MIN_OPTION_LENGTH` - - .. versionadded:: 20.0 - """ - MAX_LENGTH: ClassVar[int] = constants.PollLimit.MAX_OPTION_LENGTH - """:const:`telegram.constants.PollLimit.MAX_OPTION_LENGTH` - - .. versionadded:: 20.0 - """ - - -class PollAnswer(TelegramObject): - """ - This object represents an answer of a user in a non-anonymous poll. - - Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`poll_id`, :attr:`user` and :attr:`option_ids` are equal. - - Args: - poll_id (:obj:`str`): Unique poll identifier. - user (:class:`telegram.User`): The user, who changed the answer to the poll. - option_ids (Sequence[:obj:`int`]): 0-based identifiers of answer options, chosen by the - user. May be empty if the user retracted their vote. - - .. versionchanged:: 20.0 - |sequenceclassargs| - - Attributes: - poll_id (:obj:`str`): Unique poll identifier. - user (:class:`telegram.User`): The user, who changed the answer to the poll. - option_ids (Tuple[:obj:`int`]): Identifiers of answer options, chosen by the user. May be - empty if the user retracted their vote. - - .. versionchanged:: 20.0 - |tupleclassattrs| - - """ - - __slots__ = ("option_ids", "user", "poll_id") - - def __init__( - self, - poll_id: str, - user: User, - option_ids: Sequence[int], - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - self.poll_id: str = poll_id - self.user: User = user - self.option_ids: Tuple[int, ...] = parse_sequence_arg(option_ids) - - self._id_attrs = (self.poll_id, self.user, tuple(self.option_ids)) - - self._freeze() - - @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["PollAnswer"]: - """See :meth:`telegram.TelegramObject.de_json`.""" - 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) - - -class Poll(TelegramObject): - """ - This object contains information about a poll. - - Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`id` is equal. - - Examples: - :any:`Poll Bot ` - - Args: - id (:obj:`str`): Unique poll identifier. - question (:obj:`str`): Poll question, :tg-const:`telegram.Poll.MIN_QUESTION_LENGTH`- - :tg-const:`telegram.Poll.MAX_QUESTION_LENGTH` characters. - options (Sequence[:class:`~telegram.PollOption`]): List of poll options. - - .. versionchanged:: 20.0 - |sequenceclassargs| - is_closed (:obj:`bool`): :obj:`True`, if the poll is closed. - is_anonymous (:obj:`bool`): :obj:`True`, if the poll is anonymous. - type (:obj:`str`): Poll type, currently can be :attr:`REGULAR` or :attr:`QUIZ`. - allows_multiple_answers (:obj:`bool`): :obj:`True`, if the poll allows multiple answers. - correct_option_id (:obj:`int`, optional): A zero based identifier of the correct answer - option. Available only for closed polls in the quiz mode, which were sent - (not forwarded), by the bot or to a private chat with the bot. - explanation (:obj:`str`, optional): Text that is shown when a user chooses an incorrect - answer or taps on the lamp icon in a quiz-style poll, - 0-:tg-const:`telegram.Poll.MAX_EXPLANATION_LENGTH` characters. - explanation_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special - entities like usernames, URLs, bot commands, etc. that appear in the - :attr:`explanation`. This list is empty if the message does not contain explanation - entities. - - .. versionchanged:: 20.0 - - * This attribute is now always a (possibly empty) list and never :obj:`None`. - * |sequenceclassargs| - open_period (:obj:`int`, optional): Amount of time in seconds the poll will be active - after creation. - close_date (:obj:`datetime.datetime`, optional): Point in time (Unix timestamp) when the - poll will be automatically closed. Converted to :obj:`datetime.datetime`. - - .. versionchanged:: 20.3 - |datetime_localization| - - Attributes: - id (:obj:`str`): Unique poll identifier. - question (:obj:`str`): Poll question, :tg-const:`telegram.Poll.MIN_QUESTION_LENGTH`- - :tg-const:`telegram.Poll.MAX_QUESTION_LENGTH` characters. - options (Tuple[:class:`~telegram.PollOption`]): List of poll options. - - .. versionchanged:: 20.0 - |tupleclassattrs| - total_voter_count (:obj:`int`): Total number of users that voted in the poll. - is_closed (:obj:`bool`): :obj:`True`, if the poll is closed. - is_anonymous (:obj:`bool`): :obj:`True`, if the poll is anonymous. - type (:obj:`str`): Poll type, currently can be :attr:`REGULAR` or :attr:`QUIZ`. - allows_multiple_answers (:obj:`bool`): :obj:`True`, if the poll allows multiple answers. - correct_option_id (:obj:`int`): Optional. A zero based identifier of the correct answer - option. Available only for closed polls in the quiz mode, which were sent - (not forwarded), by the bot or to a private chat with the bot. - explanation (:obj:`str`): Optional. Text that is shown when a user chooses an incorrect - answer or taps on the lamp icon in a quiz-style poll, - 0-:tg-const:`telegram.Poll.MAX_EXPLANATION_LENGTH` characters. - explanation_entities (Tuple[:class:`telegram.MessageEntity`]): Special entities - like usernames, URLs, bot commands, etc. that appear in the :attr:`explanation`. - This list is empty if the message does not contain explanation entities. - - .. versionchanged:: 20.0 - |tupleclassattrs| - - .. versionchanged:: 20.0 - This attribute is now always a (possibly empty) list and never :obj:`None`. - open_period (:obj:`int`): Optional. Amount of time in seconds the poll will be active - after creation. - close_date (:obj:`datetime.datetime`): Optional. Point in time when the poll will be - automatically closed. - - .. versionchanged:: 20.3 - |datetime_localization| - - """ - - __slots__ = ( - "total_voter_count", - "allows_multiple_answers", - "open_period", - "options", - "type", - "explanation_entities", - "is_anonymous", - "close_date", - "is_closed", - "id", - "explanation", - "question", - "correct_option_id", - ) - - def __init__( - self, - id: str, # pylint: disable=redefined-builtin - question: str, - options: Sequence[PollOption], - total_voter_count: int, - is_closed: bool, - is_anonymous: bool, - type: str, # pylint: disable=redefined-builtin - allows_multiple_answers: bool, - correct_option_id: Optional[int] = None, - explanation: Optional[str] = None, - explanation_entities: Optional[Sequence[MessageEntity]] = None, - open_period: Optional[int] = None, - close_date: Optional[datetime.datetime] = None, - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - self.id: str = id # pylint: disable=invalid-name - self.question: str = question - self.options: Tuple[PollOption, ...] = parse_sequence_arg(options) - self.total_voter_count: int = total_voter_count - self.is_closed: bool = is_closed - self.is_anonymous: bool = is_anonymous - self.type: str = enum.get_member(constants.PollType, type, type) - self.allows_multiple_answers: bool = allows_multiple_answers - self.correct_option_id: Optional[int] = correct_option_id - self.explanation: Optional[str] = explanation - self.explanation_entities: Tuple[MessageEntity, ...] = parse_sequence_arg( - explanation_entities - ) - self.open_period: Optional[int] = open_period - self.close_date: Optional[datetime.datetime] = close_date - - self._id_attrs = (self.id,) - - self._freeze() - - @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Poll"]: - """See :meth:`telegram.TelegramObject.de_json`.""" - 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["options"] = [PollOption.de_json(option, bot) for option in data["options"]] - data["explanation_entities"] = MessageEntity.de_list(data.get("explanation_entities"), bot) - data["close_date"] = from_timestamp(data.get("close_date"), tzinfo=loc_tzinfo) - - return super().de_json(data=data, bot=bot) - - def parse_explanation_entity(self, entity: MessageEntity) -> str: - """Returns the text from a given :class:`telegram.MessageEntity`. - - Note: - This method is present because Telegram calculates the offset and length in - UTF-16 codepoint pairs, which some versions of Python don't handle automatically. - (That is, you can't just slice ``Message.text`` with the offset and length.) - - Args: - entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must - be an entity that belongs to this message. - - Returns: - :obj:`str`: The text of the given entity. - - Raises: - RuntimeError: If the poll has no explanation. - - """ - if not self.explanation: - raise RuntimeError("This Poll has no 'explanation'.") - - entity_text = self.explanation.encode("utf-16-le") - entity_text = entity_text[entity.offset * 2 : (entity.offset + entity.length) * 2] - - return entity_text.decode("utf-16-le") - - def parse_explanation_entities( - self, types: Optional[List[str]] = None - ) -> Dict[MessageEntity, str]: - """ - Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. - It contains entities from this polls explanation filtered by their ``type`` attribute as - the key, and the text that each entity belongs to as the value of the :obj:`dict`. - - Note: - This method should always be used instead of the :attr:`explanation_entities` - attribute, since it calculates the correct substring from the message text based on - UTF-16 codepoints. See :attr:`parse_explanation_entity` for more info. - - Args: - types (List[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the - ``type`` attribute of an entity is contained in this list, it will be returned. - Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. - - Returns: - Dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to - the text that belongs to them, calculated based on UTF-16 codepoints. - - """ - if types is None: - types = MessageEntity.ALL_TYPES - - return { - entity: self.parse_explanation_entity(entity) - for entity in self.explanation_entities - if entity.type in types - } - - REGULAR: ClassVar[str] = constants.PollType.REGULAR - """:const:`telegram.constants.PollType.REGULAR`""" - QUIZ: ClassVar[str] = constants.PollType.QUIZ - """:const:`telegram.constants.PollType.QUIZ`""" - MAX_EXPLANATION_LENGTH: ClassVar[int] = constants.PollLimit.MAX_EXPLANATION_LENGTH - """:const:`telegram.constants.PollLimit.MAX_EXPLANATION_LENGTH` - - .. versionadded:: 20.0 - """ - MAX_EXPLANATION_LINE_FEEDS: ClassVar[int] = constants.PollLimit.MAX_EXPLANATION_LINE_FEEDS - """:const:`telegram.constants.PollLimit.MAX_EXPLANATION_LINE_FEEDS` - - .. versionadded:: 20.0 - """ - MIN_OPEN_PERIOD: ClassVar[int] = constants.PollLimit.MIN_OPEN_PERIOD - """:const:`telegram.constants.PollLimit.MIN_OPEN_PERIOD` - - .. versionadded:: 20.0 - """ - MAX_OPEN_PERIOD: ClassVar[int] = constants.PollLimit.MAX_OPEN_PERIOD - """:const:`telegram.constants.PollLimit.MAX_OPEN_PERIOD` - - .. versionadded:: 20.0 - """ - MIN_QUESTION_LENGTH: ClassVar[int] = constants.PollLimit.MIN_QUESTION_LENGTH - """:const:`telegram.constants.PollLimit.MIN_QUESTION_LENGTH` - - .. versionadded:: 20.0 - """ - MAX_QUESTION_LENGTH: ClassVar[int] = constants.PollLimit.MAX_QUESTION_LENGTH - """:const:`telegram.constants.PollLimit.MAX_QUESTION_LENGTH` - - .. versionadded:: 20.0 - """ - MIN_OPTION_LENGTH: ClassVar[int] = constants.PollLimit.MIN_OPTION_LENGTH - """:const:`telegram.constants.PollLimit.MIN_OPTION_LENGTH` - - .. versionadded:: 20.0 - """ - MAX_OPTION_LENGTH: ClassVar[int] = constants.PollLimit.MAX_OPTION_LENGTH - """:const:`telegram.constants.PollLimit.MAX_OPTION_LENGTH` - - .. versionadded:: 20.0 - """ - MIN_OPTION_NUMBER: ClassVar[int] = constants.PollLimit.MIN_OPTION_NUMBER - """:const:`telegram.constants.PollLimit.MIN_OPTION_NUMBER` - - .. versionadded:: 20.0 - """ - MAX_OPTION_NUMBER: ClassVar[int] = constants.PollLimit.MAX_OPTION_NUMBER - """:const:`telegram.constants.PollLimit.MAX_OPTION_NUMBER` - - .. versionadded:: 20.0 - """ diff --git a/telegram/_shared.py b/telegram/_shared.py deleted file mode 100644 index 3cfc19ad93b..00000000000 --- a/telegram/_shared.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python -# -# A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 -# 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 two objects used for request chats/users service messages.""" -from typing import Optional - -from telegram._telegramobject import TelegramObject -from telegram._utils.types import JSONDict - - -class UserShared(TelegramObject): - """ - This object contains information about the user whose identifier was shared with the bot - using a :class:`telegram.KeyboardButtonRequestUser` button. - - Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`request_id` and :attr:`user_id` are equal. - - .. versionadded:: 20.1 - - Args: - request_id (:obj:`int`): Identifier of the request. - user_id (:obj:`int`): Identifier of the shared user. This number may be greater than 32 - bits and some programming languages may have difficulty/silent defects in interpreting - it. But it is smaller than 52 bits, so a signed 64-bit integer or double-precision - float type are safe for storing this identifier. - - Attributes: - request_id (:obj:`int`): Identifier of the request. - user_id (:obj:`int`): Identifier of the shared user. This number may be greater than 32 - bits and some programming languages may have difficulty/silent defects in interpreting - it. But it is smaller than 52 bits, so a signed 64-bit integer or double-precision - float type are safe for storing this identifier. - """ - - __slots__ = ("request_id", "user_id") - - def __init__( - self, - request_id: int, - user_id: int, - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - self.request_id: int = request_id - self.user_id: int = user_id - - self._id_attrs = (self.request_id, self.user_id) - - self._freeze() - - -class ChatShared(TelegramObject): - """ - This object contains information about the chat whose identifier was shared with the bot - using a :class:`telegram.KeyboardButtonRequestChat` button. - - Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`request_id` and :attr:`chat_id` are equal. - - .. versionadded:: 20.1 - - Args: - request_id (:obj:`int`): Identifier of the request. - chat_id (:obj:`int`): Identifier of the shared user. This number may be greater than 32 - bits and some programming languages may have difficulty/silent defects in interpreting - it. But it is smaller than 52 bits, so a signed 64-bit integer or double-precision - float type are safe for storing this identifier. - - Attributes: - request_id (:obj:`int`): Identifier of the request. - chat_id (:obj:`int`): Identifier of the shared user. This number may be greater than 32 - bits and some programming languages may have difficulty/silent defects in interpreting - it. But it is smaller than 52 bits, so a signed 64-bit integer or double-precision - float type are safe for storing this identifier. - """ - - __slots__ = ("request_id", "chat_id") - - def __init__( - self, - request_id: int, - chat_id: int, - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - self.request_id: int = request_id - self.chat_id: int = chat_id - - self._id_attrs = (self.request_id, self.chat_id) - - self._freeze() diff --git a/telegram/_update.py b/telegram/_update.py deleted file mode 100644 index 5d6ad07330e..00000000000 --- a/telegram/_update.py +++ /dev/null @@ -1,441 +0,0 @@ -#!/usr/bin/env python -# -# A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 -# 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 Update.""" - -from typing import TYPE_CHECKING, ClassVar, List, Optional - -from telegram import constants -from telegram._callbackquery import CallbackQuery -from telegram._chatjoinrequest import ChatJoinRequest -from telegram._chatmemberupdated import ChatMemberUpdated -from telegram._choseninlineresult import ChosenInlineResult -from telegram._inline.inlinequery import InlineQuery -from telegram._message import Message -from telegram._payment.precheckoutquery import PreCheckoutQuery -from telegram._payment.shippingquery import ShippingQuery -from telegram._poll import Poll, PollAnswer -from telegram._telegramobject import TelegramObject -from telegram._utils.types import JSONDict - -if TYPE_CHECKING: - from telegram import Bot, Chat, User - - -class Update(TelegramObject): - """This object represents an incoming update. - - Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`update_id` is equal. - - Note: - At most one of the optional parameters can be present in any given update. - - .. seealso:: :wiki:`Your First Bot ` - - Args: - update_id (:obj:`int`): The update's unique identifier. Update identifiers start from a - certain positive number and increase sequentially. This ID becomes especially handy if - you're using Webhooks, since it allows you to ignore repeated updates or to restore the - correct update sequence, should they get out of order. If there are no new updates for - at least a week, then identifier of the next update will be chosen randomly instead of - sequentially. - message (:class:`telegram.Message`, optional): New incoming message of any kind - text, - photo, sticker, etc. - edited_message (:class:`telegram.Message`, optional): New version of a message that is - known to the bot and was edited. - channel_post (:class:`telegram.Message`, optional): New incoming channel post of any kind - - text, photo, sticker, etc. - edited_channel_post (:class:`telegram.Message`, optional): New version of a channel post - that is known to the bot and was edited. - inline_query (:class:`telegram.InlineQuery`, optional): New incoming inline query. - chosen_inline_result (:class:`telegram.ChosenInlineResult`, optional): The result of an - inline query that was chosen by a user and sent to their chat partner. - callback_query (:class:`telegram.CallbackQuery`, optional): New incoming callback query. - shipping_query (:class:`telegram.ShippingQuery`, optional): New incoming shipping query. - Only for invoices with flexible price. - pre_checkout_query (:class:`telegram.PreCheckoutQuery`, optional): New incoming - pre-checkout query. Contains full information about checkout. - poll (:class:`telegram.Poll`, optional): New poll state. Bots receive only updates about - stopped polls and polls, which are sent by the bot. - poll_answer (:class:`telegram.PollAnswer`, optional): A user changed their answer - in a non-anonymous poll. Bots receive new votes only in polls that were sent - by the bot itself. - my_chat_member (:class:`telegram.ChatMemberUpdated`, optional): The bot's chat member - status was updated in a chat. For private chats, this update is received only when the - bot is blocked or unblocked by the user. - - .. versionadded:: 13.4 - chat_member (:class:`telegram.ChatMemberUpdated`, optional): A chat member's status was - updated in a chat. The bot must be an administrator in the chat and must explicitly - specify :attr:`CHAT_MEMBER` in the list of - :paramref:`telegram.ext.Application.run_polling.allowed_updates` to receive these - updates (see :meth:`telegram.Bot.get_updates`, :meth:`telegram.Bot.set_webhook`, - :meth:`telegram.ext.Application.run_polling` and - :meth:`telegram.ext.Application.run_webhook`). - - .. versionadded:: 13.4 - chat_join_request (:class:`telegram.ChatJoinRequest`, optional): A request to join the - chat has been sent. The bot must have the - :attr:`telegram.ChatPermissions.can_invite_users` administrator right in the chat to - receive these updates. - - .. versionadded:: 13.8 - Attributes: - update_id (:obj:`int`): The update's unique identifier. Update identifiers start from a - certain positive number and increase sequentially. This ID becomes especially handy if - you're using Webhooks, since it allows you to ignore repeated updates or to restore the - correct update sequence, should they get out of order. If there are no new updates for - at least a week, then identifier of the next update will be chosen randomly instead of - sequentially. - message (:class:`telegram.Message`): Optional. New incoming message of any kind - text, - photo, sticker, etc. - edited_message (:class:`telegram.Message`): Optional. New version of a message that is - known to the bot and was edited. - channel_post (:class:`telegram.Message`): Optional. New incoming channel post of any kind - - text, photo, sticker, etc. - edited_channel_post (:class:`telegram.Message`): Optional. New version of a channel post - that is known to the bot and was edited. - inline_query (:class:`telegram.InlineQuery`): Optional. New incoming inline query. - chosen_inline_result (:class:`telegram.ChosenInlineResult`): Optional. The result of an - inline query that was chosen by a user and sent to their chat partner. - callback_query (:class:`telegram.CallbackQuery`): Optional. New incoming callback query. - - Examples: - :any:`Arbitrary Callback Data Bot ` - shipping_query (:class:`telegram.ShippingQuery`): Optional. New incoming shipping query. - Only for invoices with flexible price. - pre_checkout_query (:class:`telegram.PreCheckoutQuery`): Optional. New incoming - pre-checkout query. Contains full information about checkout. - poll (:class:`telegram.Poll`): Optional. New poll state. Bots receive only updates about - stopped polls and polls, which are sent by the bot. - poll_answer (:class:`telegram.PollAnswer`): Optional. A user changed their answer - in a non-anonymous poll. Bots receive new votes only in polls that were sent - by the bot itself. - my_chat_member (:class:`telegram.ChatMemberUpdated`): Optional. The bot's chat member - status was updated in a chat. For private chats, this update is received only when the - bot is blocked or unblocked by the user. - - .. versionadded:: 13.4 - chat_member (:class:`telegram.ChatMemberUpdated`): Optional. A chat member's status was - updated in a chat. The bot must be an administrator in the chat and must explicitly - specify :attr:`CHAT_MEMBER` in the list of - :paramref:`telegram.ext.Application.run_polling.allowed_updates` to receive these - updates (see :meth:`telegram.Bot.get_updates`, :meth:`telegram.Bot.set_webhook`, - :meth:`telegram.ext.Application.run_polling` and - :meth:`telegram.ext.Application.run_webhook`). - - .. versionadded:: 13.4 - chat_join_request (:class:`telegram.ChatJoinRequest`): Optional. A request to join the - chat has been sent. The bot must have the - :attr:`telegram.ChatPermissions.can_invite_users` administrator right in the chat to - receive these updates. - - .. versionadded:: 13.8 - - """ - - __slots__ = ( - "callback_query", - "chosen_inline_result", - "pre_checkout_query", - "inline_query", - "update_id", - "message", - "shipping_query", - "poll", - "poll_answer", - "channel_post", - "edited_channel_post", - "edited_message", - "_effective_user", - "_effective_chat", - "_effective_message", - "my_chat_member", - "chat_member", - "chat_join_request", - ) - - MESSAGE: ClassVar[str] = constants.UpdateType.MESSAGE - """:const:`telegram.constants.UpdateType.MESSAGE` - - .. versionadded:: 13.5""" - EDITED_MESSAGE: ClassVar[str] = constants.UpdateType.EDITED_MESSAGE - """:const:`telegram.constants.UpdateType.EDITED_MESSAGE` - - .. versionadded:: 13.5""" - CHANNEL_POST: ClassVar[str] = constants.UpdateType.CHANNEL_POST - """:const:`telegram.constants.UpdateType.CHANNEL_POST` - - .. versionadded:: 13.5""" - EDITED_CHANNEL_POST: ClassVar[str] = constants.UpdateType.EDITED_CHANNEL_POST - """:const:`telegram.constants.UpdateType.EDITED_CHANNEL_POST` - - .. versionadded:: 13.5""" - INLINE_QUERY: ClassVar[str] = constants.UpdateType.INLINE_QUERY - """:const:`telegram.constants.UpdateType.INLINE_QUERY` - - .. versionadded:: 13.5""" - CHOSEN_INLINE_RESULT: ClassVar[str] = constants.UpdateType.CHOSEN_INLINE_RESULT - """:const:`telegram.constants.UpdateType.CHOSEN_INLINE_RESULT` - - .. versionadded:: 13.5""" - CALLBACK_QUERY: ClassVar[str] = constants.UpdateType.CALLBACK_QUERY - """:const:`telegram.constants.UpdateType.CALLBACK_QUERY` - - .. versionadded:: 13.5""" - SHIPPING_QUERY: ClassVar[str] = constants.UpdateType.SHIPPING_QUERY - """:const:`telegram.constants.UpdateType.SHIPPING_QUERY` - - .. versionadded:: 13.5""" - PRE_CHECKOUT_QUERY: ClassVar[str] = constants.UpdateType.PRE_CHECKOUT_QUERY - """:const:`telegram.constants.UpdateType.PRE_CHECKOUT_QUERY` - - .. versionadded:: 13.5""" - POLL: ClassVar[str] = constants.UpdateType.POLL - """:const:`telegram.constants.UpdateType.POLL` - - .. versionadded:: 13.5""" - POLL_ANSWER: ClassVar[str] = constants.UpdateType.POLL_ANSWER - """:const:`telegram.constants.UpdateType.POLL_ANSWER` - - .. versionadded:: 13.5""" - MY_CHAT_MEMBER: ClassVar[str] = constants.UpdateType.MY_CHAT_MEMBER - """:const:`telegram.constants.UpdateType.MY_CHAT_MEMBER` - - .. versionadded:: 13.5""" - CHAT_MEMBER: ClassVar[str] = constants.UpdateType.CHAT_MEMBER - """:const:`telegram.constants.UpdateType.CHAT_MEMBER` - - .. versionadded:: 13.5""" - CHAT_JOIN_REQUEST = constants.UpdateType.CHAT_JOIN_REQUEST - """:const:`telegram.constants.UpdateType.CHAT_JOIN_REQUEST` - - .. versionadded:: 13.8""" - ALL_TYPES: ClassVar[List[str]] = list(constants.UpdateType) - """List[:obj:`str`]: A list of all available update types. - - .. versionadded:: 13.5""" - - def __init__( - self, - update_id: int, - message: Optional[Message] = None, - edited_message: Optional[Message] = None, - channel_post: Optional[Message] = None, - edited_channel_post: Optional[Message] = None, - inline_query: Optional[InlineQuery] = None, - chosen_inline_result: Optional[ChosenInlineResult] = None, - callback_query: Optional[CallbackQuery] = None, - shipping_query: Optional[ShippingQuery] = None, - pre_checkout_query: Optional[PreCheckoutQuery] = None, - poll: Optional[Poll] = None, - poll_answer: Optional[PollAnswer] = None, - my_chat_member: Optional[ChatMemberUpdated] = None, - chat_member: Optional[ChatMemberUpdated] = None, - chat_join_request: Optional[ChatJoinRequest] = None, - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - # Required - self.update_id: int = update_id - # Optionals - self.message: Optional[Message] = message - self.edited_message: Optional[Message] = edited_message - self.inline_query: Optional[InlineQuery] = inline_query - self.chosen_inline_result: Optional[ChosenInlineResult] = chosen_inline_result - self.callback_query: Optional[CallbackQuery] = callback_query - self.shipping_query: Optional[ShippingQuery] = shipping_query - self.pre_checkout_query: Optional[PreCheckoutQuery] = pre_checkout_query - self.channel_post: Optional[Message] = channel_post - self.edited_channel_post: Optional[Message] = edited_channel_post - self.poll: Optional[Poll] = poll - self.poll_answer: Optional[PollAnswer] = poll_answer - self.my_chat_member: Optional[ChatMemberUpdated] = my_chat_member - self.chat_member: Optional[ChatMemberUpdated] = chat_member - self.chat_join_request: Optional[ChatJoinRequest] = chat_join_request - - self._effective_user: Optional["User"] = None - self._effective_chat: Optional["Chat"] = None - self._effective_message: Optional[Message] = None - - self._id_attrs = (self.update_id,) - - self._freeze() - - @property - def effective_user(self) -> Optional["User"]: - """ - :class:`telegram.User`: The user that sent this update, no matter what kind of update this - is. If no user is associated with this update, this gives :obj:`None`. This is the case - if :attr:`channel_post`, :attr:`edited_channel_post` or :attr:`poll` is present. - - Example: - * If :attr:`message` is present, this will give - :attr:`telegram.Message.from_user`. - * If :attr:`poll_answer` is present, this will give :attr:`telegram.PollAnswer.user`. - - """ - if self._effective_user: - return self._effective_user - - user = None - - if self.message: - user = self.message.from_user - - elif self.edited_message: - user = self.edited_message.from_user - - elif self.inline_query: - user = self.inline_query.from_user - - elif self.chosen_inline_result: - user = self.chosen_inline_result.from_user - - elif self.callback_query: - user = self.callback_query.from_user - - elif self.shipping_query: - user = self.shipping_query.from_user - - elif self.pre_checkout_query: - user = self.pre_checkout_query.from_user - - elif self.poll_answer: - user = self.poll_answer.user - - elif self.my_chat_member: - user = self.my_chat_member.from_user - - elif self.chat_member: - user = self.chat_member.from_user - - elif self.chat_join_request: - user = self.chat_join_request.from_user - - self._effective_user = user - return user - - @property - def effective_chat(self) -> Optional["Chat"]: - """ - :class:`telegram.Chat`: The chat that this update was sent in, no matter what kind of - update this is. - If no chat is associated with this update, this gives :obj:`None`. - This is the case, if :attr:`inline_query`, - :attr:`chosen_inline_result`, :attr:`callback_query` from inline messages, - :attr:`shipping_query`, :attr:`pre_checkout_query`, :attr:`poll` or - :attr:`poll_answer` is present. - - Example: - If :attr:`message` is present, this will give :attr:`telegram.Message.chat`. - - """ - if self._effective_chat: - return self._effective_chat - - chat = None - - if self.message: - chat = self.message.chat - - elif self.edited_message: - chat = self.edited_message.chat - - elif self.callback_query and self.callback_query.message: - chat = self.callback_query.message.chat - - elif self.channel_post: - chat = self.channel_post.chat - - elif self.edited_channel_post: - chat = self.edited_channel_post.chat - - elif self.my_chat_member: - chat = self.my_chat_member.chat - - elif self.chat_member: - chat = self.chat_member.chat - - elif self.chat_join_request: - chat = self.chat_join_request.chat - - self._effective_chat = chat - return chat - - @property - def effective_message(self) -> Optional[Message]: - """ - :class:`telegram.Message`: The message included in this update, no matter what kind of - update this is. More precisely, this will be the message contained in :attr:`message`, - :attr:`edited_message`, :attr:`channel_post`, :attr:`edited_channel_post` or - :attr:`callback_query` (i.e. :attr:`telegram.CallbackQuery.message`) or :obj:`None`, if - none of those are present. - - """ - if self._effective_message: - return self._effective_message - - message = None - - if self.message: - message = self.message - - elif self.edited_message: - message = self.edited_message - - elif self.callback_query: - message = self.callback_query.message - - elif self.channel_post: - message = self.channel_post - - elif self.edited_channel_post: - message = self.edited_channel_post - - self._effective_message = message - return message - - @classmethod - def de_json(cls, data: Optional[JSONDict], bot: "Bot") -> Optional["Update"]: - """See :meth:`telegram.TelegramObject.de_json`.""" - data = cls._parse_data(data) - - if not data: - return None - - data["message"] = Message.de_json(data.get("message"), bot) - data["edited_message"] = Message.de_json(data.get("edited_message"), bot) - data["inline_query"] = InlineQuery.de_json(data.get("inline_query"), bot) - data["chosen_inline_result"] = ChosenInlineResult.de_json( - data.get("chosen_inline_result"), bot - ) - data["callback_query"] = CallbackQuery.de_json(data.get("callback_query"), bot) - data["shipping_query"] = ShippingQuery.de_json(data.get("shipping_query"), bot) - data["pre_checkout_query"] = PreCheckoutQuery.de_json(data.get("pre_checkout_query"), bot) - data["channel_post"] = Message.de_json(data.get("channel_post"), bot) - data["edited_channel_post"] = Message.de_json(data.get("edited_channel_post"), bot) - data["poll"] = Poll.de_json(data.get("poll"), bot) - data["poll_answer"] = PollAnswer.de_json(data.get("poll_answer"), bot) - data["my_chat_member"] = ChatMemberUpdated.de_json(data.get("my_chat_member"), bot) - data["chat_member"] = ChatMemberUpdated.de_json(data.get("chat_member"), bot) - data["chat_join_request"] = ChatJoinRequest.de_json(data.get("chat_join_request"), bot) - - return super().de_json(data=data, bot=bot) diff --git a/telegram/_user.py b/telegram/_user.py deleted file mode 100644 index abafce0c380..00000000000 --- a/telegram/_user.py +++ /dev/null @@ -1,1660 +0,0 @@ -#!/usr/bin/env python -# pylint: disable=redefined-builtin -# -# A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 -# 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 User.""" -from datetime import datetime -from typing import TYPE_CHECKING, Optional, Sequence, Tuple, Union - -from telegram._inline.inlinekeyboardbutton import InlineKeyboardButton -from telegram._menubutton import MenuButton -from telegram._telegramobject import TelegramObject -from telegram._utils.defaultvalue import DEFAULT_NONE -from telegram._utils.types import DVInput, FileInput, JSONDict, ODVInput, ReplyMarkup -from telegram.helpers import mention_html as helpers_mention_html -from telegram.helpers import mention_markdown as helpers_mention_markdown - -if TYPE_CHECKING: - from telegram import ( - Animation, - Audio, - Contact, - Document, - InlineKeyboardMarkup, - InputMediaAudio, - InputMediaDocument, - InputMediaPhoto, - InputMediaVideo, - LabeledPrice, - Location, - Message, - MessageEntity, - MessageId, - PhotoSize, - Sticker, - UserProfilePhotos, - Venue, - Video, - VideoNote, - Voice, - ) - - -class User(TelegramObject): - """This object represents a Telegram user or bot. - - Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`id` is equal. - - .. versionchanged:: 20.0 - The following are now keyword-only arguments in Bot methods: - ``location``, ``filename``, ``venue``, ``contact``, - ``{read, write, connect, pool}_timeout`` ``api_kwargs``. Use a named argument for those, - and notice that some positional arguments changed position as a result. - - Args: - id (:obj:`int`): Unique identifier for this user or bot. - is_bot (:obj:`bool`): :obj:`True`, if this user is a bot. - first_name (:obj:`str`): User's or bot's first name. - last_name (:obj:`str`, optional): User's or bot's last name. - username (:obj:`str`, optional): User's or bot's username. - language_code (:obj:`str`, optional): IETF language tag of the user's language. - can_join_groups (:obj:`str`, optional): :obj:`True`, if the bot can be invited to groups. - Returned only in :attr:`telegram.Bot.get_me` requests. - can_read_all_group_messages (:obj:`str`, optional): :obj:`True`, if privacy mode is - disabled for the bot. Returned only in :attr:`telegram.Bot.get_me` requests. - supports_inline_queries (:obj:`str`, optional): :obj:`True`, if the bot supports inline - queries. Returned only in :attr:`telegram.Bot.get_me` requests. - - is_premium (:obj:`bool`, optional): :obj:`True`, if this user is a Telegram Premium user. - - .. versionadded:: 20.0 - added_to_attachment_menu (:obj:`bool`, optional): :obj:`True`, if this user added - the bot to the attachment menu. - - .. versionadded:: 20.0 - Attributes: - id (:obj:`int`): Unique identifier for this user or bot. - is_bot (:obj:`bool`): :obj:`True`, if this user is a bot. - first_name (:obj:`str`): User's or bot's first name. - last_name (:obj:`str`): Optional. User's or bot's last name. - username (:obj:`str`): Optional. User's or bot's username. - language_code (:obj:`str`): Optional. IETF language tag of the user's language. - can_join_groups (:obj:`str`): Optional. :obj:`True`, if the bot can be invited to groups. - Returned only in :attr:`telegram.Bot.get_me` requests. - can_read_all_group_messages (:obj:`str`): Optional. :obj:`True`, if privacy mode is - disabled for the bot. Returned only in :attr:`telegram.Bot.get_me` requests. - supports_inline_queries (:obj:`str`): Optional. :obj:`True`, if the bot supports inline - queries. Returned only in :attr:`telegram.Bot.get_me` requests. - is_premium (:obj:`bool`): Optional. :obj:`True`, if this user is a Telegram - Premium user. - - .. versionadded:: 20.0 - added_to_attachment_menu (:obj:`bool`): Optional. :obj:`True`, if this user added - the bot to the attachment menu. - - .. versionadded:: 20.0 - .. |user_chat_id_note| replace:: This shortcuts build on the assumption that :attr:`User.id` - coincides with the :attr:`Chat.id` of the private chat with the user. This has been the - case so far, but Telegram does not guarantee that this stays this way. - """ - - __slots__ = ( - "is_bot", - "can_read_all_group_messages", - "username", - "first_name", - "last_name", - "can_join_groups", - "supports_inline_queries", - "id", - "language_code", - "is_premium", - "added_to_attachment_menu", - ) - - def __init__( - self, - id: int, - first_name: str, - is_bot: bool, - last_name: Optional[str] = None, - username: Optional[str] = None, - language_code: Optional[str] = None, - can_join_groups: Optional[bool] = None, - can_read_all_group_messages: Optional[bool] = None, - supports_inline_queries: Optional[bool] = None, - is_premium: Optional[bool] = None, - added_to_attachment_menu: Optional[bool] = None, - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - # Required - self.id: int = id # pylint: disable=invalid-name - self.first_name: str = first_name - self.is_bot: bool = is_bot - # Optionals - self.last_name: Optional[str] = last_name - self.username: Optional[str] = username - self.language_code: Optional[str] = language_code - self.can_join_groups: Optional[bool] = can_join_groups - self.can_read_all_group_messages: Optional[bool] = can_read_all_group_messages - self.supports_inline_queries: Optional[bool] = supports_inline_queries - self.is_premium: Optional[bool] = is_premium - self.added_to_attachment_menu: Optional[bool] = added_to_attachment_menu - - self._id_attrs = (self.id,) - - self._freeze() - - @property - def name(self) -> str: - """:obj:`str`: Convenience property. If available, returns the user's :attr:`username` - prefixed with "@". If :attr:`username` is not available, returns :attr:`full_name`. - """ - if self.username: - return f"@{self.username}" - return self.full_name - - @property - def full_name(self) -> str: - """:obj:`str`: Convenience property. The user's :attr:`first_name`, followed by (if - available) :attr:`last_name`. - """ - if self.last_name: - return f"{self.first_name} {self.last_name}" - return self.first_name - - @property - def link(self) -> Optional[str]: - """:obj:`str`: Convenience property. If :attr:`username` is available, returns a t.me link - of the user. - """ - if self.username: - return f"https://t.me/{self.username}" - return None - - async def get_profile_photos( - 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, - ) -> Optional["UserProfilePhotos"]: - """Shortcut for:: - - await bot.get_user_profile_photos(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.get_user_profile_photos`. - - """ - return await self.get_bot().get_user_profile_photos( - user_id=self.id, - offset=offset, - limit=limit, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - def mention_markdown(self, name: Optional[str] = None) -> str: - """ - Note: - :tg-const:`telegram.constants.ParseMode.MARKDOWN` is a legacy mode, retained by - Telegram for backward compatibility. You should use :meth:`mention_markdown_v2` - instead. - - Args: - name (:obj:`str`): The name used as a link for the user. Defaults to :attr:`full_name`. - - Returns: - :obj:`str`: The inline mention for the user as markdown (version 1). - - """ - if name: - return helpers_mention_markdown(self.id, name) - return helpers_mention_markdown(self.id, self.full_name) - - def mention_markdown_v2(self, name: Optional[str] = None) -> str: - """ - Args: - name (:obj:`str`): The name used as a link for the user. Defaults to :attr:`full_name`. - - Returns: - :obj:`str`: The inline mention for the user as markdown (version 2). - - """ - if name: - return helpers_mention_markdown(self.id, name, version=2) - return helpers_mention_markdown(self.id, self.full_name, version=2) - - def mention_html(self, name: Optional[str] = None) -> str: - """ - Args: - name (:obj:`str`): The name used as a link for the user. Defaults to :attr:`full_name`. - - Returns: - :obj:`str`: The inline mention for the user as HTML. - - """ - if name: - return helpers_mention_html(self.id, name) - return helpers_mention_html(self.id, self.full_name) - - def mention_button(self, name: Optional[str] = None) -> InlineKeyboardButton: - """Shortcut for:: - - InlineKeyboardButton(text=name, url=f"tg://user?id={update.effective_user.id}") - - .. versionadded:: 13.9 - - Args: - name (:obj:`str`): The name used as a link for the user. Defaults to :attr:`full_name`. - - Returns: - :class:`telegram.InlineKeyboardButton`: InlineButton with url set to the user mention - """ - return InlineKeyboardButton(text=name or self.full_name, url=f"tg://user?id={self.id}") - - async def pin_message( - self, - message_id: int, - disable_notification: ODVInput[bool] = DEFAULT_NONE, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> bool: - """Shortcut for:: - - await bot.pin_chat_message(chat_id=update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.pin_chat_message`. - - Note: - |user_chat_id_note| - - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - - """ - return await self.get_bot().pin_chat_message( - chat_id=self.id, - message_id=message_id, - disable_notification=disable_notification, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def unpin_message( - self, - 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, - ) -> bool: - """Shortcut for:: - - await bot.unpin_chat_message(chat_id=update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.unpin_chat_message`. - - Note: - |user_chat_id_note| - - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - - """ - return await self.get_bot().unpin_chat_message( - chat_id=self.id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - message_id=message_id, - ) - - async def unpin_all_messages( - self, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> bool: - """Shortcut for:: - - await bot.unpin_all_chat_messages(chat_id=update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.unpin_all_chat_messages`. - - Note: - |user_chat_id_note| - - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - - """ - return await self.get_bot().unpin_all_chat_messages( - chat_id=self.id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def send_message( - self, - text: str, - parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_web_page_preview: ODVInput[bool] = DEFAULT_NONE, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_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_message(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_message`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().send_message( - chat_id=self.id, - text=text, - parse_mode=parse_mode, - disable_web_page_preview=disable_web_page_preview, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - allow_sending_without_reply=allow_sending_without_reply, - entities=entities, - protect_content=protect_content, - message_thread_id=message_thread_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def send_photo( - self, - photo: Union[FileInput, "PhotoSize"], - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - *, - filename: Optional[str] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_photo(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_photo`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().send_photo( - chat_id=self.id, - photo=photo, - caption=caption, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - parse_mode=parse_mode, - allow_sending_without_reply=allow_sending_without_reply, - caption_entities=caption_entities, - filename=filename, - protect_content=protect_content, - message_thread_id=message_thread_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - has_spoiler=has_spoiler, - ) - - async def send_media_group( - self, - media: Sequence[ - Union["InputMediaAudio", "InputMediaDocument", "InputMediaPhoto", "InputMediaVideo"] - ], - disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - caption: Optional[str] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - ) -> Tuple["Message", ...]: - """Shortcut for:: - - await bot.send_media_group(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_media_group`. - - Note: - |user_chat_id_note| - - Returns: - Tuple[:class:`telegram.Message`:] On success, a tuple of :class:`~telegram.Message` - instances that were sent is returned. - - """ - return await self.get_bot().send_media_group( - chat_id=self.id, - media=media, - disable_notification=disable_notification, - 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, - allow_sending_without_reply=allow_sending_without_reply, - protect_content=protect_content, - message_thread_id=message_thread_id, - caption=caption, - parse_mode=parse_mode, - caption_entities=caption_entities, - ) - - async def send_audio( - self, - audio: Union[FileInput, "Audio"], - duration: Optional[int] = None, - performer: Optional[str] = None, - title: Optional[str] = None, - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - thumb: Optional[FileInput] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - *, - filename: Optional[str] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_audio(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_audio`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().send_audio( - chat_id=self.id, - audio=audio, - duration=duration, - performer=performer, - title=title, - caption=caption, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - parse_mode=parse_mode, - thumb=thumb, - allow_sending_without_reply=allow_sending_without_reply, - caption_entities=caption_entities, - filename=filename, - protect_content=protect_content, - message_thread_id=message_thread_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - thumbnail=thumbnail, - ) - - async def send_chat_action( - self, - action: str, - message_thread_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, - ) -> bool: - """Shortcut for:: - - await bot.send_chat_action(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_chat_action`. - - Note: - |user_chat_id_note| - - Returns: - :obj:`True`: On success. - - """ - return await self.get_bot().send_chat_action( - chat_id=self.id, - action=action, - message_thread_id=message_thread_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - send_action = send_chat_action - """Alias for :attr:`send_chat_action`""" - - async def send_contact( - self, - phone_number: Optional[str] = None, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - vcard: Optional[str] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - contact: Optional["Contact"] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_contact(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_contact`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().send_contact( - chat_id=self.id, - phone_number=phone_number, - first_name=first_name, - last_name=last_name, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - contact=contact, - vcard=vcard, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def send_dice( - self, - disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - emoji: Optional[str] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_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_dice(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_dice`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().send_dice( - chat_id=self.id, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - emoji=emoji, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def send_document( - self, - document: Union[FileInput, "Document"], - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - thumb: Optional[FileInput] = None, - disable_content_type_detection: Optional[bool] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - *, - filename: Optional[str] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_document(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_document`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().send_document( - chat_id=self.id, - document=document, - filename=filename, - caption=caption, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - parse_mode=parse_mode, - thumb=thumb, - thumbnail=thumbnail, - api_kwargs=api_kwargs, - disable_content_type_detection=disable_content_type_detection, - allow_sending_without_reply=allow_sending_without_reply, - caption_entities=caption_entities, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def send_game( - self, - game_short_name: str, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_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_game(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_game`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().send_game( - chat_id=self.id, - game_short_name=game_short_name, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def send_invoice( - self, - title: str, - description: str, - payload: str, - provider_token: str, - currency: str, - prices: Sequence["LabeledPrice"], - start_parameter: Optional[str] = None, - photo_url: Optional[str] = None, - photo_size: Optional[int] = None, - photo_width: Optional[int] = None, - photo_height: Optional[int] = None, - need_name: Optional[bool] = None, - need_phone_number: Optional[bool] = None, - need_email: Optional[bool] = None, - need_shipping_address: Optional[bool] = None, - is_flexible: Optional[bool] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional["InlineKeyboardMarkup"] = None, - provider_data: Optional[Union[str, object]] = None, - send_phone_number_to_provider: Optional[bool] = None, - send_email_to_provider: Optional[bool] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - max_tip_amount: Optional[int] = None, - suggested_tip_amounts: Optional[Sequence[int]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_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_invoice(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_invoice`. - - Warning: - As of API 5.2 :paramref:`start_parameter ` - is an optional argument and therefore the - order of the arguments had to be changed. Use keyword arguments to make sure that the - arguments are passed correctly. - - Note: - |user_chat_id_note| - - .. versionchanged:: 13.5 - As of Bot API 5.2, the parameter - :paramref:`start_parameter ` is optional. - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().send_invoice( - chat_id=self.id, - title=title, - description=description, - payload=payload, - provider_token=provider_token, - currency=currency, - prices=prices, - start_parameter=start_parameter, - photo_url=photo_url, - photo_size=photo_size, - photo_width=photo_width, - photo_height=photo_height, - need_name=need_name, - need_phone_number=need_phone_number, - need_email=need_email, - need_shipping_address=need_shipping_address, - is_flexible=is_flexible, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - provider_data=provider_data, - send_phone_number_to_provider=send_phone_number_to_provider, - send_email_to_provider=send_email_to_provider, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - max_tip_amount=max_tip_amount, - suggested_tip_amounts=suggested_tip_amounts, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def send_location( - self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - live_period: Optional[int] = None, - horizontal_accuracy: Optional[float] = None, - heading: Optional[int] = None, - proximity_alert_radius: Optional[int] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - location: Optional["Location"] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_location(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_location`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().send_location( - chat_id=self.id, - latitude=latitude, - longitude=longitude, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - location=location, - live_period=live_period, - api_kwargs=api_kwargs, - horizontal_accuracy=horizontal_accuracy, - heading=heading, - proximity_alert_radius=proximity_alert_radius, - allow_sending_without_reply=allow_sending_without_reply, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def send_animation( - self, - animation: Union[FileInput, "Animation"], - duration: Optional[int] = None, - width: Optional[int] = None, - height: Optional[int] = None, - thumb: Optional[FileInput] = None, - caption: Optional[str] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, - *, - filename: Optional[str] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_animation(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_animation`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().send_animation( - chat_id=self.id, - animation=animation, - duration=duration, - width=width, - height=height, - thumb=thumb, - caption=caption, - parse_mode=parse_mode, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - caption_entities=caption_entities, - filename=filename, - protect_content=protect_content, - message_thread_id=message_thread_id, - has_spoiler=has_spoiler, - thumbnail=thumbnail, - ) - - async def send_sticker( - self, - sticker: Union[FileInput, "Sticker"], - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - emoji: Optional[str] = None, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_sticker(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_sticker`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().send_sticker( - chat_id=self.id, - sticker=sticker, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - protect_content=protect_content, - message_thread_id=message_thread_id, - emoji=emoji, - ) - - async def send_video( - self, - video: Union[FileInput, "Video"], - duration: Optional[int] = None, - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - width: Optional[int] = None, - height: Optional[int] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - supports_streaming: Optional[bool] = None, - thumb: Optional[FileInput] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - has_spoiler: Optional[bool] = None, - thumbnail: Optional[FileInput] = None, - *, - filename: Optional[str] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_video(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_video`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().send_video( - chat_id=self.id, - video=video, - duration=duration, - caption=caption, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - width=width, - height=height, - parse_mode=parse_mode, - supports_streaming=supports_streaming, - thumb=thumb, - thumbnail=thumbnail, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - caption_entities=caption_entities, - filename=filename, - protect_content=protect_content, - message_thread_id=message_thread_id, - has_spoiler=has_spoiler, - ) - - async def send_venue( - self, - latitude: Optional[float] = None, - longitude: Optional[float] = None, - title: Optional[str] = None, - address: Optional[str] = None, - foursquare_id: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - foursquare_type: Optional[str] = None, - google_place_id: Optional[str] = None, - google_place_type: Optional[str] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - venue: Optional["Venue"] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_venue(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_venue`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().send_venue( - chat_id=self.id, - latitude=latitude, - longitude=longitude, - title=title, - address=address, - foursquare_id=foursquare_id, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - venue=venue, - foursquare_type=foursquare_type, - api_kwargs=api_kwargs, - google_place_id=google_place_id, - google_place_type=google_place_type, - allow_sending_without_reply=allow_sending_without_reply, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def send_video_note( - self, - video_note: Union[FileInput, "VideoNote"], - duration: Optional[int] = None, - length: Optional[int] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - thumb: Optional[FileInput] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - thumbnail: Optional[FileInput] = None, - *, - filename: Optional[str] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_video_note(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_video_note`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().send_video_note( - chat_id=self.id, - video_note=video_note, - duration=duration, - length=length, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - thumb=thumb, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - filename=filename, - protect_content=protect_content, - message_thread_id=message_thread_id, - thumbnail=thumbnail, - ) - - async def send_voice( - self, - voice: Union[FileInput, "Voice"], - duration: Optional[int] = None, - caption: Optional[str] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_id: Optional[int] = None, - *, - filename: Optional[str] = None, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = 20, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> "Message": - """Shortcut for:: - - await bot.send_voice(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_voice`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().send_voice( - chat_id=self.id, - voice=voice, - duration=duration, - caption=caption, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - parse_mode=parse_mode, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - caption_entities=caption_entities, - filename=filename, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def send_poll( - self, - question: str, - options: Sequence[str], - is_anonymous: Optional[bool] = None, - type: Optional[str] = None, - allows_multiple_answers: Optional[bool] = None, - correct_option_id: Optional[int] = None, - is_closed: Optional[bool] = None, - disable_notification: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - reply_markup: Optional[ReplyMarkup] = None, - explanation: Optional[str] = None, - explanation_parse_mode: ODVInput[str] = DEFAULT_NONE, - open_period: Optional[int] = None, - close_date: Optional[Union[int, datetime]] = None, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - explanation_entities: Optional[Sequence["MessageEntity"]] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_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_poll(update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.send_poll`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().send_poll( - chat_id=self.id, - question=question, - options=options, - is_anonymous=is_anonymous, - type=type, # pylint=pylint, - allows_multiple_answers=allows_multiple_answers, - correct_option_id=correct_option_id, - is_closed=is_closed, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - explanation=explanation, - explanation_parse_mode=explanation_parse_mode, - open_period=open_period, - close_date=close_date, - api_kwargs=api_kwargs, - allow_sending_without_reply=allow_sending_without_reply, - explanation_entities=explanation_entities, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def send_copy( - self, - from_chat_id: Union[str, int], - message_id: int, - caption: Optional[str] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - allow_sending_without_reply: DVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_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, - ) -> "MessageId": - """Shortcut for:: - - await bot.copy_message(chat_id=update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.copy_message`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().copy_message( - chat_id=self.id, - from_chat_id=from_chat_id, - message_id=message_id, - caption=caption, - parse_mode=parse_mode, - caption_entities=caption_entities, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - allow_sending_without_reply=allow_sending_without_reply, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def copy_message( - self, - chat_id: Union[int, str], - message_id: int, - caption: Optional[str] = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Optional[Sequence["MessageEntity"]] = None, - disable_notification: DVInput[bool] = DEFAULT_NONE, - reply_to_message_id: Optional[int] = None, - allow_sending_without_reply: DVInput[bool] = DEFAULT_NONE, - reply_markup: Optional[ReplyMarkup] = None, - protect_content: ODVInput[bool] = DEFAULT_NONE, - message_thread_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, - ) -> "MessageId": - """Shortcut for:: - - await bot.copy_message(from_chat_id=update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see :meth:`telegram.Bot.copy_message`. - - Note: - |user_chat_id_note| - - Returns: - :class:`telegram.Message`: On success, instance representing the message posted. - - """ - return await self.get_bot().copy_message( - from_chat_id=self.id, - chat_id=chat_id, - message_id=message_id, - caption=caption, - parse_mode=parse_mode, - caption_entities=caption_entities, - disable_notification=disable_notification, - reply_to_message_id=reply_to_message_id, - allow_sending_without_reply=allow_sending_without_reply, - reply_markup=reply_markup, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - protect_content=protect_content, - message_thread_id=message_thread_id, - ) - - async def approve_join_request( - self, - chat_id: Union[int, str], - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> bool: - """Shortcut for:: - - await bot.approve_chat_join_request(user_id=update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.approve_chat_join_request`. - - Note: - |user_chat_id_note| - - .. versionadded:: 13.8 - - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - - """ - return await self.get_bot().approve_chat_join_request( - user_id=self.id, - chat_id=chat_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def decline_join_request( - self, - chat_id: Union[int, str], - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> bool: - """Shortcut for:: - - await bot.decline_chat_join_request(user_id=update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.decline_chat_join_request`. - - Note: - |user_chat_id_note| - - .. versionadded:: 13.8 - - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - - """ - return await self.get_bot().decline_chat_join_request( - user_id=self.id, - chat_id=chat_id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def set_menu_button( - self, - menu_button: Optional[MenuButton] = None, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> bool: - """Shortcut for:: - - await bot.set_chat_menu_button(chat_id=update.effective_chat.id, *args, **kwargs) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.set_chat_menu_button`. - - .. seealso:: :meth:`get_menu_button` - - Note: - |user_chat_id_note| - - .. versionadded:: 20.0 - - Returns: - :obj:`bool`: On success, :obj:`True` is returned. - """ - return await self.get_bot().set_chat_menu_button( - chat_id=self.id, - menu_button=menu_button, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) - - async def get_menu_button( - self, - *, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, - api_kwargs: Optional[JSONDict] = None, - ) -> MenuButton: - """Shortcut for:: - - await bot.get_chat_menu_button(chat_id=update.effective_user.id, *args, **kwargs) - - For the documentation of the arguments, please see - :meth:`telegram.Bot.get_chat_menu_button`. - - .. seealso:: :meth:`set_menu_button` - - Note: - |user_chat_id_note| - - .. versionadded:: 20.0 - - Returns: - :class:`telegram.MenuButton`: On success, the current menu button is returned. - """ - return await self.get_bot().get_chat_menu_button( - chat_id=self.id, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, - pool_timeout=pool_timeout, - api_kwargs=api_kwargs, - ) diff --git a/telegram/_writeaccessallowed.py b/telegram/_writeaccessallowed.py deleted file mode 100644 index 698cbc9f042..00000000000 --- a/telegram/_writeaccessallowed.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python -# -# A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 -# 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 related to the write access allowed service message.""" -from typing import Optional - -from telegram._telegramobject import TelegramObject -from telegram._utils.types import JSONDict - - -class WriteAccessAllowed(TelegramObject): - """ - This object represents a service message about a user allowing a bot to write messages after - adding the bot to the attachment menu or launching a Web App from a link. - - .. versionadded:: 20.0 - - Args: - web_app_name (:obj:`str`, optional): Name of the Web App which was launched from a link. - - .. versionadded:: 20.3 - - Attributes: - web_app_name (:obj:`str`): Optional. Name of the Web App which was launched from a link. - - .. versionadded:: 20.3 - - """ - - __slots__ = ("web_app_name",) - - def __init__( - self, web_app_name: Optional[str] = None, *, api_kwargs: Optional[JSONDict] = None - ): - super().__init__(api_kwargs=api_kwargs) - self.web_app_name: Optional[str] = web_app_name - - self._freeze() diff --git a/telegram/constants.py b/telegram/constants.py deleted file mode 100644 index 7250ceabb06..00000000000 --- a/telegram/constants.py +++ /dev/null @@ -1,1749 +0,0 @@ -# python-telegram-bot - a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 -# by the python-telegram-bot contributors -# -# 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 several constants that are relevant for working with the Bot API. - -Unless noted otherwise, all constants in this module were extracted from the -`Telegram Bots FAQ `_ and -`Telegram Bots API `_. - -Most of the following constants are related to specific classes or topics and are grouped into -enums. If they are related to a specific class, then they are also available as attributes of -those classes. - -.. versionchanged:: 20.0 - - * Most of the constants in this module are grouped into enums. -""" -# TODO: Remove this when https://github.com/PyCQA/pylint/issues/6887 is resolved. -# pylint: disable=invalid-enum-extension - -__all__ = [ - "BOT_API_VERSION", - "BOT_API_VERSION_INFO", - "BotCommandLimit", - "BotCommandScopeType", - "BotDescriptionLimit", - "BotNameLimit", - "CallbackQueryLimit", - "ChatAction", - "ChatID", - "ChatInviteLinkLimit", - "ChatLimit", - "ChatMemberStatus", - "ChatPhotoSize", - "ChatType", - "ContactLimit", - "CustomEmojiStickerLimit", - "DiceEmoji", - "DiceLimit", - "FileSizeLimit", - "FloodLimit", - "ForumIconColor", - "ForumTopicLimit", - "InlineKeyboardButtonLimit", - "InlineKeyboardMarkupLimit", - "InlineQueryLimit", - "InlineQueryResultLimit", - "InlineQueryResultsButtonLimit", - "InlineQueryResultType", - "InputMediaType", - "InvoiceLimit", - "LocationLimit", - "MaskPosition", - "MediaGroupLimit", - "MenuButtonType", - "MessageAttachmentType", - "MessageEntityType", - "MessageLimit", - "MessageType", - "PollingLimit", - "ParseMode", - "PollLimit", - "PollType", - "ReplyLimit", - "SUPPORTED_WEBHOOK_PORTS", - "StickerFormat", - "StickerLimit", - "StickerSetLimit", - "StickerType", - "WebhookLimit", - "UpdateType", - "UserProfilePhotosLimit", -] - -import sys -from typing import List, NamedTuple - -from telegram._utils.enum import IntEnum, StringEnum - - -class _BotAPIVersion(NamedTuple): - """Similar behavior to sys.version_info. - So far TG has only published X.Y releases. We can add X.Y.Z(a(S)) if needed. - """ - - major: int - minor: int - - def __repr__(self) -> str: - """Unfortunately calling super().__repr__ doesn't work with typing.NamedTuple, so we - do this manually. - """ - return f"BotAPIVersion(major={self.major}, minor={self.minor})" - - def __str__(self) -> str: - return f"{self.major}.{self.minor}" - - -#: :class:`typing.NamedTuple`: A tuple containing the two components of the version number: -# ``major`` and ``minor``. Both values are integers. -#: The components can also be accessed by name, so ``BOT_API_VERSION_INFO[0]`` is equivalent -#: to ``BOT_API_VERSION_INFO.major`` and so on. Also available as -#: :data:`telegram.__bot_api_version_info__`. -#: -#: .. versionadded:: 20.0 -BOT_API_VERSION_INFO = _BotAPIVersion(major=6, minor=7) -#: :obj:`str`: Telegram Bot API -#: version supported by this version of `python-telegram-bot`. Also available as -#: :data:`telegram.__bot_api_version__`. -#: -#: .. versionadded:: 13.4 -BOT_API_VERSION = str(BOT_API_VERSION_INFO) - -# constants above this line are tested - -#: List[:obj:`int`]: Ports supported by -#: :paramref:`telegram.Bot.set_webhook.url`. -SUPPORTED_WEBHOOK_PORTS: List[int] = [443, 80, 88, 8443] - - -class BotCommandLimit(IntEnum): - """This enum contains limitations for :class:`telegram.BotCommand` and - :meth:`telegram.Bot.set_my_commands`. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MIN_COMMAND = 1 - """:obj:`int`: Minimum value allowed for :paramref:`~telegram.BotCommand.command` parameter of - :class:`telegram.BotCommand`. - """ - MAX_COMMAND = 32 - """:obj:`int`: Maximum value allowed for :paramref:`~telegram.BotCommand.command` parameter of - :class:`telegram.BotCommand`. - """ - MIN_DESCRIPTION = 1 - """:obj:`int`: Minimum value allowed for :paramref:`~telegram.BotCommand.description` - parameter of :class:`telegram.BotCommand`. - """ - MAX_DESCRIPTION = 256 - """:obj:`int`: Maximum value allowed for :paramref:`~telegram.BotCommand.description` - parameter of :class:`telegram.BotCommand`. - """ - MAX_COMMAND_NUMBER = 100 - """:obj:`int`: Maximum number of bot commands passed in a :obj:`list` to the - :paramref:`~telegram.Bot.set_my_commands.commands` - parameter of :meth:`telegram.Bot.set_my_commands`. - """ - - -class BotCommandScopeType(StringEnum): - """This enum contains the available types of :class:`telegram.BotCommandScope`. The enum - members of this enumeration are instances of :class:`str` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - DEFAULT = "default" - """:obj:`str`: The type of :class:`telegram.BotCommandScopeDefault`.""" - ALL_PRIVATE_CHATS = "all_private_chats" - """:obj:`str`: The type of :class:`telegram.BotCommandScopeAllPrivateChats`.""" - ALL_GROUP_CHATS = "all_group_chats" - """:obj:`str`: The type of :class:`telegram.BotCommandScopeAllGroupChats`.""" - ALL_CHAT_ADMINISTRATORS = "all_chat_administrators" - """:obj:`str`: The type of :class:`telegram.BotCommandScopeAllChatAdministrators`.""" - CHAT = "chat" - """:obj:`str`: The type of :class:`telegram.BotCommandScopeChat`.""" - CHAT_ADMINISTRATORS = "chat_administrators" - """:obj:`str`: The type of :class:`telegram.BotCommandScopeChatAdministrators`.""" - CHAT_MEMBER = "chat_member" - """:obj:`str`: The type of :class:`telegram.BotCommandScopeChatMember`.""" - - -class BotDescriptionLimit(IntEnum): - """This enum contains limitations for the methods :meth:`telegram.Bot.set_my_description` and - :meth:`telegram.Bot.set_my_short_description`. The enum members of this enumeration are - instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.2 - """ - - __slots__ = () - - MAX_DESCRIPTION_LENGTH = 512 - """:obj:`int`: Maximum length for the parameter - :paramref:`~telegram.Bot.set_my_description.description` of - :meth:`telegram.Bot.set_my_description` - """ - MAX_SHORT_DESCRIPTION_LENGTH = 120 - """:obj:`int`: Maximum length for the parameter - :paramref:`~telegram.Bot.set_my_short_description.short_description` of - :meth:`telegram.Bot.set_my_short_description` - """ - - -class BotNameLimit(IntEnum): - """This enum contains limitations for the methods :meth:`telegram.Bot.set_my_name`. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.3 - """ - - __slots__ = () - - MAX_NAME_LENGTH = 64 - """:obj:`int`: Maximum length for the parameter :paramref:`~telegram.Bot.set_my_name.name` of - :meth:`telegram.Bot.set_my_name` - """ - - -class CallbackQueryLimit(IntEnum): - """This enum contains limitations for :class:`telegram.CallbackQuery`/ - :meth:`telegram.Bot.answer_callback_query`. The enum members of this enumeration are instances - of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - ANSWER_CALLBACK_QUERY_TEXT_LENGTH = 200 - """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the - :paramref:`~telegram.Bot.answer_callback_query.text` parameter of - :meth:`telegram.Bot.answer_callback_query`.""" - - -class ChatAction(StringEnum): - """This enum contains the available chat actions for :meth:`telegram.Bot.send_chat_action`. - The enum members of this enumeration are instances of :class:`str` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - CHOOSE_STICKER = "choose_sticker" - """:obj:`str`: Chat action indicating that the bot is selecting a sticker.""" - FIND_LOCATION = "find_location" - """:obj:`str`: Chat action indicating that the bot is selecting a location.""" - RECORD_VOICE = "record_voice" - """:obj:`str`: Chat action indicating that the bot is recording a voice message.""" - RECORD_VIDEO = "record_video" - """:obj:`str`: Chat action indicating that the bot is recording a video.""" - RECORD_VIDEO_NOTE = "record_video_note" - """:obj:`str`: Chat action indicating that the bot is recording a video note.""" - TYPING = "typing" - """:obj:`str`: A chat indicating the bot is typing.""" - UPLOAD_VOICE = "upload_voice" - """:obj:`str`: Chat action indicating that the bot is uploading a voice message.""" - UPLOAD_DOCUMENT = "upload_document" - """:obj:`str`: Chat action indicating that the bot is uploading a document.""" - UPLOAD_PHOTO = "upload_photo" - """:obj:`str`: Chat action indicating that the bot is uploading a photo.""" - UPLOAD_VIDEO = "upload_video" - """:obj:`str`: Chat action indicating that the bot is uploading a video.""" - UPLOAD_VIDEO_NOTE = "upload_video_note" - """:obj:`str`: Chat action indicating that the bot is uploading a video note.""" - - -class ChatID(IntEnum): - """This enum contains some special chat IDs. The enum - members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - ANONYMOUS_ADMIN = 1087968824 - """:obj:`int`: User ID in groups for messages sent by anonymous admins. - - Note: - :attr:`telegram.Message.from_user` will contain this ID for backwards compatibility only. - It's recommended to use :attr:`telegram.Message.sender_chat` instead. - """ - SERVICE_CHAT = 777000 - """:obj:`int`: Telegram service chat, that also acts as sender of channel posts forwarded to - discussion groups. - - Note: - :attr:`telegram.Message.from_user` will contain this ID for backwards compatibility only. - It's recommended to use :attr:`telegram.Message.sender_chat` instead. - """ - FAKE_CHANNEL = 136817688 - """:obj:`int`: User ID in groups when message is sent on behalf of a channel. - - Note: - * :attr:`telegram.Message.from_user` will contain this ID for backwards compatibility only. - It's recommended to use :attr:`telegram.Message.sender_chat` instead. - * This value is undocumented and might be changed by Telegram. - """ - - -class ChatInviteLinkLimit(IntEnum): - """This enum contains limitations for :class:`telegram.ChatInviteLink`/ - :meth:`telegram.Bot.create_chat_invite_link`/:meth:`telegram.Bot.edit_chat_invite_link`. The - enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MIN_MEMBER_LIMIT = 1 - """:obj:`int`: Minimum value allowed for the - :paramref:`~telegram.Bot.create_chat_invite_link.member_limit` parameter of - :meth:`telegram.Bot.create_chat_invite_link` and - :paramref:`~telegram.Bot.edit_chat_invite_link.member_limit` of - :meth:`telegram.Bot.edit_chat_invite_link`. - """ - MAX_MEMBER_LIMIT = 99999 - """:obj:`int`: Maximum value allowed for the - :paramref:`~telegram.Bot.create_chat_invite_link.member_limit` parameter of - :meth:`telegram.Bot.create_chat_invite_link` and - :paramref:`~telegram.Bot.edit_chat_invite_link.member_limit` of - :meth:`telegram.Bot.edit_chat_invite_link`. - """ - NAME_LENGTH = 32 - """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the - :paramref:`~telegram.Bot.create_chat_invite_link.name` parameter of - :meth:`telegram.Bot.create_chat_invite_link` and - :paramref:`~telegram.Bot.edit_chat_invite_link.name` of - :meth:`telegram.Bot.edit_chat_invite_link`. - """ - - -class ChatLimit(IntEnum): - """This enum contains limitations for - :meth:`telegram.Bot.set_chat_administrator_custom_title`, - :meth:`telegram.Bot.set_chat_description`, and :meth:`telegram.Bot.set_chat_title`. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - CHAT_ADMINISTRATOR_CUSTOM_TITLE_LENGTH = 16 - """:obj:`int`: Maximum length of a :obj:`str` passed as the - :paramref:`~telegram.Bot.set_chat_administrator_custom_title.custom_title` parameter of - :meth:`telegram.Bot.set_chat_administrator_custom_title`. - """ - CHAT_DESCRIPTION_LENGTH = 255 - """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the - :paramref:`~telegram.Bot.set_chat_description.description` parameter of - :meth:`telegram.Bot.set_chat_description`. - """ - MIN_CHAT_TITLE_LENGTH = 1 - """:obj:`int`: Minimum length of a :obj:`str` passed as the - :paramref:`~telegram.Bot.set_chat_title.title` parameter of - :meth:`telegram.Bot.set_chat_title`. - """ - MAX_CHAT_TITLE_LENGTH = 128 - """:obj:`int`: Maximum length of a :obj:`str` passed as the - :paramref:`~telegram.Bot.set_chat_title.title` parameter of - :meth:`telegram.Bot.set_chat_title`. - """ - - -class ChatMemberStatus(StringEnum): - """This enum contains the available states for :class:`telegram.ChatMember`. The enum - members of this enumeration are instances of :class:`str` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - ADMINISTRATOR = "administrator" - """:obj:`str`: A :class:`telegram.ChatMember` who is administrator of the chat.""" - OWNER = "creator" - """:obj:`str`: A :class:`telegram.ChatMember` who is the owner of the chat.""" - BANNED = "kicked" - """:obj:`str`: A :class:`telegram.ChatMember` who was banned in the chat.""" - LEFT = "left" - """:obj:`str`: A :class:`telegram.ChatMember` who has left the chat.""" - MEMBER = "member" - """:obj:`str`: A :class:`telegram.ChatMember` who is a member of the chat.""" - RESTRICTED = "restricted" - """:obj:`str`: A :class:`telegram.ChatMember` who was restricted in this chat.""" - - -class ChatPhotoSize(IntEnum): - """This enum contains limitations for :class:`telegram.ChatPhoto`. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - SMALL = 160 - """:obj:`int`: Width and height of a small chat photo, ID of which is passed in - :paramref:`~telegram.ChatPhoto.small_file_id` and - :paramref:`~telegram.ChatPhoto.small_file_unique_id` parameters of - :class:`telegram.ChatPhoto`. - """ - BIG = 640 - """:obj:`int`: Width and height of a big chat photo, ID of which is passed in - :paramref:`~telegram.ChatPhoto.big_file_id` and - :paramref:`~telegram.ChatPhoto.big_file_unique_id` parameters of - :class:`telegram.ChatPhoto`. - """ - - -class ChatType(StringEnum): - """This enum contains the available types of :class:`telegram.Chat`. The enum - members of this enumeration are instances of :class:`str` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - SENDER = "sender" - """:obj:`str`: A :class:`telegram.Chat` that represents the chat of a :class:`telegram.User` - sending an :class:`telegram.InlineQuery`. """ - PRIVATE = "private" - """:obj:`str`: A :class:`telegram.Chat` that is private.""" - GROUP = "group" - """:obj:`str`: A :class:`telegram.Chat` that is a group.""" - SUPERGROUP = "supergroup" - """:obj:`str`: A :class:`telegram.Chat` that is a supergroup.""" - CHANNEL = "channel" - """:obj:`str`: A :class:`telegram.Chat` that is a channel.""" - - -class ContactLimit(IntEnum): - """This enum contains limitations for :class:`telegram.InlineQueryResultContact`, - :class:`telegram.InputContactMessageContent`, and :meth:`telegram.Bot.send_contact`. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - VCARD = 2048 - """:obj:`int`: Maximum value allowed for: - - * :paramref:`~telegram.Bot.send_contact.vcard` parameter of :meth:`~telegram.Bot.send_contact` - * :paramref:`~telegram.InlineQueryResultContact.vcard` parameter of - :class:`~telegram.InlineQueryResultContact` - * :paramref:`~telegram.InputContactMessageContent.vcard` parameter of - :class:`~telegram.InputContactMessageContent` - """ - - -class CustomEmojiStickerLimit(IntEnum): - """This enum contains limitations for :meth:`telegram.Bot.get_custom_emoji_stickers`. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - CUSTOM_EMOJI_IDENTIFIER_LIMIT = 200 - """:obj:`int`: Maximum amount of custom emoji identifiers which can be specified for the - :paramref:`~telegram.Bot.get_custom_emoji_stickers.custom_emoji_ids` parameter of - :meth:`telegram.Bot.get_custom_emoji_stickers`. - """ - - -class DiceEmoji(StringEnum): - """This enum contains the available emoji for :class:`telegram.Dice`/ - :meth:`telegram.Bot.send_dice`. The enum - members of this enumeration are instances of :class:`str` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - DICE = "🎲" - """:obj:`str`: A :class:`telegram.Dice` with the emoji ``🎲``.""" - DARTS = "🎯" - """:obj:`str`: A :class:`telegram.Dice` with the emoji ``🎯``.""" - BASKETBALL = "🏀" - """:obj:`str`: A :class:`telegram.Dice` with the emoji ``🏀``.""" - FOOTBALL = "⚽" - """:obj:`str`: A :class:`telegram.Dice` with the emoji ``⚽``.""" - SLOT_MACHINE = "🎰" - """:obj:`str`: A :class:`telegram.Dice` with the emoji ``🎰``.""" - BOWLING = "🎳" - """:obj:`str`: A :class:`telegram.Dice` with the emoji ``🎳``.""" - - -class DiceLimit(IntEnum): - """This enum contains limitations for :class:`telegram.Dice`. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MIN_VALUE = 1 - """:obj:`int`: Minimum value allowed for :paramref:`~telegram.Dice.value` parameter of - :class:`telegram.Dice` (any emoji). - """ - - MAX_VALUE_BASKETBALL = 5 - """:obj:`int`: Maximum value allowed for :paramref:`~telegram.Dice.value` parameter of - :class:`telegram.Dice` if :paramref:`~telegram.Dice.emoji` is - :tg-const:`telegram.constants.DiceEmoji.BASKETBALL`. - """ - MAX_VALUE_BOWLING = 6 - """:obj:`int`: Maximum value allowed for :paramref:`~telegram.Dice.value` parameter of - :class:`telegram.Dice` if :paramref:`~telegram.Dice.emoji` is - :tg-const:`telegram.constants.DiceEmoji.BOWLING`. - """ - MAX_VALUE_DARTS = 6 - """:obj:`int`: Maximum value allowed for :paramref:`~telegram.Dice.value` parameter of - :class:`telegram.Dice` if :paramref:`~telegram.Dice.emoji` is - :tg-const:`telegram.constants.DiceEmoji.DARTS`. - """ - MAX_VALUE_DICE = 6 - """:obj:`int`: Maximum value allowed for :paramref:`~telegram.Dice.value` parameter of - :class:`telegram.Dice` if :paramref:`~telegram.Dice.emoji` is - :tg-const:`telegram.constants.DiceEmoji.DICE`. - """ - MAX_VALUE_FOOTBALL = 5 - """:obj:`int`: Maximum value allowed for :paramref:`~telegram.Dice.value` parameter of - :class:`telegram.Dice` if :paramref:`~telegram.Dice.emoji` is - :tg-const:`telegram.constants.DiceEmoji.FOOTBALL`. - """ - MAX_VALUE_SLOT_MACHINE = 64 - """:obj:`int`: Maximum value allowed for :paramref:`~telegram.Dice.value` parameter of - :class:`telegram.Dice` if :paramref:`~telegram.Dice.emoji` is - :tg-const:`telegram.constants.DiceEmoji.SLOT_MACHINE`. - """ - - -class FileSizeLimit(IntEnum): - """This enum contains limitations regarding the upload and download of files. The enum - members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - FILESIZE_DOWNLOAD = int(20e6) # (20MB) - """:obj:`int`: Bots can download files of up to 20MB in size.""" - FILESIZE_UPLOAD = int(50e6) # (50MB) - """:obj:`int`: Bots can upload non-photo files of up to 50MB in size.""" - FILESIZE_UPLOAD_LOCAL_MODE = int(2e9) # (2000MB) - """:obj:`int`: Bots can upload non-photo files of up to 2000MB in size when using a local bot - API server. - """ - FILESIZE_DOWNLOAD_LOCAL_MODE = sys.maxsize - """:obj:`int`: Bots can download files without a size limit when using a local bot API server. - """ - PHOTOSIZE_UPLOAD = int(10e6) # (10MB) - """:obj:`int`: Bots can upload photo files of up to 10MB in size.""" - VOICE_NOTE_FILE_SIZE = int(1e6) # (1MB) - """:obj:`int`: File size limit for the :meth:`~telegram.Bot.send_voice` method of - :class:`telegram.Bot`. Bots can send :mimetype:`audio/ogg` files of up to 1MB in size as - a voice note. Larger voice notes (up to 20MB) will be sent as files.""" - # It seems OK to link 20MB limit to FILESIZE_DOWNLOAD rather than creating a new constant - - -class FloodLimit(IntEnum): - """This enum contains limitations regarding flood limits. The enum - members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MESSAGES_PER_SECOND_PER_CHAT = 1 - """:obj:`int`: The number of messages that can be sent per second in a particular chat. - Telegram may allow short bursts that go over this limit, but eventually you'll begin - receiving 429 errors. - """ - MESSAGES_PER_SECOND = 30 - """:obj:`int`: The number of messages that can roughly be sent in an interval of 30 seconds - across all chats. - """ - MESSAGES_PER_MINUTE_PER_GROUP = 20 - """:obj:`int`: The number of messages that can roughly be sent to a particular group within one - minute. - """ - - -class ForumIconColor(IntEnum): - """This enum contains the available colors for use in - :paramref:`telegram.Bot.create_forum_topic.icon_color`. The enum members of this enumeration - are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - BLUE = 0x6FB9F0 - """:obj:`int`: An icon with a color which corresponds to blue (``0x6FB9F0``). - - .. raw:: html - -
- - """ - YELLOW = 0xFFD67E - """:obj:`int`: An icon with a color which corresponds to yellow (``0xFFD67E``). - - .. raw:: html - -
- - """ - PURPLE = 0xCB86DB - """:obj:`int`: An icon with a color which corresponds to purple (``0xCB86DB``). - - .. raw:: html - -
- - """ - GREEN = 0x8EEE98 - """:obj:`int`: An icon with a color which corresponds to green (``0x8EEE98``). - - .. raw:: html - -
- - """ - PINK = 0xFF93B2 - """:obj:`int`: An icon with a color which corresponds to pink (``0xFF93B2``). - - .. raw:: html - -
- - """ - RED = 0xFB6F5F - """:obj:`int`: An icon with a color which corresponds to red (``0xFB6F5F``). - - .. raw:: html - -
- - """ - - -class InlineKeyboardButtonLimit(IntEnum): - """This enum contains limitations for :class:`telegram.InlineKeyboardButton`. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MIN_CALLBACK_DATA = 1 - """:obj:`int`: Minimum value allowed for - :paramref:`~telegram.InlineKeyboardButton.callback_data` parameter of - :class:`telegram.InlineKeyboardButton` - """ - MAX_CALLBACK_DATA = 64 - """:obj:`int`: Maximum value allowed for - :paramref:`~telegram.InlineKeyboardButton.callback_data` parameter of - :class:`telegram.InlineKeyboardButton` - """ - - -class InlineKeyboardMarkupLimit(IntEnum): - """This enum contains limitations for :class:`telegram.InlineKeyboardMarkup`/ - :meth:`telegram.Bot.send_message` & friends. The enum - members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - TOTAL_BUTTON_NUMBER = 100 - """:obj:`int`: Maximum number of buttons that can be attached to a message. - - Note: - This value is undocumented and might be changed by Telegram. - """ - BUTTONS_PER_ROW = 8 - """:obj:`int`: Maximum number of buttons that can be attached to a message per row. - - Note: - This value is undocumented and might be changed by Telegram. - """ - - -class InputMediaType(StringEnum): - """This enum contains the available types of :class:`telegram.InputMedia`. The enum - members of this enumeration are instances of :class:`str` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - ANIMATION = "animation" - """:obj:`str`: Type of :class:`telegram.InputMediaAnimation`.""" - DOCUMENT = "document" - """:obj:`str`: Type of :class:`telegram.InputMediaDocument`.""" - AUDIO = "audio" - """:obj:`str`: Type of :class:`telegram.InputMediaAudio`.""" - 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 - of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - RESULTS = 50 - """:obj:`int`: Maximum number of results that can be passed to - :meth:`telegram.Bot.answer_inline_query`.""" - MAX_OFFSET_LENGTH = 64 - """:obj:`int`: Maximum number of bytes in a :obj:`str` passed as the - :paramref:`~telegram.Bot.answer_inline_query.next_offset` parameter of - :meth:`telegram.Bot.answer_inline_query`.""" - MAX_QUERY_LENGTH = 256 - """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the - :paramref:`~telegram.InlineQuery.query` parameter of :class:`telegram.InlineQuery`.""" - MIN_SWITCH_PM_TEXT_LENGTH = 1 - """:obj:`int`: Minimum number of characters in a :obj:`str` passed as the - :paramref:`~telegram.Bot.answer_inline_query.switch_pm_parameter` parameter of - :meth:`telegram.Bot.answer_inline_query`. - - .. deprecated:: 20.3 - Deprecated in favor of :attr:`InlineQueryResultsButtonLimit.MIN_START_PARAMETER_LENGTH`. - """ - MAX_SWITCH_PM_TEXT_LENGTH = 64 - """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the - :paramref:`~telegram.Bot.answer_inline_query.switch_pm_parameter` parameter of - :meth:`telegram.Bot.answer_inline_query`. - - .. deprecated:: 20.3 - Deprecated in favor of :attr:`InlineQueryResultsButtonLimit.MAX_START_PARAMETER_LENGTH`. - """ - - -class InlineQueryResultLimit(IntEnum): - """This enum contains limitations for :class:`telegram.InlineQueryResult` and its subclasses. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MIN_ID_LENGTH = 1 - """:obj:`int`: Minimum number of bytes in a :obj:`str` passed as the - :paramref:`~telegram.InlineQueryResult.id` parameter of - :class:`telegram.InlineQueryResult` and its subclasses - """ - MAX_ID_LENGTH = 64 - """:obj:`int`: Maximum number of bytes in a :obj:`str` passed as the - :paramref:`~telegram.InlineQueryResult.id` parameter of - :class:`telegram.InlineQueryResult` and its subclasses - """ - - -class InlineQueryResultsButtonLimit(IntEnum): - """This enum contains limitations for :class:`telegram.InlineQueryResultsButton`. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.3 - """ - - __slots__ = () - - MIN_START_PARAMETER_LENGTH = InlineQueryLimit.MIN_SWITCH_PM_TEXT_LENGTH - """:obj:`int`: Minimum number of characters in a :obj:`str` passed as the - :paramref:`~telegram.InlineQueryResultsButton.start_parameter` parameter of - :meth:`telegram.InlineQueryResultsButton`.""" - - MAX_START_PARAMETER_LENGTH = InlineQueryLimit.MAX_SWITCH_PM_TEXT_LENGTH - """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the - :paramref:`~telegram.InlineQueryResultsButton.start_parameter` parameter of - :meth:`telegram.InlineQueryResultsButton`.""" - - -class InlineQueryResultType(StringEnum): - """This enum contains the available types of :class:`telegram.InlineQueryResult`. The enum - members of this enumeration are instances of :class:`str` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - AUDIO = "audio" - """:obj:`str`: Type of :class:`telegram.InlineQueryResultAudio` and - :class:`telegram.InlineQueryResultCachedAudio`. - """ - DOCUMENT = "document" - """:obj:`str`: Type of :class:`telegram.InlineQueryResultDocument` and - :class:`telegram.InlineQueryResultCachedDocument`. - """ - GIF = "gif" - """:obj:`str`: Type of :class:`telegram.InlineQueryResultGif` and - :class:`telegram.InlineQueryResultCachedGif`. - """ - MPEG4GIF = "mpeg4_gif" - """:obj:`str`: Type of :class:`telegram.InlineQueryResultMpeg4Gif` and - :class:`telegram.InlineQueryResultCachedMpeg4Gif`. - """ - PHOTO = "photo" - """:obj:`str`: Type of :class:`telegram.InlineQueryResultPhoto` and - :class:`telegram.InlineQueryResultCachedPhoto`. - """ - STICKER = "sticker" - """:obj:`str`: Type of and :class:`telegram.InlineQueryResultCachedSticker`.""" - VIDEO = "video" - """:obj:`str`: Type of :class:`telegram.InlineQueryResultVideo` and - :class:`telegram.InlineQueryResultCachedVideo`. - """ - VOICE = "voice" - """:obj:`str`: Type of :class:`telegram.InlineQueryResultVoice` and - :class:`telegram.InlineQueryResultCachedVoice`. - """ - ARTICLE = "article" - """:obj:`str`: Type of :class:`telegram.InlineQueryResultArticle`.""" - CONTACT = "contact" - """:obj:`str`: Type of :class:`telegram.InlineQueryResultContact`.""" - GAME = "game" - """:obj:`str`: Type of :class:`telegram.InlineQueryResultGame`.""" - LOCATION = "location" - """:obj:`str`: Type of :class:`telegram.InlineQueryResultLocation`.""" - VENUE = "venue" - """:obj:`str`: Type of :class:`telegram.InlineQueryResultVenue`.""" - - -class LocationLimit(IntEnum): - """This enum contains limitations for - :class:`telegram.Location`/:class:`telegram.ChatLocation`/ - :meth:`telegram.Bot.edit_message_live_location`/:meth:`telegram.Bot.send_location`. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MIN_CHAT_LOCATION_ADDRESS = 1 - """:obj:`int`: Minimum value allowed for :paramref:`~telegram.ChatLocation.address` parameter - of :class:`telegram.ChatLocation` - """ - MAX_CHAT_LOCATION_ADDRESS = 64 - """:obj:`int`: Minimum value allowed for :paramref:`~telegram.ChatLocation.address` parameter - of :class:`telegram.ChatLocation` - """ - - HORIZONTAL_ACCURACY = 1500 - """:obj:`int`: Maximum value allowed for: - - * :paramref:`~telegram.Location.horizontal_accuracy` parameter of :class:`telegram.Location` - * :paramref:`~telegram.InlineQueryResultLocation.horizontal_accuracy` parameter of - :class:`telegram.InlineQueryResultLocation` - * :paramref:`~telegram.InputLocationMessageContent.horizontal_accuracy` parameter of - :class:`telegram.InputLocationMessageContent` - * :paramref:`~telegram.Bot.edit_message_live_location.horizontal_accuracy` parameter of - :meth:`telegram.Bot.edit_message_live_location` - * :paramref:`~telegram.Bot.send_location.horizontal_accuracy` parameter of - :meth:`telegram.Bot.send_location` - """ - - MIN_HEADING = 1 - """:obj:`int`: Minimum value allowed for: - - * :paramref:`~telegram.Location.heading` parameter of :class:`telegram.Location` - * :paramref:`~telegram.InlineQueryResultLocation.heading` parameter of - :class:`telegram.InlineQueryResultLocation` - * :paramref:`~telegram.InputLocationMessageContent.heading` parameter of - :class:`telegram.InputLocationMessageContent` - * :paramref:`~telegram.Bot.edit_message_live_location.heading` parameter of - :meth:`telegram.Bot.edit_message_live_location` - * :paramref:`~telegram.Bot.send_location.heading` parameter of - :meth:`telegram.Bot.send_location` - """ - MAX_HEADING = 360 - """:obj:`int`: Maximum value allowed for: - - * :paramref:`~telegram.Location.heading` parameter of :class:`telegram.Location` - * :paramref:`~telegram.InlineQueryResultLocation.heading` parameter of - :class:`telegram.InlineQueryResultLocation` - * :paramref:`~telegram.InputLocationMessageContent.heading` parameter of - :class:`telegram.InputLocationMessageContent` - * :paramref:`~telegram.Bot.edit_message_live_location.heading` parameter of - :meth:`telegram.Bot.edit_message_live_location` - * :paramref:`~telegram.Bot.send_location.heading` parameter of - :meth:`telegram.Bot.send_location` - """ - - MIN_LIVE_PERIOD = 60 - """:obj:`int`: Minimum value allowed for: - - * :paramref:`~telegram.InlineQueryResultLocation.live_period` parameter of - :class:`telegram.InlineQueryResultLocation` - * :paramref:`~telegram.InputLocationMessageContent.live_period` parameter of - :class:`telegram.InputLocationMessageContent` - * :paramref:`~telegram.Bot.edit_message_live_location.live_period` parameter of - :meth:`telegram.Bot.edit_message_live_location` - * :paramref:`~telegram.Bot.send_location.live_period` parameter of - :meth:`telegram.Bot.send_location` - """ - MAX_LIVE_PERIOD = 86400 - """:obj:`int`: Maximum value allowed for: - - * :paramref:`~telegram.InlineQueryResultLocation.live_period` parameter of - :class:`telegram.InlineQueryResultLocation` - * :paramref:`~telegram.InputLocationMessageContent.live_period` parameter of - :class:`telegram.InputLocationMessageContent` - * :paramref:`~telegram.Bot.edit_message_live_location.live_period` parameter of - :meth:`telegram.Bot.edit_message_live_location` - * :paramref:`~telegram.Bot.send_location.live_period` parameter of - :meth:`telegram.Bot.send_location` - """ - - MIN_PROXIMITY_ALERT_RADIUS = 1 - """:obj:`int`: Minimum value allowed for: - - * :paramref:`~telegram.InlineQueryResultLocation.proximity_alert_radius` parameter of - :class:`telegram.InlineQueryResultLocation` - * :paramref:`~telegram.InputLocationMessageContent.proximity_alert_radius` parameter of - :class:`telegram.InputLocationMessageContent` - * :paramref:`~telegram.Bot.edit_message_live_location.proximity_alert_radius` parameter of - :meth:`telegram.Bot.edit_message_live_location` - * :paramref:`~telegram.Bot.send_location.proximity_alert_radius` parameter of - :meth:`telegram.Bot.send_location` - """ - MAX_PROXIMITY_ALERT_RADIUS = 100000 - """:obj:`int`: Maximum value allowed for: - - * :paramref:`~telegram.InlineQueryResultLocation.proximity_alert_radius` parameter of - :class:`telegram.InlineQueryResultLocation` - * :paramref:`~telegram.InputLocationMessageContent.proximity_alert_radius` parameter of - :class:`telegram.InputLocationMessageContent` - * :paramref:`~telegram.Bot.edit_message_live_location.proximity_alert_radius` parameter of - :meth:`telegram.Bot.edit_message_live_location` - * :paramref:`~telegram.Bot.send_location.proximity_alert_radius` parameter of - :meth:`telegram.Bot.send_location` - """ - - -class MaskPosition(StringEnum): - """This enum contains the available positions for :class:`telegram.MaskPosition`. The enum - members of this enumeration are instances of :class:`str` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - FOREHEAD = "forehead" - """:obj:`str`: Mask position for a sticker on the forehead.""" - EYES = "eyes" - """:obj:`str`: Mask position for a sticker on the eyes.""" - MOUTH = "mouth" - """:obj:`str`: Mask position for a sticker on the mouth.""" - CHIN = "chin" - """:obj:`str`: Mask position for a sticker on the chin.""" - - -class MediaGroupLimit(IntEnum): - """This enum contains limitations for :meth:`telegram.Bot.send_media_group`. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MIN_MEDIA_LENGTH = 2 - """:obj:`int`: Minimum length of a :obj:`list` passed as the - :paramref:`~telegram.Bot.send_media_group.media` parameter of - :meth:`telegram.Bot.send_media_group`. - """ - MAX_MEDIA_LENGTH = 10 - """:obj:`int`: Maximum length of a :obj:`list` passed as the - :paramref:`~telegram.Bot.send_media_group.media` parameter of - :meth:`telegram.Bot.send_media_group`. - """ - - -class MenuButtonType(StringEnum): - """This enum contains the available types of :class:`telegram.MenuButton`. The enum - members of this enumeration are instances of :class:`str` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - COMMANDS = "commands" - """:obj:`str`: The type of :class:`telegram.MenuButtonCommands`.""" - WEB_APP = "web_app" - """:obj:`str`: The type of :class:`telegram.MenuButtonWebApp`.""" - DEFAULT = "default" - """:obj:`str`: The type of :class:`telegram.MenuButtonDefault`.""" - - -class MessageAttachmentType(StringEnum): - """This enum contains the available types of :class:`telegram.Message` that can be seen - as attachment. The enum - members of this enumeration are instances of :class:`str` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - # Make sure that all constants here are also listed in the MessageType Enum! - # (Enums are not extendable) - - ANIMATION = "animation" - """:obj:`str`: Messages with :attr:`telegram.Message.animation`.""" - AUDIO = "audio" - """:obj:`str`: Messages with :attr:`telegram.Message.audio`.""" - CONTACT = "contact" - """:obj:`str`: Messages with :attr:`telegram.Message.contact`.""" - DICE = "dice" - """:obj:`str`: Messages with :attr:`telegram.Message.dice`.""" - DOCUMENT = "document" - """:obj:`str`: Messages with :attr:`telegram.Message.document`.""" - GAME = "game" - """:obj:`str`: Messages with :attr:`telegram.Message.game`.""" - INVOICE = "invoice" - """:obj:`str`: Messages with :attr:`telegram.Message.invoice`.""" - LOCATION = "location" - """:obj:`str`: Messages with :attr:`telegram.Message.location`.""" - PASSPORT_DATA = "passport_data" - """:obj:`str`: Messages with :attr:`telegram.Message.passport_data`.""" - PHOTO = "photo" - """:obj:`str`: Messages with :attr:`telegram.Message.photo`.""" - POLL = "poll" - """:obj:`str`: Messages with :attr:`telegram.Message.poll`.""" - STICKER = "sticker" - """:obj:`str`: Messages with :attr:`telegram.Message.sticker`.""" - SUCCESSFUL_PAYMENT = "successful_payment" - """:obj:`str`: Messages with :attr:`telegram.Message.successful_payment`.""" - VIDEO = "video" - """:obj:`str`: Messages with :attr:`telegram.Message.video`.""" - VIDEO_NOTE = "video_note" - """:obj:`str`: Messages with :attr:`telegram.Message.video_note`.""" - VOICE = "voice" - """:obj:`str`: Messages with :attr:`telegram.Message.voice`.""" - VENUE = "venue" - """:obj:`str`: Messages with :attr:`telegram.Message.venue`.""" - - -class MessageEntityType(StringEnum): - """This enum contains the available types of :class:`telegram.MessageEntity`. The enum - members of this enumeration are instances of :class:`str` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MENTION = "mention" - """:obj:`str`: Message entities representing a mention.""" - HASHTAG = "hashtag" - """:obj:`str`: Message entities representing a hashtag.""" - CASHTAG = "cashtag" - """:obj:`str`: Message entities representing a cashtag.""" - PHONE_NUMBER = "phone_number" - """:obj:`str`: Message entities representing a phone number.""" - BOT_COMMAND = "bot_command" - """:obj:`str`: Message entities representing a bot command.""" - URL = "url" - """:obj:`str`: Message entities representing a url.""" - EMAIL = "email" - """:obj:`str`: Message entities representing a email.""" - BOLD = "bold" - """:obj:`str`: Message entities representing bold text.""" - ITALIC = "italic" - """:obj:`str`: Message entities representing italic text.""" - CODE = "code" - """:obj:`str`: Message entities representing monowidth string.""" - PRE = "pre" - """:obj:`str`: Message entities representing monowidth block.""" - TEXT_LINK = "text_link" - """:obj:`str`: Message entities representing clickable text URLs.""" - TEXT_MENTION = "text_mention" - """:obj:`str`: Message entities representing text mention for users without usernames.""" - UNDERLINE = "underline" - """:obj:`str`: Message entities representing underline text.""" - STRIKETHROUGH = "strikethrough" - """:obj:`str`: Message entities representing strikethrough text.""" - SPOILER = "spoiler" - """:obj:`str`: Message entities representing spoiler text.""" - CUSTOM_EMOJI = "custom_emoji" - """:obj:`str`: Message entities representing inline custom emoji stickers. - - .. versionadded:: 20.0 - """ - - -class MessageLimit(IntEnum): - """This enum contains limitations for :class:`telegram.Message`/ - :class:`telegram.InputTextMessageContent`/ - :meth:`telegram.Bot.send_message` & friends. The enum - members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - # TODO add links to params? - MAX_TEXT_LENGTH = 4096 - """:obj:`int`: Maximum number of characters in a :obj:`str` passed as: - - * :paramref:`~telegram.Game.text` parameter of :class:`telegram.Game` - * :paramref:`~telegram.Message.text` parameter of :class:`telegram.Message` - * :paramref:`~telegram.InputTextMessageContent.message_text` parameter of - :class:`telegram.InputTextMessageContent` - * :paramref:`~telegram.Bot.send_message.text` parameter of :meth:`telegram.Bot.send_message` - * :paramref:`~telegram.Bot.edit_message_text.text` parameter of - :meth:`telegram.Bot.edit_message_text` - """ - CAPTION_LENGTH = 1024 - """:obj:`int`: Maximum number of characters in a :obj:`str` passed as: - - * :paramref:`~telegram.Message.caption` parameter of :class:`telegram.Message` - * :paramref:`~telegram.InputMedia.caption` parameter of :class:`telegram.InputMedia` - and its subclasses - * ``caption`` parameter of subclasses of :class:`telegram.InlineQueryResult` - * ``caption`` parameter of :meth:`telegram.Bot.send_photo`, :meth:`telegram.Bot.send_audio`, - :meth:`telegram.Bot.send_document`, :meth:`telegram.Bot.send_video`, - :meth:`telegram.Bot.send_animation`, :meth:`telegram.Bot.send_voice`, - :meth:`telegram.Bot.edit_message_caption`, :meth:`telegram.Bot.copy_message` - """ - # constants above this line are tested - MIN_TEXT_LENGTH = 1 - """:obj:`int`: Minimum number of characters in a :obj:`str` passed as the - :paramref:`~telegram.InputTextMessageContent.message_text` parameter of - :class:`telegram.InputTextMessageContent` and the - :paramref:`~telegram.Bot.edit_message_text.text` parameter of - :meth:`telegram.Bot.edit_message_text`. - """ - # TODO this constant is not used. helpers.py contains 64 as a number - DEEP_LINK_LENGTH = 64 - """:obj:`int`: Maximum number of characters for a deep link.""" - # TODO this constant is not used anywhere - MESSAGE_ENTITIES = 100 - """:obj:`int`: Maximum number of entities that can be displayed in a message. Further entities - will simply be ignored by Telegram. - - Note: - This value is undocumented and might be changed by Telegram. - """ - - -class MessageType(StringEnum): - """This enum contains the available types of :class:`telegram.Message` that can be seen - as attachment. The enum - members of this enumeration are instances of :class:`str` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - # Make sure that all attachment type constants are also listed in the - # MessageAttachmentType Enum! (Enums are not extendable) - - # -------------------------------------------------- Attachment types - ANIMATION = "animation" - """:obj:`str`: Messages with :attr:`telegram.Message.animation`.""" - AUDIO = "audio" - """:obj:`str`: Messages with :attr:`telegram.Message.audio`.""" - CONTACT = "contact" - """:obj:`str`: Messages with :attr:`telegram.Message.contact`.""" - DICE = "dice" - """:obj:`str`: Messages with :attr:`telegram.Message.dice`.""" - DOCUMENT = "document" - """:obj:`str`: Messages with :attr:`telegram.Message.document`.""" - GAME = "game" - """:obj:`str`: Messages with :attr:`telegram.Message.game`.""" - INVOICE = "invoice" - """:obj:`str`: Messages with :attr:`telegram.Message.invoice`.""" - LOCATION = "location" - """:obj:`str`: Messages with :attr:`telegram.Message.location`.""" - PASSPORT_DATA = "passport_data" - """:obj:`str`: Messages with :attr:`telegram.Message.passport_data`.""" - PHOTO = "photo" - """:obj:`str`: Messages with :attr:`telegram.Message.photo`.""" - POLL = "poll" - """:obj:`str`: Messages with :attr:`telegram.Message.poll`.""" - STICKER = "sticker" - """:obj:`str`: Messages with :attr:`telegram.Message.sticker`.""" - SUCCESSFUL_PAYMENT = "successful_payment" - """:obj:`str`: Messages with :attr:`telegram.Message.successful_payment`.""" - VIDEO = "video" - """:obj:`str`: Messages with :attr:`telegram.Message.video`.""" - VIDEO_NOTE = "video_note" - """:obj:`str`: Messages with :attr:`telegram.Message.video_note`.""" - VOICE = "voice" - """:obj:`str`: Messages with :attr:`telegram.Message.voice`.""" - VENUE = "venue" - """:obj:`str`: Messages with :attr:`telegram.Message.venue`.""" - # -------------------------------------------------- Other types - TEXT = "text" - """:obj:`str`: Messages with :attr:`telegram.Message.text`.""" - NEW_CHAT_MEMBERS = "new_chat_members" - """:obj:`str`: Messages with :attr:`telegram.Message.new_chat_members`.""" - LEFT_CHAT_MEMBER = "left_chat_member" - """:obj:`str`: Messages with :attr:`telegram.Message.left_chat_member`.""" - NEW_CHAT_TITLE = "new_chat_title" - """: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`.""" - DELETE_CHAT_PHOTO = "delete_chat_photo" - """:obj:`str`: Messages with :attr:`telegram.Message.delete_chat_photo`.""" - GROUP_CHAT_CREATED = "group_chat_created" - """:obj:`str`: Messages with :attr:`telegram.Message.group_chat_created`.""" - SUPERGROUP_CHAT_CREATED = "supergroup_chat_created" - """:obj:`str`: Messages with :attr:`telegram.Message.supergroup_chat_created`.""" - CHANNEL_CHAT_CREATED = "channel_chat_created" - """:obj:`str`: Messages with :attr:`telegram.Message.channel_chat_created`.""" - MESSAGE_AUTO_DELETE_TIMER_CHANGED = "message_auto_delete_timer_changed" - """:obj:`str`: Messages with :attr:`telegram.Message.message_auto_delete_timer_changed`.""" - MIGRATE_TO_CHAT_ID = "migrate_to_chat_id" - """:obj:`str`: Messages with :attr:`telegram.Message.migrate_to_chat_id`.""" - MIGRATE_FROM_CHAT_ID = "migrate_from_chat_id" - """:obj:`str`: Messages with :attr:`telegram.Message.migrate_from_chat_id`.""" - PINNED_MESSAGE = "pinned_message" - """:obj:`str`: Messages with :attr:`telegram.Message.pinned_message`.""" - PROXIMITY_ALERT_TRIGGERED = "proximity_alert_triggered" - """:obj:`str`: Messages with :attr:`telegram.Message.proximity_alert_triggered`.""" - VIDEO_CHAT_SCHEDULED = "video_chat_scheduled" - """:obj:`str`: Messages with :attr:`telegram.Message.video_chat_scheduled`.""" - VIDEO_CHAT_STARTED = "video_chat_started" - """:obj:`str`: Messages with :attr:`telegram.Message.video_chat_started`.""" - VIDEO_CHAT_ENDED = "video_chat_ended" - """:obj:`str`: Messages with :attr:`telegram.Message.video_chat_ended`.""" - VIDEO_CHAT_PARTICIPANTS_INVITED = "video_chat_participants_invited" - """:obj:`str`: Messages with :attr:`telegram.Message.video_chat_participants_invited`.""" - - -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. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MIN_LIMIT = 1 - """:obj:`int`: Minimum value allowed for the :paramref:`~telegram.Bot.get_updates.limit` - parameter of :meth:`telegram.Bot.get_updates`. - """ - MAX_LIMIT = 100 - """:obj:`int`: Maximum value allowed for the :paramref:`~telegram.Bot.get_updates.limit` - parameter of :meth:`telegram.Bot.get_updates`. - """ - - -class ReplyLimit(IntEnum): - """This enum contains limitations for :class:`telegram.ForceReply` - and :class:`telegram.ReplyKeyboardMarkup`. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MIN_INPUT_FIELD_PLACEHOLDER = 1 - """:obj:`int`: Minimum value allowed for - :paramref:`~telegram.ForceReply.input_field_placeholder` parameter of - :class:`telegram.ForceReply` and - :paramref:`~telegram.ReplyKeyboardMarkup.input_field_placeholder` parameter of - :class:`telegram.ReplyKeyboardMarkup` - """ - MAX_INPUT_FIELD_PLACEHOLDER = 64 - """:obj:`int`: Maximum value allowed for - :paramref:`~telegram.ForceReply.input_field_placeholder` parameter of - :class:`telegram.ForceReply` and - :paramref:`~telegram.ReplyKeyboardMarkup.input_field_placeholder` parameter of - :class:`telegram.ReplyKeyboardMarkup` - """ - - -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. - - .. versionadded:: 20.2 - """ - - __slots__ = () - - STATIC = "static" - """:obj:`str`: Static sticker.""" - ANIMATED = "animated" - """:obj:`str`: Animated sticker.""" - VIDEO = "video" - """:obj:`str`: Video sticker.""" - - -class StickerLimit(IntEnum): - """This enum contains limitations for various sticker methods, such as - :meth:`telegram.Bot.create_new_sticker_set`. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MIN_NAME_AND_TITLE = 1 - """:obj:`int`: Minimum number of characters in a :obj:`str` passed as the - :paramref:`~telegram.Bot.create_new_sticker_set.name` parameter or the - :paramref:`~telegram.Bot.create_new_sticker_set.title` parameter of - :meth:`telegram.Bot.create_new_sticker_set`. - """ - MAX_NAME_AND_TITLE = 64 - """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the - :paramref:`~telegram.Bot.create_new_sticker_set.name` parameter or the - :paramref:`~telegram.Bot.create_new_sticker_set.title` parameter of - :meth:`telegram.Bot.create_new_sticker_set`. - """ - MIN_STICKER_EMOJI = 1 - """:obj:`int`: Minimum number of emojis associated with a sticker, passed as the - :paramref:`~telegram.Bot.setStickerEmojiList.emoji_list` parameter of - :meth:`telegram.Bot.set_sticker_emoji_list`. - - .. versionadded:: 20.2 - """ - MAX_STICKER_EMOJI = 20 - """:obj:`int`: Maximum number of emojis associated with a sticker, passed as the - :paramref:`~telegram.Bot.setStickerEmojiList.emoji_list` parameter of - :meth:`telegram.Bot.set_sticker_emoji_list`. - - .. versionadded:: 20.2 - """ - MAX_SEARCH_KEYWORDS = 20 - """:obj:`int`: Maximum number of search keywords for a sticker, passed as the - :paramref:`~telegram.Bot.set_sticker_keywords.keywords` parameter of - :meth:`telegram.Bot.set_sticker_keywords`. - - .. versionadded:: 20.2 - """ - MAX_KEYWORD_LENGTH = 64 - """:obj:`int`: Maximum number of characters in a search keyword for a sticker, for each item in - :paramref:`~telegram.Bot.set_sticker_keywords.keywords` sequence of - :meth:`telegram.Bot.set_sticker_keywords`. - - .. versionadded:: 20.2 - """ - - -class StickerSetLimit(IntEnum): - """This enum contains limitations for various sticker set methods, such as - :meth:`telegram.Bot.create_new_sticker_set` and :meth:`telegram.Bot.add_sticker_to_set`. - - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.2 - """ - - __slots__ = () - - MIN_INITIAL_STICKERS = 1 - """:obj:`int`: Minimum number of stickers needed to create a sticker set, passed as the - :paramref:`~telegram.Bot.create_new_sticker_set.stickers` parameter of - :meth:`telegram.Bot.create_new_sticker_set`. - """ - MAX_INITIAL_STICKERS = 50 - """:obj:`int`: Maximum number of stickers allowed while creating a sticker set, passed as the - :paramref:`~telegram.Bot.create_new_sticker_set.stickers` parameter of - :meth:`telegram.Bot.create_new_sticker_set`. - """ - MAX_EMOJI_STICKERS = 200 - """:obj:`int`: Maximum number of stickers allowed in an emoji sticker set, as given in - :meth:`telegram.Bot.add_sticker_to_set`. - """ - MAX_ANIMATED_STICKERS = 50 - """:obj:`int`: Maximum number of stickers allowed in an animated or video sticker set, as given - in :meth:`telegram.Bot.add_sticker_to_set`. - """ - MAX_STATIC_STICKERS = 120 - """:obj:`int`: Maximum number of stickers allowed in a static sticker set, as given in - :meth:`telegram.Bot.add_sticker_to_set`. - """ - MAX_STATIC_THUMBNAIL_SIZE = 128 - """:obj:`int`: Maximum size of the thumbnail if it is a **.WEBP** or **.PNG** in kilobytes, - as given in :meth:`telegram.Bot.set_sticker_set_thumbnail`.""" - MAX_ANIMATED_THUMBNAIL_SIZE = 32 - """:obj:`int`: Maximum size of the thumbnail if it is a **.TGS** or **.WEBM** in kilobytes, - as given in :meth:`telegram.Bot.set_sticker_set_thumbnail`.""" - STATIC_THUMB_DIMENSIONS = 100 - """:obj:`int`: Exact height and width of the thumbnail if it is a **.WEBP** or **.PNG** in - pixels, as given in :meth:`telegram.Bot.set_sticker_set_thumbnail`.""" - - -class StickerType(StringEnum): - """This enum contains the available types of :class:`telegram.Sticker`. The enum - members of this enumeration are instances of :class:`str` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - REGULAR = "regular" - """:obj:`str`: Regular sticker.""" - MASK = "mask" - """:obj:`str`: Mask sticker.""" - CUSTOM_EMOJI = "custom_emoji" - """:obj:`str`: Custom emoji sticker.""" - - -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. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MARKDOWN = "Markdown" - """:obj:`str`: Markdown parse mode. - - Note: - :attr:`MARKDOWN` is a legacy mode, retained by Telegram for backward compatibility. - You should use :attr:`MARKDOWN_V2` instead. - """ - MARKDOWN_V2 = "MarkdownV2" - """:obj:`str`: Markdown parse mode version 2.""" - HTML = "HTML" - """:obj:`str`: HTML parse mode.""" - - -class PollLimit(IntEnum): - """This enum contains limitations for :class:`telegram.Poll`/:class:`telegram.PollOption`/ - :meth:`telegram.Bot.send_poll`. The enum - members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MIN_QUESTION_LENGTH = 1 - """:obj:`int`: Minimum value allowed for the :paramref:`~telegram.Poll.question` - parameter of :class:`telegram.Poll` and the :paramref:`~telegram.Bot.send_poll.question` - parameter of :meth:`telegram.Bot.send_poll`. - """ - MAX_QUESTION_LENGTH = 300 - """:obj:`int`: Maximum value allowed for the :paramref:`~telegram.Poll.question` - parameter of :class:`telegram.Poll` and the :paramref:`~telegram.Bot.send_poll.question` - parameter of :meth:`telegram.Bot.send_poll`. - """ - MIN_OPTION_LENGTH = 1 - """:obj:`int`: Minimum length of each :obj:`str` passed in a :obj:`list` - to the :paramref:`~telegram.Bot.send_poll.options` parameter of - :meth:`telegram.Bot.send_poll`. - """ - MAX_OPTION_LENGTH = 100 - """:obj:`int`: Maximum length of each :obj:`str` passed in a :obj:`list` - to the :paramref:`~telegram.Bot.send_poll.options` parameter of - :meth:`telegram.Bot.send_poll`. - """ - MIN_OPTION_NUMBER = 2 - """:obj:`int`: Minimum number of strings passed in a :obj:`list` - to the :paramref:`~telegram.Bot.send_poll.options` parameter of - :meth:`telegram.Bot.send_poll`. - """ - MAX_OPTION_NUMBER = 10 - """:obj:`int`: Maximum number of strings passed in a :obj:`list` - to the :paramref:`~telegram.Bot.send_poll.options` parameter of - :meth:`telegram.Bot.send_poll`. - """ - MAX_EXPLANATION_LENGTH = 200 - """:obj:`int`: Maximum number of characters in a :obj:`str` passed as the - :paramref:`~telegram.Poll.explanation` parameter of :class:`telegram.Poll` and the - :paramref:`~telegram.Bot.send_poll.explanation` parameter of :meth:`telegram.Bot.send_poll`. - """ - MAX_EXPLANATION_LINE_FEEDS = 2 - """:obj:`int`: Maximum number of line feeds in a :obj:`str` passed as the - :paramref:`~telegram.Bot.send_poll.explanation` parameter of :meth:`telegram.Bot.send_poll` - after entities parsing. - """ - MIN_OPEN_PERIOD = 5 - """:obj:`int`: Minimum value allowed for the - :paramref:`~telegram.Bot.send_poll.open_period` parameter of :meth:`telegram.Bot.send_poll`. - Also used in the :paramref:`~telegram.Bot.send_poll.close_date` parameter of - :meth:`telegram.Bot.send_poll`. - """ - MAX_OPEN_PERIOD = 600 - """:obj:`int`: Maximum value allowed for the - :paramref:`~telegram.Bot.send_poll.open_period` parameter of :meth:`telegram.Bot.send_poll`. - Also used in the :paramref:`~telegram.Bot.send_poll.close_date` parameter of - :meth:`telegram.Bot.send_poll`. - """ - - -class PollType(StringEnum): - """This enum contains the available types for :class:`telegram.Poll`/ - :meth:`telegram.Bot.send_poll`. The enum - members of this enumeration are instances of :class:`str` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - REGULAR = "regular" - """:obj:`str`: regular polls.""" - QUIZ = "quiz" - """:obj:`str`: quiz polls.""" - - -class UpdateType(StringEnum): - """This enum contains the available types of :class:`telegram.Update`. The enum - members of this enumeration are instances of :class:`str` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MESSAGE = "message" - """:obj:`str`: Updates with :attr:`telegram.Update.message`.""" - EDITED_MESSAGE = "edited_message" - """:obj:`str`: Updates with :attr:`telegram.Update.edited_message`.""" - CHANNEL_POST = "channel_post" - """:obj:`str`: Updates with :attr:`telegram.Update.channel_post`.""" - EDITED_CHANNEL_POST = "edited_channel_post" - """:obj:`str`: Updates with :attr:`telegram.Update.edited_channel_post`.""" - INLINE_QUERY = "inline_query" - """:obj:`str`: Updates with :attr:`telegram.Update.inline_query`.""" - CHOSEN_INLINE_RESULT = "chosen_inline_result" - """:obj:`str`: Updates with :attr:`telegram.Update.chosen_inline_result`.""" - CALLBACK_QUERY = "callback_query" - """:obj:`str`: Updates with :attr:`telegram.Update.callback_query`.""" - SHIPPING_QUERY = "shipping_query" - """:obj:`str`: Updates with :attr:`telegram.Update.shipping_query`.""" - PRE_CHECKOUT_QUERY = "pre_checkout_query" - """:obj:`str`: Updates with :attr:`telegram.Update.pre_checkout_query`.""" - POLL = "poll" - """:obj:`str`: Updates with :attr:`telegram.Update.poll`.""" - POLL_ANSWER = "poll_answer" - """:obj:`str`: Updates with :attr:`telegram.Update.poll_answer`.""" - MY_CHAT_MEMBER = "my_chat_member" - """:obj:`str`: Updates with :attr:`telegram.Update.my_chat_member`.""" - CHAT_MEMBER = "chat_member" - """:obj:`str`: Updates with :attr:`telegram.Update.chat_member`.""" - CHAT_JOIN_REQUEST = "chat_join_request" - """:obj:`str`: Updates with :attr:`telegram.Update.chat_join_request`.""" - - -class InvoiceLimit(IntEnum): - """This enum contains limitations for :class:`telegram.InputInvoiceMessageContent`, - :meth:`telegram.Bot.send_invoice`, and :meth:`telegram.Bot.create_invoice_link`. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MIN_TITLE_LENGTH = 1 - """:obj:`int`: Minimum number of characters in a :obj:`str` passed as: - - * :paramref:`~telegram.InputInvoiceMessageContent.title` parameter of - :class:`telegram.InputInvoiceMessageContent` - * :paramref:`~telegram.Bot.send_invoice.title` parameter of - :meth:`telegram.Bot.send_invoice`. - * :paramref:`~telegram.Bot.create_invoice_link.title` parameter of - :meth:`telegram.Bot.create_invoice_link`. - """ - MAX_TITLE_LENGTH = 32 - """:obj:`int`: Maximum number of characters in a :obj:`str` passed as: - - * :paramref:`~telegram.InputInvoiceMessageContent.title` parameter of - :class:`telegram.InputInvoiceMessageContent` - * :paramref:`~telegram.Bot.send_invoice.title` parameter of - :meth:`telegram.Bot.send_invoice`. - * :paramref:`~telegram.Bot.create_invoice_link.title` parameter of - :meth:`telegram.Bot.create_invoice_link`. - """ - MIN_DESCRIPTION_LENGTH = 1 - """:obj:`int`: Minimum number of characters in a :obj:`str` passed as: - - * :paramref:`~telegram.InputInvoiceMessageContent.description` parameter of - :class:`telegram.InputInvoiceMessageContent` - * :paramref:`~telegram.Bot.send_invoice.description` parameter of - :meth:`telegram.Bot.send_invoice`. - * :paramref:`~telegram.Bot.create_invoice_link.description` parameter of - :meth:`telegram.Bot.create_invoice_link`. - """ - MAX_DESCRIPTION_LENGTH = 255 - """:obj:`int`: Maximum number of characters in a :obj:`str` passed as: - - * :paramref:`~telegram.InputInvoiceMessageContent.description` parameter of - :class:`telegram.InputInvoiceMessageContent` - * :paramref:`~telegram.Bot.send_invoice.description` parameter of - :meth:`telegram.Bot.send_invoice`. - * :paramref:`~telegram.Bot.create_invoice_link.description` parameter of - :meth:`telegram.Bot.create_invoice_link`. - """ - MIN_PAYLOAD_LENGTH = 1 - """:obj:`int`: Minimum amount of bytes in a :obj:`str` passed as: - - * :paramref:`~telegram.InputInvoiceMessageContent.payload` parameter of - :class:`telegram.InputInvoiceMessageContent` - * :paramref:`~telegram.Bot.send_invoice.payload` parameter of - :meth:`telegram.Bot.send_invoice`. - * :paramref:`~telegram.Bot.create_invoice_link.payload` parameter of - :meth:`telegram.Bot.create_invoice_link`. - """ - MAX_PAYLOAD_LENGTH = 128 - """:obj:`int`: Maximum amount of bytes in a :obj:`str` passed as: - - * :paramref:`~telegram.InputInvoiceMessageContent.payload` parameter of - :class:`telegram.InputInvoiceMessageContent` - * :paramref:`~telegram.Bot.send_invoice.payload` parameter of - :meth:`telegram.Bot.send_invoice`. - * :paramref:`~telegram.Bot.create_invoice_link.payload` parameter of - :meth:`telegram.Bot.create_invoice_link`. - """ - MAX_TIP_AMOUNTS = 4 - """:obj:`int`: Maximum length of a :obj:`Sequence` passed as: - - * :paramref:`~telegram.Bot.send_invoice.suggested_tip_amounts` parameter of - :meth:`telegram.Bot.send_invoice`. - * :paramref:`~telegram.Bot.create_invoice_link.suggested_tip_amounts` parameter of - :meth:`telegram.Bot.create_invoice_link`. - """ - - -class UserProfilePhotosLimit(IntEnum): - """This enum contains limitations for :paramref:`telegram.Bot.get_user_profile_photos.limit`. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MIN_LIMIT = 1 - """:obj:`int`: Minimum value allowed for - :paramref:`~telegram.Bot.get_user_profile_photos.limit` parameter of - :meth:`telegram.Bot.get_user_profile_photos`. - """ - MAX_LIMIT = 100 - """:obj:`int`: Maximum value allowed for - :paramref:`~telegram.Bot.get_user_profile_photos.limit` parameter of - :meth:`telegram.Bot.get_user_profile_photos`. - """ - - -class WebhookLimit(IntEnum): - """This enum contains limitations for :paramref:`telegram.Bot.set_webhook.max_connections` and - :paramref:`telegram.Bot.set_webhook.secret_token`. The enum members of this enumeration are - instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MIN_CONNECTIONS_LIMIT = 1 - """:obj:`int`: Minimum value allowed for the - :paramref:`~telegram.Bot.set_webhook.max_connections` parameter of - :meth:`telegram.Bot.set_webhook`. - """ - MAX_CONNECTIONS_LIMIT = 100 - """:obj:`int`: Maximum value allowed for the - :paramref:`~telegram.Bot.set_webhook.max_connections` parameter of - :meth:`telegram.Bot.set_webhook`. - """ - MIN_SECRET_TOKEN_LENGTH = 1 - """:obj:`int`: Minimum length of the secret token for the - :paramref:`~telegram.Bot.set_webhook.secret_token` parameter of - :meth:`telegram.Bot.set_webhook`. - """ - MAX_SECRET_TOKEN_LENGTH = 256 - """:obj:`int`: Maximum length of the secret token for the - :paramref:`~telegram.Bot.set_webhook.secret_token` parameter of - :meth:`telegram.Bot.set_webhook`. - """ - - -class ForumTopicLimit(IntEnum): - """This enum contains limitations for :paramref:`telegram.Bot.create_forum_topic.name` and - :paramref:`telegram.Bot.edit_forum_topic.name`. - The enum members of this enumeration are instances of :class:`int` and can be treated as such. - - .. versionadded:: 20.0 - """ - - __slots__ = () - - MIN_NAME_LENGTH = 1 - """:obj:`int`: Minimum length of a :obj:`str` passed as: - - * :paramref:`~telegram.Bot.create_forum_topic.name` parameter of - :meth:`telegram.Bot.create_forum_topic` - * :paramref:`~telegram.Bot.edit_forum_topic.name` parameter of - :meth:`telegram.Bot.edit_forum_topic` - * :paramref:`~telegram.Bot.edit_general_forum_topic.name` parameter of - :meth:`telegram.Bot.edit_general_forum_topic` - """ - MAX_NAME_LENGTH = 128 - """:obj:`int`: Maximum length of a :obj:`str` passed as: - - * :paramref:`~telegram.Bot.create_forum_topic.name` parameter of - :meth:`telegram.Bot.create_forum_topic` - * :paramref:`~telegram.Bot.edit_forum_topic.name` parameter of - :meth:`telegram.Bot.edit_forum_topic` - * :paramref:`~telegram.Bot.edit_general_forum_topic.name` parameter of - :meth:`telegram.Bot.edit_general_forum_topic` - """ diff --git a/telegram/ext/_defaults.py b/telegram/ext/_defaults.py deleted file mode 100644 index 7117fd1851b..00000000000 --- a/telegram/ext/_defaults.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env python -# -# A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 -# 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 the class Defaults, which allows passing default values to Application.""" -import datetime -from typing import Any, Dict, NoReturn, Optional - -from telegram._utils.datetime import UTC - - -class Defaults: - """Convenience Class to gather all parameters with a (user defined) default value - - .. seealso:: :wiki:`Architecture Overview `, - :wiki:`Adding Defaults to Your Bot ` - - .. versionchanged:: 20.0 - Removed the argument and attribute ``timeout``. Specify default timeout behavior for the - networking backend directly via :class:`telegram.ext.ApplicationBuilder` instead. - - Parameters: - parse_mode (:obj:`str`, optional): |parse_mode| - disable_notification (:obj:`bool`, optional): |disable_notification| - disable_web_page_preview (:obj:`bool`, optional): Disables link previews for links in this - message. - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| - quote (:obj:`bool`, optional): If set to :obj:`True`, the reply is sent as an actual reply - to the message. If ``reply_to_message_id`` is passed, this parameter will - be ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - tzinfo (:class:`datetime.tzinfo`, optional): A timezone to be used for all date(time) - inputs appearing throughout PTB, i.e. if a timezone naive date(time) object is passed - somewhere, it will be assumed to be in :paramref:`tzinfo`. If the - :class:`telegram.ext.JobQueue` is used, this must be a timezone provided - by the ``pytz`` module. Defaults to ``pytz.utc``, if available, and - :attr:`datetime.timezone.utc` otherwise. - block (:obj:`bool`, optional): Default setting for the :paramref:`BaseHandler.block` - parameter - of handlers and error handlers registered through :meth:`Application.add_handler` and - :meth:`Application.add_error_handler`. Defaults to :obj:`True`. - protect_content (:obj:`bool`, optional): |protect_content| - - .. versionadded:: 20.0 - """ - - __slots__ = ( - "_tzinfo", - "_disable_web_page_preview", - "_block", - "_quote", - "_disable_notification", - "_allow_sending_without_reply", - "_parse_mode", - "_api_defaults", - "_protect_content", - ) - - def __init__( - self, - parse_mode: Optional[str] = None, - disable_notification: Optional[bool] = None, - disable_web_page_preview: Optional[bool] = None, - quote: Optional[bool] = None, - tzinfo: datetime.tzinfo = UTC, - block: bool = True, - allow_sending_without_reply: Optional[bool] = None, - protect_content: Optional[bool] = None, - ): - self._parse_mode: Optional[str] = parse_mode - self._disable_notification: Optional[bool] = disable_notification - self._disable_web_page_preview: Optional[bool] = disable_web_page_preview - self._allow_sending_without_reply: Optional[bool] = allow_sending_without_reply - self._quote: Optional[bool] = quote - self._tzinfo: datetime.tzinfo = tzinfo - self._block: bool = block - self._protect_content: Optional[bool] = protect_content - - # Gather all defaults that actually have a default value - self._api_defaults = {} - for kwarg in ( - "parse_mode", - "explanation_parse_mode", - "disable_notification", - "disable_web_page_preview", - "allow_sending_without_reply", - "protect_content", - ): - value = getattr(self, kwarg) - if value is not None: - self._api_defaults[kwarg] = value - - @property - def api_defaults(self) -> Dict[str, Any]: # skip-cq: PY-D0003 - return self._api_defaults - - @property - def parse_mode(self) -> Optional[str]: - """:obj:`str`: Optional. Send Markdown or HTML, if you want Telegram apps to show - bold, italic, fixed-width text or URLs in your bot's message. - """ - return self._parse_mode - - @parse_mode.setter - def parse_mode(self, value: object) -> NoReturn: - raise AttributeError("You can not assign a new value to parse_mode after initialization.") - - @property - def explanation_parse_mode(self) -> Optional[str]: - """:obj:`str`: Optional. Alias for :attr:`parse_mode`, used for - the corresponding parameter of :meth:`telegram.Bot.send_poll`. - """ - return self._parse_mode - - @explanation_parse_mode.setter - def explanation_parse_mode(self, value: object) -> NoReturn: - raise AttributeError( - "You can not assign a new value to explanation_parse_mode after initialization." - ) - - @property - def disable_notification(self) -> Optional[bool]: - """:obj:`bool`: Optional. Sends the message silently. Users will - receive a notification with no sound. - """ - return self._disable_notification - - @disable_notification.setter - def disable_notification(self, value: object) -> NoReturn: - raise AttributeError( - "You can not assign a new value to disable_notification after initialization." - ) - - @property - def disable_web_page_preview(self) -> Optional[bool]: - """:obj:`bool`: Optional. Disables link previews for links in this - message. - """ - return self._disable_web_page_preview - - @disable_web_page_preview.setter - def disable_web_page_preview(self, value: object) -> NoReturn: - raise AttributeError( - "You can not assign a new value to disable_web_page_preview after initialization." - ) - - @property - def allow_sending_without_reply(self) -> Optional[bool]: - """:obj:`bool`: Optional. Pass :obj:`True`, if the message - should be sent even if the specified replied-to message is not found. - """ - return self._allow_sending_without_reply - - @allow_sending_without_reply.setter - def allow_sending_without_reply(self, value: object) -> NoReturn: - raise AttributeError( - "You can not assign a new value to allow_sending_without_reply after initialization." - ) - - @property - def quote(self) -> Optional[bool]: - """:obj:`bool`: Optional. If set to :obj:`True`, the reply is sent as an actual reply - to the message. If ``reply_to_message_id`` is passed, this parameter will - be ignored. Default: :obj:`True` in group chats and :obj:`False` in private chats. - """ - return self._quote - - @quote.setter - def quote(self, value: object) -> NoReturn: - raise AttributeError("You can not assign a new value to quote after initialization.") - - @property - def tzinfo(self) -> datetime.tzinfo: - """:obj:`tzinfo`: A timezone to be used for all date(time) objects appearing - throughout PTB. - """ - return self._tzinfo - - @tzinfo.setter - def tzinfo(self, value: object) -> NoReturn: - raise AttributeError("You can not assign a new value to tzinfo after initialization.") - - @property - def block(self) -> bool: - """:obj:`bool`: Optional. Default setting for the :paramref:`BaseHandler.block` parameter - of handlers and error handlers registered through :meth:`Application.add_handler` and - :meth:`Application.add_error_handler`. - """ - return self._block - - @block.setter - def block(self, value: object) -> NoReturn: - raise AttributeError("You can not assign a new value to block after initialization.") - - @property - def protect_content(self) -> Optional[bool]: - """:obj:`bool`: Optional. Protects the contents of the sent message from forwarding and - saving. - - .. versionadded:: 20.0 - """ - return self._protect_content - - @protect_content.setter - def protect_content(self, value: object) -> NoReturn: - raise AttributeError( - "You can't assign a new value to protect_content after initialization." - ) - - def __hash__(self) -> int: - return hash( - ( - self._parse_mode, - self._disable_notification, - self._disable_web_page_preview, - self._allow_sending_without_reply, - self._quote, - self._tzinfo, - self._block, - self._protect_content, - ) - ) - - def __eq__(self, other: object) -> bool: - if isinstance(other, Defaults): - return all(getattr(self, attr) == getattr(other, attr) for attr in self.__slots__) - return False - - def __ne__(self, other: object) -> bool: - return not self == other diff --git a/tests/README.rst b/tests/README.rst index 3f8c9f194e5..54792ceb561 100644 --- a/tests/README.rst +++ b/tests/README.rst @@ -4,7 +4,13 @@ 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. + +Since PTB uses a src-based layout, make sure you have installed the package in development mode before running the tests: + +.. code-block:: bash + + $ pip install -e . Running tests ============= @@ -36,7 +42,7 @@ such that tests marked with ``@pytest.mark.xdist_group("name")`` are run on the .. code-block:: bash - $ pytest -n auto --dist=loadgroup + $ pytest -n auto --dist=worksteal This will result in a significant speedup, but may cause some tests to fail. If you want to run the failed tests in isolation, you can use the ``--lf`` flag: @@ -72,7 +78,7 @@ complete and correct. To run it, export an environment variable first: $ export TEST_OFFICIAL=true -and then run ``pytest tests/test_official.py``. +and then run ``pytest tests/test_official/test_official.py``. Note: You need py 3.10+ to run this test. We also have another marker, ``@pytest.mark.dev``, which you can add to tests that you want to run selectively. Use as follows: @@ -82,17 +88,24 @@ Use as follows: $ pytest -m dev +Debugging tests +=============== + +Writing tests can be challenging, and fixing failing tests can be even more so. To help with this, +PTB has started to adopt the use of ``logging`` in the test suite. You can insert debug logging +statements in your tests to help you understand what's going on. To enable these logs, you can set +``log_level = DEBUG`` in ``pyproject.toml`` or use the ``--log-level=INFO`` flag when running the tests. +If a test is large and complicated, it is recommended to leave the debug logs for others to use as +well. + + Bots used in tests ================== If you run the tests locally, the test setup will use one of the two public bots available. Which bot of the two gets chosen for the test session is random. Whereas when the tests on the -Github Actions CI are run, the test setup allocates a different, but same bot for every combination of Python version and -OS. - -Thus, number of bots used for testing locally is 2 (called as fallback bots), and on the CI, -its [3.7, 3.8, 3.9, 3.10, 3.11] x [ubuntu-latest, macos-latest, windows-latest] = 15. Bringing the -total number of bots used for testing to 17. +Github Actions CI are run, the test setup allocates a different, but the same bot is allocated for every combination of Python version and +OS. The operating systems and Python versions the CI runs the tests on can be viewed in the `corresponding workflow`_. That's it! If you have any questions, feel free to ask them in the `PTB dev @@ -100,4 +113,5 @@ group`_. .. _pytest: https://docs.pytest.org/en/stable/ .. _pytest-xdist: https://pypi.org/project/pytest-xdist/ -.. _PTB dev group: https://t.me/pythontelegrambotgroup \ No newline at end of file +.. _PTB dev group: https://t.me/pythontelegrambotgroup +.. _corresponding workflow: https://github.com/python-telegram-bot/python-telegram-bot/blob/master/.github/workflows/unit_tests.yml diff --git a/tests/_files/__init__.py b/tests/_files/__init__.py index 1eaba12c869..c95cb3c9741 100644 --- a/tests/_files/__init__.py +++ b/tests/_files/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_files/conftest.py b/tests/_files/conftest.py new file mode 100644 index 00000000000..c83ea80ab88 --- /dev/null +++ b/tests/_files/conftest.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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/]. +"""Module to provide fixtures most of which are used in test_inputmedia.py.""" + +import pytest + +from telegram.error import BadRequest +from tests.auxil.files import data_file +from tests.auxil.networking import expect_bad_request + + +@pytest.fixture(scope="session") +async def animation(bot, chat_id): + with data_file("game.gif").open("rb") as f, data_file("thumb.jpg").open("rb") as thumb: + return ( + await bot.send_animation(chat_id, animation=f, read_timeout=50, thumbnail=thumb) + ).animation + + +@pytest.fixture +def animation_file(): + with data_file("game.gif").open("rb") as f: + yield f + + +@pytest.fixture(scope="module") +async def animated_sticker(bot, chat_id): + with data_file("telegram_animated_sticker.tgs").open("rb") as f: + return (await bot.send_sticker(chat_id, sticker=f, read_timeout=50)).sticker + + +@pytest.fixture +def animated_sticker_file(): + with data_file("telegram_animated_sticker.tgs").open("rb") as f: + yield f + + +@pytest.fixture +async def animated_sticker_set(bot): + ss = await bot.get_sticker_set(f"animated_test_by_{bot.username}") + if len(ss.stickers) > 100: + try: + for i in range(1, 50): + await bot.delete_sticker_from_set(ss.stickers[-i].file_id) + except BadRequest as e: + if e.message == "Stickerset_not_modified": + return ss + raise Exception("stickerset is growing too large.") from None + return ss + + +@pytest.fixture(scope="session") +async def audio(bot, chat_id): + with data_file("telegram.mp3").open("rb") as f, data_file("thumb.jpg").open("rb") as thumb: + return (await bot.send_audio(chat_id, audio=f, read_timeout=50, thumbnail=thumb)).audio + + +@pytest.fixture +def audio_file(): + with data_file("telegram.mp3").open("rb") as f: + yield f + + +@pytest.fixture(scope="session") +async def document(bot, chat_id): + with data_file("telegram.png").open("rb") as f: + return (await bot.send_document(chat_id, document=f, read_timeout=50)).document + + +@pytest.fixture +def document_file(): + with data_file("telegram.png").open("rb") as f: + yield f + + +@pytest.fixture(scope="session") +def photo(photolist): + return photolist[-1] + + +@pytest.fixture +def photo_file(): + with data_file("telegram.jpg").open("rb") as f: + yield f + + +@pytest.fixture(scope="session") +async def photolist(bot, chat_id): + async def func(): + with data_file("telegram.jpg").open("rb") as f: + return (await bot.send_photo(chat_id, photo=f, read_timeout=50)).photo + + return await expect_bad_request( + func, "Type of file mismatch", "Telegram did not accept the file." + ) + + +@pytest.fixture(scope="module") +async def sticker(bot, chat_id): + with data_file("telegram.webp").open("rb") as f: + sticker = (await bot.send_sticker(chat_id, sticker=f, read_timeout=50)).sticker + # necessary to properly test needs_repainting + with sticker._unfrozen(): + sticker.needs_repainting = True + return sticker + + +@pytest.fixture +def sticker_file(): + with data_file("telegram.webp").open("rb") as file: + yield file + + +@pytest.fixture +async def sticker_set(bot): + ss = await bot.get_sticker_set(f"test_by_{bot.username}") + if len(ss.stickers) > 100: + try: + for i in range(1, 50): + await bot.delete_sticker_from_set(ss.stickers[-i].file_id) + except BadRequest as e: + if e.message == "Stickerset_not_modified": + return ss + raise Exception("stickerset is growing too large.") from None + return ss + + +@pytest.fixture +def sticker_set_thumb_file(): + with data_file("sticker_set_thumb.png").open("rb") as file: + yield file + + +@pytest.fixture(scope="session") +def thumb(photolist): + return photolist[0] + + +@pytest.fixture(scope="session") +async def video(bot, chat_id): + with data_file("telegram.mp4").open("rb") as f: + return (await bot.send_video(chat_id, video=f, read_timeout=50)).video + + +@pytest.fixture +def video_file(): + with data_file("telegram.mp4").open("rb") as f: + yield f + + +@pytest.fixture +def video_sticker_file(): + with data_file("telegram_video_sticker.webm").open("rb") as f: + yield f + + +@pytest.fixture(scope="module") +def video_sticker(bot, chat_id): + with data_file("telegram_video_sticker.webm").open("rb") as f: + return bot.send_sticker(chat_id, sticker=f, timeout=50).sticker + + +@pytest.fixture +async def video_sticker_set(bot): + ss = await bot.get_sticker_set(f"video_test_by_{bot.username}") + if len(ss.stickers) > 100: + try: + for i in range(1, 50): + await bot.delete_sticker_from_set(ss.stickers[-i].file_id) + except BadRequest as e: + if e.message == "Stickerset_not_modified": + return ss + raise Exception("stickerset is growing too large.") from None + return ss diff --git a/tests/_files/test_animation.py b/tests/_files/test_animation.py index 2761f58002d..df4ae468949 100644 --- a/tests/_files/test_animation.py +++ b/tests/_files/test_animation.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,48 +17,34 @@ # 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 asyncio +import datetime as dtm import os from pathlib import Path import pytest -from telegram import Animation, Bot, InputFile, MessageEntity, PhotoSize, Voice +from telegram import Animation, Bot, InputFile, MessageEntity, PhotoSize, ReplyParameters, Voice +from telegram.constants import ParseMode from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, check_shortcut_signature, ) -from tests.auxil.deprecations import ( - check_thumb_deprecation_warning_for_method_args, - check_thumb_deprecation_warnings_for_args_and_attrs, -) +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file from tests.auxil.slots import mro_slots -@pytest.fixture() -def animation_file(): - with data_file("game.gif").open("rb") as f: - yield f - - -@pytest.fixture(scope="module") -async def animation(bot, chat_id): - with data_file("game.gif").open("rb") as f, data_file("thumb.jpg").open("rb") as thumb: - return ( - await bot.send_animation(chat_id, animation=f, read_timeout=50, thumbnail=thumb) - ).animation - - -class TestAnimationBase: +class AnimationTestBase: animation_file_id = "CgADAQADngIAAuyVeEez0xRovKi9VAI" animation_file_unique_id = "adc3145fd2e84d95b64d68eaa22aa33e" width = 320 height = 180 - duration = 1 + duration = dtm.timedelta(seconds=1) # animation_file_url = 'https://python-telegram-bot.org/static/testfiles/game.gif' # Shortened link, the above one is cached with the wrong duration. animation_file_url = "http://bit.ly/2L18jua" @@ -68,7 +54,7 @@ class TestAnimationBase: caption = "Test *animation*" -class TestAnimationWithoutRequest(TestAnimationBase): +class TestAnimationWithoutRequest(AnimationTestBase): def test_slot_behaviour(self, animation): for attr in animation.__slots__: assert getattr(animation, attr, "err") != "err", f"got extra slot '{attr}'" @@ -86,37 +72,26 @@ def test_expected_values(self, animation): assert animation.file_name.startswith("game.gif") == self.file_name.startswith("game.gif") assert isinstance(animation.thumbnail, PhotoSize) - def test_thumb_property_deprecation_warning(self, recwarn): - animation = Animation( - self.animation_file_id, - self.animation_file_unique_id, - thumb=object(), - width=self.width, - height=self.height, - duration=self.duration, - ) - assert animation.thumb is animation.thumbnail - check_thumb_deprecation_warnings_for_args_and_attrs(recwarn, __file__) - - def test_de_json(self, bot, animation): + def test_de_json(self, offline_bot, animation): json_dict = { "file_id": self.animation_file_id, "file_unique_id": self.animation_file_unique_id, "width": self.width, "height": self.height, - "duration": self.duration, + "duration": self.duration.total_seconds(), "thumbnail": animation.thumbnail.to_dict(), "file_name": self.file_name, "mime_type": self.mime_type, "file_size": self.file_size, } - animation = Animation.de_json(json_dict, bot) + animation = Animation.de_json(json_dict, offline_bot) assert animation.api_kwargs == {} assert animation.file_id == self.animation_file_id assert animation.file_unique_id == self.animation_file_unique_id assert animation.file_name == self.file_name assert animation.mime_type == self.mime_type assert animation.file_size == self.file_size + assert animation._duration == self.duration def test_to_dict(self, animation): animation_dict = animation.to_dict() @@ -126,12 +101,31 @@ def test_to_dict(self, animation): assert animation_dict["file_unique_id"] == animation.file_unique_id assert animation_dict["width"] == animation.width assert animation_dict["height"] == animation.height - assert animation_dict["duration"] == animation.duration + assert animation_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(animation_dict["duration"], int) assert animation_dict["thumbnail"] == animation.thumbnail.to_dict() assert animation_dict["file_name"] == animation.file_name assert animation_dict["mime_type"] == animation.mime_type assert animation_dict["file_size"] == animation.file_size + def test_time_period_properties(self, PTB_TIMEDELTA, animation): + if PTB_TIMEDELTA: + assert animation.duration == self.duration + assert isinstance(animation.duration, dtm.timedelta) + else: + assert animation.duration == int(self.duration.total_seconds()) + assert isinstance(animation.duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, animation): + animation.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = Animation( self.animation_file_id, @@ -154,18 +148,24 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) - async def test_send_animation_custom_filename(self, bot, chat_id, animation_file, monkeypatch): + async def test_send_animation_custom_filename( + self, offline_bot, chat_id, animation_file, monkeypatch + ): async def make_assertion(url, request_data: RequestData, *args, **kwargs): - return list(request_data.multipart_data.values())[0][0] == "custom_filename" + return next(iter(request_data.multipart_data.values()))[0] == "custom_filename" - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.send_animation(chat_id, animation_file, filename="custom_filename") + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_animation( + chat_id, animation_file, filename="custom_filename" + ) @pytest.mark.parametrize("local_mode", [True, False]) - async def test_send_animation_local_files(self, monkeypatch, bot, chat_id, local_mode): + async def test_send_animation_local_files( + self, monkeypatch, offline_bot, chat_id, local_mode, dummy_message_dict + ): try: - bot._local_mode = local_mode - # For just test that the correct paths are passed as we have no local bot API set up + offline_bot._local_mode = local_mode + # For just test that the correct paths are passed as we have no local Bot API set up test_flag = False file = data_file("telegram.jpg") expected = file.as_uri() @@ -180,41 +180,20 @@ async def make_assertion(_, data, *args, **kwargs): test_flag = isinstance(data.get("animation"), InputFile) and isinstance( data.get("thumbnail"), InputFile ) + return dummy_message_dict - monkeypatch.setattr(bot, "_post", make_assertion) - await bot.send_animation(chat_id, file, thumbnail=file) + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.send_animation(chat_id, file, thumbnail=file) assert test_flag finally: - bot._local_mode = False + offline_bot._local_mode = False - async def test_send_with_animation(self, monkeypatch, bot, chat_id, animation): + async def test_send_with_animation(self, monkeypatch, offline_bot, chat_id, animation): async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.json_parameters["animation"] == animation.file_id - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.send_animation(animation=animation, chat_id=chat_id) - - @pytest.mark.parametrize("bot_class", ["Bot", "ExtBot"]) - async def test_send_animation_thumb_deprecation_warning( - self, recwarn, monkeypatch, bot_class, bot, raw_bot, chat_id, animation - ): - async def make_assertion(url, request_data: RequestData, *args, **kwargs): - return True - - bot = raw_bot if bot_class == "Bot" else bot - - monkeypatch.setattr(bot.request, "post", make_assertion) - await bot.send_animation(chat_id, animation, thumb="thumb") - check_thumb_deprecation_warning_for_method_args(recwarn, __file__) - - async def test_send_animation_with_local_files_throws_error_with_different_thumb_and_thumbnail( - self, bot, chat_id - ): - file = data_file("telegram.jpg") - different_file = data_file("telegram_no_standard_header.jpg") - - with pytest.raises(ValueError, match="different entities as 'thumb' and 'thumbnail'"): - await bot.send_animation(chat_id, file, thumbnail=file, thumb=different_file) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_animation(animation=animation, chat_id=chat_id) async def test_get_file_instance_method(self, monkeypatch, animation): async def make_assertion(*_, **kwargs): @@ -227,13 +206,43 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(animation.get_bot(), "get_file", make_assertion) assert await animation.get_file() + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_animation_default_quote_parse_mode( + self, default_bot, chat_id, animation, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() + + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom -class TestAnimationWithRequest(TestAnimationBase): - async def test_send_all_args(self, bot, chat_id, animation_file, animation, thumb_file): + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_animation( + chat_id, animation, reply_parameters=ReplyParameters(**kwargs) + ) + + +class TestAnimationWithRequest(AnimationTestBase): + @pytest.mark.parametrize("duration", [1, dtm.timedelta(seconds=1)]) + async def test_send_all_args( + self, bot, chat_id, animation_file, animation, thumb_file, duration + ): message = await bot.send_animation( chat_id, animation_file, - duration=self.duration, + duration=duration, width=self.width, height=self.height, caption=self.caption, @@ -242,6 +251,7 @@ async def test_send_all_args(self, bot, chat_id, animation_file, animation, thum protect_content=True, thumbnail=thumb_file, has_spoiler=True, + show_caption_above_media=True, ) assert isinstance(message.animation, Animation) @@ -251,25 +261,23 @@ async def test_send_all_args(self, bot, chat_id, animation_file, animation, thum assert message.animation.file_unique_id assert message.animation.file_name == animation.file_name assert message.animation.mime_type == animation.mime_type - assert message.animation.file_size == animation.file_size + # TGs reported file size is not reliable + assert isinstance(message.animation.file_size, int) assert message.animation.thumbnail.width == self.width assert message.animation.thumbnail.height == self.height assert message.has_protected_content + assert message.show_caption_above_media try: assert message.has_media_spoiler except AssertionError: pytest.xfail("This is a bug on Telegram's end") - async def test_get_and_download(self, bot, animation): - path = Path("game.gif") - if path.is_file(): - path.unlink() - + async def test_get_and_download(self, bot, animation, tmp_file): new_file = await bot.get_file(animation.file_id) assert new_file.file_path.startswith("https://") - new_filepath = await new_file.download_to_drive("game.gif") + new_filepath = await new_file.download_to_drive(tmp_file) assert new_filepath.is_file() async def test_send_animation_url_file(self, bot, chat_id, animation): @@ -364,7 +372,7 @@ async def test_send_animation_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="message not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_animation( chat_id, animation, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_files/test_audio.py b/tests/_files/test_audio.py index 2817710ee9d..0bd9b2e6fd8 100644 --- a/tests/_files/test_audio.py +++ b/tests/_files/test_audio.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,46 +17,34 @@ # 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 asyncio +import datetime as dtm import os from pathlib import Path import pytest -from telegram import Audio, Bot, InputFile, MessageEntity, Voice -from telegram.error import TelegramError +from telegram import Audio, Bot, InputFile, MessageEntity, ReplyParameters, Voice +from telegram.constants import ParseMode +from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, check_shortcut_signature, ) -from tests.auxil.deprecations import ( - check_thumb_deprecation_warning_for_method_args, - check_thumb_deprecation_warnings_for_args_and_attrs, -) +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file from tests.auxil.slots import mro_slots -@pytest.fixture() -def audio_file(): - with data_file("telegram.mp3").open("rb") as f: - yield f - - -@pytest.fixture(scope="module") -async def audio(bot, chat_id): - with data_file("telegram.mp3").open("rb") as f, data_file("thumb.jpg").open("rb") as thumb: - return (await bot.send_audio(chat_id, audio=f, read_timeout=50, thumbnail=thumb)).audio - - -class TestAudioBase: +class AudioTestBase: caption = "Test *audio*" performer = "Leandro Toledo" title = "Teste" file_name = "telegram.mp3" - duration = 3 + duration = dtm.timedelta(seconds=3) # audio_file_url = 'https://python-telegram-bot.org/static/testfiles/telegram.mp3' # Shortened link, the above one is cached with the wrong duration. audio_file_url = "https://goo.gl/3En24v" @@ -69,7 +57,7 @@ class TestAudioBase: audio_file_unique_id = "adc3145fd2e84d95b64d68eaa22aa33e" -class TestAudioWithoutRequest(TestAudioBase): +class TestAudioWithoutRequest(AudioTestBase): def test_slot_behaviour(self, audio): for attr in audio.__slots__: assert getattr(audio, attr, "err") != "err", f"got extra slot '{attr}'" @@ -84,25 +72,20 @@ def test_creation(self, audio): assert audio.file_unique_id def test_expected_values(self, audio): - assert audio.duration == self.duration + assert audio._duration == self.duration assert audio.performer is None assert audio.title is None assert audio.mime_type == self.mime_type assert audio.file_size == self.file_size - assert audio.thumbnail.file_size == self.thumb_file_size + assert audio.thumbnail.file_size in [self.thumb_file_size, 1395] assert audio.thumbnail.width == self.thumb_width assert audio.thumbnail.height == self.thumb_height - def test_thumb_property_deprecation_warning(self, recwarn): - audio = Audio(self.audio_file_id, self.audio_file_unique_id, self.duration, thumb=object()) - assert audio.thumb is audio.thumbnail - check_thumb_deprecation_warnings_for_args_and_attrs(recwarn, __file__) - - def test_de_json(self, bot, audio): + def test_de_json(self, offline_bot, audio): json_dict = { "file_id": self.audio_file_id, "file_unique_id": self.audio_file_unique_id, - "duration": self.duration, + "duration": int(self.duration.total_seconds()), "performer": self.performer, "title": self.title, "file_name": self.file_name, @@ -110,12 +93,12 @@ def test_de_json(self, bot, audio): "file_size": self.file_size, "thumbnail": audio.thumbnail.to_dict(), } - json_audio = Audio.de_json(json_dict, bot) + json_audio = Audio.de_json(json_dict, offline_bot) assert json_audio.api_kwargs == {} assert json_audio.file_id == self.audio_file_id assert json_audio.file_unique_id == self.audio_file_unique_id - assert json_audio.duration == self.duration + assert json_audio._duration == self.duration assert json_audio.performer == self.performer assert json_audio.title == self.title assert json_audio.file_name == self.file_name @@ -129,11 +112,30 @@ def test_to_dict(self, audio): assert isinstance(audio_dict, dict) assert audio_dict["file_id"] == audio.file_id assert audio_dict["file_unique_id"] == audio.file_unique_id - assert audio_dict["duration"] == audio.duration + assert audio_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(audio_dict["duration"], int) assert audio_dict["mime_type"] == audio.mime_type assert audio_dict["file_size"] == audio.file_size assert audio_dict["file_name"] == audio.file_name + def test_time_period_properties(self, PTB_TIMEDELTA, audio): + if PTB_TIMEDELTA: + assert audio.duration == self.duration + assert isinstance(audio.duration, dtm.timedelta) + else: + assert audio.duration == int(self.duration.total_seconds()) + assert isinstance(audio.duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, audio): + audio.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self, audio): a = Audio(audio.file_id, audio.file_unique_id, audio.duration) b = Audio("", audio.file_unique_id, audio.duration) @@ -154,38 +156,27 @@ def test_equality(self, audio): assert a != e assert hash(a) != hash(e) - async def test_send_with_audio(self, monkeypatch, bot, chat_id, audio): + async def test_send_with_audio(self, monkeypatch, offline_bot, chat_id, audio): async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.json_parameters["audio"] == audio.file_id - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.send_audio(audio=audio, chat_id=chat_id) - - @pytest.mark.parametrize("bot_class", ["Bot", "ExtBot"]) - async def test_send_audio_thumb_deprecation_warning( - self, recwarn, monkeypatch, bot_class, bot, raw_bot, chat_id, audio - ): - async def make_assertion(url, request_data: RequestData, *args, **kwargs): - return True - - bot = raw_bot if bot_class == "Bot" else bot + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_audio(audio=audio, chat_id=chat_id) - monkeypatch.setattr(bot.request, "post", make_assertion) - await bot.send_audio(chat_id, audio, thumb="thumb") - check_thumb_deprecation_warning_for_method_args(recwarn, __file__) - - async def test_send_audio_custom_filename(self, bot, chat_id, audio_file, monkeypatch): + async def test_send_audio_custom_filename(self, offline_bot, chat_id, audio_file, monkeypatch): async def make_assertion(url, request_data: RequestData, *args, **kwargs): - return list(request_data.multipart_data.values())[0][0] == "custom_filename" + return next(iter(request_data.multipart_data.values()))[0] == "custom_filename" - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.send_audio(chat_id, audio_file, filename="custom_filename") + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_audio(chat_id, audio_file, filename="custom_filename") @pytest.mark.parametrize("local_mode", [True, False]) - async def test_send_audio_local_files(self, monkeypatch, bot, chat_id, local_mode): + async def test_send_audio_local_files( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): try: - bot._local_mode = local_mode - # For just test that the correct paths are passed as we have no local bot API set up + offline_bot._local_mode = local_mode + # For just test that the correct paths are passed as we have no local Bot API set up test_flag = False file = data_file("telegram.jpg") expected = file.as_uri() @@ -198,21 +189,13 @@ async def make_assertion(_, data, *args, **kwargs): test_flag = isinstance(data.get("audio"), InputFile) and isinstance( data.get("thumbnail"), InputFile ) + return dummy_message_dict - monkeypatch.setattr(bot, "_post", make_assertion) - await bot.send_audio(chat_id, file, thumbnail=file) + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.send_audio(chat_id, file, thumbnail=file) assert test_flag finally: - bot._local_mode = False - - async def test_send_audio_with_local_files_throws_error_with_different_thumb_and_thumbnail( - self, bot, chat_id - ): - file = data_file("telegram.jpg") - different_file = data_file("telegram_no_standard_header.jpg") - - with pytest.raises(ValueError, match="different entities as 'thumb' and 'thumbnail'"): - await bot.send_audio(chat_id, file, thumbnail=file, thumb=different_file) + offline_bot._local_mode = False async def test_get_file_instance_method(self, monkeypatch, audio): async def make_assertion(*_, **kwargs): @@ -225,14 +208,40 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(audio._bot, "get_file", make_assertion) assert await audio.get_file() + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_audio_default_quote_parse_mode( + self, default_bot, chat_id, audio, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() -class TestAudioWithRequest(TestAudioBase): - async def test_send_all_args(self, bot, chat_id, audio_file, thumb_file): + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_audio(chat_id, audio, reply_parameters=ReplyParameters(**kwargs)) + + +class TestAudioWithRequest(AudioTestBase): + @pytest.mark.parametrize("duration", [3, dtm.timedelta(seconds=3)]) + async def test_send_all_args(self, bot, chat_id, audio_file, thumb_file, duration): message = await bot.send_audio( chat_id, audio=audio_file, caption=self.caption, - duration=self.duration, + duration=duration, performer=self.performer, title=self.title, disable_notification=False, @@ -248,7 +257,7 @@ async def test_send_all_args(self, bot, chat_id, audio_file, thumb_file): assert isinstance(message.audio.file_unique_id, str) assert message.audio.file_unique_id is not None assert message.audio.file_id is not None - assert message.audio.duration == self.duration + assert message.audio._duration == self.duration assert message.audio.performer == self.performer assert message.audio.title == self.title assert message.audio.file_name == self.file_name @@ -259,19 +268,15 @@ async def test_send_all_args(self, bot, chat_id, audio_file, thumb_file): assert message.audio.thumbnail.height == self.thumb_height assert message.has_protected_content - async def test_get_and_download(self, bot, chat_id, audio): - path = Path("telegram.mp3") - if path.is_file(): - path.unlink() - + async def test_get_and_download(self, bot, chat_id, audio, tmp_file): new_file = await bot.get_file(audio.file_id) assert new_file.file_size == self.file_size assert new_file.file_unique_id == audio.file_unique_id assert str(new_file.file_path).startswith("https://") - await new_file.download_to_drive("telegram.mp3") - assert path.is_file() + await new_file.download_to_drive(tmp_file) + assert tmp_file.is_file() async def test_send_mp3_url_file(self, bot, chat_id, audio): message = await bot.send_audio( @@ -357,3 +362,36 @@ async def test_error_send_empty_file_id(self, bot, chat_id): async def test_error_send_without_required_args(self, bot, chat_id): with pytest.raises(TypeError): await bot.send_audio(chat_id=chat_id) + + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"allow_sending_without_reply": True}, None), + ({"allow_sending_without_reply": False}, None), + ({"allow_sending_without_reply": False}, True), + ], + indirect=["default_bot"], + ) + async def test_send_audio_default_allow_sending_without_reply( + self, default_bot, chat_id, audio, custom + ): + reply_to_message = await default_bot.send_message(chat_id, "test") + await reply_to_message.delete() + if custom is not None: + message = await default_bot.send_audio( + chat_id, + audio, + allow_sending_without_reply=custom, + reply_to_message_id=reply_to_message.message_id, + ) + assert message.reply_to_message is None + elif default_bot.defaults.allow_sending_without_reply: + message = await default_bot.send_audio( + chat_id, audio, reply_to_message_id=reply_to_message.message_id + ) + assert message.reply_to_message is None + else: + with pytest.raises(BadRequest, match="Message to be replied not found"): + await default_bot.send_audio( + chat_id, audio, reply_to_message_id=reply_to_message.message_id + ) diff --git a/tests/_files/test_chatphoto.py b/tests/_files/test_chatphoto.py index d15ac61813c..651d2ced060 100644 --- a/tests/_files/test_chatphoto.py +++ b/tests/_files/test_chatphoto.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -36,7 +36,7 @@ from tests.auxil.slots import mro_slots -@pytest.fixture() +@pytest.fixture def chatphoto_file(): with data_file("telegram.jpg").open("rb") as f: yield f @@ -52,7 +52,7 @@ async def func(): ) -class TestChatPhotoBase: +class ChatPhotoTestBase: chatphoto_small_file_id = "smallCgADAQADngIAAuyVeEez0xRovKi9VAI" chatphoto_big_file_id = "bigCgADAQADngIAAuyVeEez0xRovKi9VAI" chatphoto_small_file_unique_id = "smalladc3145fd2e84d95b64d68eaa22aa33e" @@ -60,20 +60,20 @@ class TestChatPhotoBase: chatphoto_file_url = "https://python-telegram-bot.org/static/testfiles/telegram.jpg" -class TestChatPhotoWithoutRequest(TestChatPhotoBase): +class TestChatPhotoWithoutRequest(ChatPhotoTestBase): def test_slot_behaviour(self, chat_photo): for attr in chat_photo.__slots__: assert getattr(chat_photo, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(chat_photo)) == len(set(mro_slots(chat_photo))), "duplicate slot" - def test_de_json(self, bot, chat_photo): + def test_de_json(self, offline_bot, chat_photo): json_dict = { "small_file_id": self.chatphoto_small_file_id, "big_file_id": self.chatphoto_big_file_id, "small_file_unique_id": self.chatphoto_small_file_unique_id, "big_file_unique_id": self.chatphoto_big_file_unique_id, } - chat_photo = ChatPhoto.de_json(json_dict, bot) + chat_photo = ChatPhoto.de_json(json_dict, offline_bot) assert chat_photo.api_kwargs == {} assert chat_photo.small_file_id == self.chatphoto_small_file_id assert chat_photo.big_file_id == self.chatphoto_big_file_id @@ -121,12 +121,14 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) - async def test_send_with_chat_photo(self, monkeypatch, bot, super_group_id, chat_photo): + async def test_send_with_chat_photo( + self, monkeypatch, offline_bot, super_group_id, chat_photo + ): async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.parameters["photo"] == chat_photo.to_dict() - monkeypatch.setattr(bot.request, "post", make_assertion) - message = await bot.set_chat_photo(photo=chat_photo, chat_id=super_group_id) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + message = await offline_bot.set_chat_photo(photo=chat_photo, chat_id=super_group_id) assert message async def test_get_small_file_instance_method(self, monkeypatch, chat_photo): @@ -155,11 +157,7 @@ async def make_assertion(*_, **kwargs): class TestChatPhotoWithRequest: - async def test_get_and_download(self, bot, chat_photo): - jpg_file = Path("telegram.jpg") - if jpg_file.is_file(): - jpg_file.unlink() - + async def test_get_and_download(self, bot, chat_photo, tmp_file): tasks = {bot.get_file(chat_photo.small_file_id), bot.get_file(chat_photo.big_file_id)} asserts = [] @@ -171,8 +169,8 @@ async def test_get_and_download(self, bot, chat_photo): asserts.append("big") assert file.file_path.startswith("https://") - await file.download_to_drive(jpg_file) - assert jpg_file.is_file() + await file.download_to_drive(tmp_file) + assert tmp_file.is_file() assert "small" in asserts assert "big" in asserts diff --git a/tests/_files/test_contact.py b/tests/_files/test_contact.py index d7906a9ee30..b7e282d0271 100644 --- a/tests/_files/test_contact.py +++ b/tests/_files/test_contact.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -21,51 +21,53 @@ import pytest -from telegram import Contact, Voice +from telegram import Contact, ReplyParameters, Voice +from telegram.constants import ParseMode from telegram.error import BadRequest from telegram.request import RequestData +from tests.auxil.build_messages import make_message from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def contact(): return Contact( - TestContactBase.phone_number, - TestContactBase.first_name, - TestContactBase.last_name, - TestContactBase.user_id, + ContactTestBase.phone_number, + ContactTestBase.first_name, + ContactTestBase.last_name, + ContactTestBase.user_id, ) -class TestContactBase: +class ContactTestBase: phone_number = "+11234567890" first_name = "Leandro" last_name = "Toledo" user_id = 23 -class TestContactWithoutRequest(TestContactBase): +class TestContactWithoutRequest(ContactTestBase): def test_slot_behaviour(self, contact): for attr in contact.__slots__: assert getattr(contact, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(contact)) == len(set(mro_slots(contact))), "duplicate slot" - def test_de_json_required(self, bot): + def test_de_json_required(self, offline_bot): json_dict = {"phone_number": self.phone_number, "first_name": self.first_name} - contact = Contact.de_json(json_dict, bot) + contact = Contact.de_json(json_dict, offline_bot) assert contact.api_kwargs == {} assert contact.phone_number == self.phone_number assert contact.first_name == self.first_name - def test_de_json_all(self, bot): + def test_de_json_all(self, offline_bot): json_dict = { "phone_number": self.phone_number, "first_name": self.first_name, "last_name": self.last_name, "user_id": self.user_id, } - contact = Contact.de_json(json_dict, bot) + contact = Contact.de_json(json_dict, offline_bot) assert contact.api_kwargs == {} assert contact.phone_number == self.phone_number @@ -102,20 +104,20 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) - async def test_send_contact_without_required(self, bot, chat_id): + async def test_send_contact_without_required(self, offline_bot, chat_id): with pytest.raises(ValueError, match="Either contact or phone_number and first_name"): - await bot.send_contact(chat_id=chat_id) + await offline_bot.send_contact(chat_id=chat_id) - async def test_send_mutually_exclusive(self, bot, chat_id, contact): + async def test_send_mutually_exclusive(self, offline_bot, chat_id, contact): with pytest.raises(ValueError, match="Not both"): - await bot.send_contact( + await offline_bot.send_contact( chat_id=chat_id, contact=contact, phone_number=contact.phone_number, first_name=contact.first_name, ) - async def test_send_with_contact(self, monkeypatch, bot, chat_id, contact): + async def test_send_with_contact(self, monkeypatch, offline_bot, chat_id, contact): async def make_assertion(url, request_data: RequestData, *args, **kwargs): data = request_data.json_parameters phone = data["phone_number"] == contact.phone_number @@ -123,11 +125,38 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): last = data["last_name"] == contact.last_name return phone and first and last - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.send_contact(contact=contact, chat_id=chat_id) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_contact(contact=contact, chat_id=chat_id) + + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_contact_default_quote_parse_mode( + self, default_bot, chat_id, contact, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() + + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_contact( + chat_id, contact=contact, reply_parameters=ReplyParameters(**kwargs) + ) -class TestContactWithRequest(TestContactBase): +class TestContactWithRequest(ContactTestBase): @pytest.mark.parametrize( ("default_bot", "custom"), [ @@ -156,7 +185,7 @@ async def test_send_contact_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="message not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_contact( chat_id, contact=contact, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_files/test_document.py b/tests/_files/test_document.py index 85cfaabab42..224e05aa2fa 100644 --- a/tests/_files/test_document.py +++ b/tests/_files/test_document.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -22,7 +22,8 @@ import pytest -from telegram import Bot, Document, InputFile, MessageEntity, PhotoSize, Voice +from telegram import Bot, Document, InputFile, MessageEntity, PhotoSize, ReplyParameters, Voice +from telegram.constants import ParseMode from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData @@ -31,27 +32,12 @@ check_shortcut_call, check_shortcut_signature, ) -from tests.auxil.deprecations import ( - check_thumb_deprecation_warning_for_method_args, - check_thumb_deprecation_warnings_for_args_and_attrs, -) +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file from tests.auxil.slots import mro_slots -@pytest.fixture() -def document_file(): - with data_file("telegram.png").open("rb") as f: - yield f - - -@pytest.fixture(scope="module") -async def document(bot, chat_id): - with data_file("telegram.png").open("rb") as f: - return (await bot.send_document(chat_id, document=f, read_timeout=50)).document - - -class TestDocumentBase: +class DocumentTestBase: caption = "DocumentTest - *Caption*" document_file_url = "https://python-telegram-bot.org/static/testfiles/telegram.gif" file_size = 12948 @@ -64,7 +50,7 @@ class TestDocumentBase: document_file_unique_id = "adc3145fd2e84d95b64d68eaa22aa33e" -class TestDocumentWithoutRequest(TestDocumentBase): +class TestDocumentWithoutRequest(DocumentTestBase): def test_slot_behaviour(self, document): for attr in document.__slots__: assert getattr(document, attr, "err") != "err", f"got extra slot '{attr}'" @@ -81,16 +67,11 @@ def test_expected_values(self, document): assert document.file_size == self.file_size assert document.mime_type == self.mime_type assert document.file_name == self.file_name - assert document.thumbnail.file_size == self.thumb_file_size + assert document.thumbnail.file_size in [self.thumb_file_size, 7980] assert document.thumbnail.width == self.thumb_width assert document.thumbnail.height == self.thumb_height - def test_thumb_property_deprecation_warning(self, recwarn): - document = Document(file_id="file_id", file_unique_id="file_unique_id", thumb=object()) - assert document.thumb is document.thumbnail - check_thumb_deprecation_warnings_for_args_and_attrs(recwarn, __file__) - - def test_de_json(self, bot, document): + def test_de_json(self, offline_bot, document): json_dict = { "file_id": self.document_file_id, "file_unique_id": self.document_file_unique_id, @@ -99,7 +80,7 @@ def test_de_json(self, bot, document): "mime_type": self.mime_type, "file_size": self.file_size, } - test_document = Document.de_json(json_dict, bot) + test_document = Document.de_json(json_dict, offline_bot) assert test_document.api_kwargs == {} assert test_document.file_id == self.document_file_id @@ -135,13 +116,13 @@ def test_equality(self, document): assert a != e assert hash(a) != hash(e) - async def test_error_send_without_required_args(self, bot, chat_id): + async def test_error_send_without_required_args(self, offline_bot, chat_id): with pytest.raises(TypeError): - await bot.send_document(chat_id=chat_id) + await offline_bot.send_document(chat_id=chat_id) @pytest.mark.parametrize("disable_content_type_detection", [True, False, None]) async def test_send_with_document( - self, monkeypatch, bot, chat_id, document, disable_content_type_detection + self, monkeypatch, offline_bot, chat_id, document, disable_content_type_detection ): async def make_assertion(url, request_data: RequestData, *args, **kwargs): data = request_data.parameters @@ -150,9 +131,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): ) return data["document"] == document.file_id and type_detection - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - message = await bot.send_document( + message = await offline_bot.send_document( document=document, chat_id=chat_id, disable_content_type_detection=disable_content_type_detection, @@ -160,24 +141,40 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): assert message - @pytest.mark.parametrize("bot_class", ["Bot", "ExtBot"]) - async def test_send_document_thumb_deprecation_warning( - self, recwarn, monkeypatch, bot_class, bot, raw_bot, chat_id, document + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_document_default_quote_parse_mode( + self, default_bot, chat_id, document, custom, monkeypatch ): async def make_assertion(url, request_data: RequestData, *args, **kwargs): - return True + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() - bot = raw_bot if bot_class == "Bot" else bot + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom - monkeypatch.setattr(bot.request, "post", make_assertion) - await bot.send_document(chat_id, document, thumb="thumb") - check_thumb_deprecation_warning_for_method_args(recwarn, __file__) + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_document( + chat_id, document, reply_parameters=ReplyParameters(**kwargs) + ) @pytest.mark.parametrize("local_mode", [True, False]) - async def test_send_document_local_files(self, monkeypatch, bot, chat_id, local_mode): + async def test_send_document_local_files( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): try: - bot._local_mode = local_mode - # For just test that the correct paths are passed as we have no local bot API set up + offline_bot._local_mode = local_mode + # For just test that the correct paths are passed as we have no local Bot API set up test_flag = False file = data_file("telegram.jpg") expected = file.as_uri() @@ -192,21 +189,13 @@ async def make_assertion(_, data, *args, **kwargs): test_flag = isinstance(data.get("document"), InputFile) and isinstance( data.get("thumbnail"), InputFile ) + return dummy_message_dict - monkeypatch.setattr(bot, "_post", make_assertion) - await bot.send_document(chat_id, file, thumbnail=file) + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.send_document(chat_id, file, thumbnail=file) assert test_flag finally: - bot._local_mode = False - - async def test_send_document_with_local_files_throws_error_with_different_thumb_and_thumbnail( - self, bot, chat_id - ): - file = data_file("telegram.jpg") - different_file = data_file("telegram_no_standard_header.jpg") - - with pytest.raises(ValueError, match="different entities as 'thumb' and 'thumbnail'"): - await bot.send_document(chat_id, file, thumbnail=file, thumb=different_file) + offline_bot._local_mode = False async def test_get_file_instance_method(self, monkeypatch, document): async def make_assertion(*_, **kwargs): @@ -220,7 +209,7 @@ async def make_assertion(*_, **kwargs): assert await document.get_file() -class TestDocumentWithRequest(TestDocumentBase): +class TestDocumentWithRequest(DocumentTestBase): async def test_error_send_empty_file(self, bot, chat_id): with Path(os.devnull).open("rb") as f, pytest.raises(TelegramError): await bot.send_document(chat_id=chat_id, document=f) @@ -229,20 +218,16 @@ async def test_error_send_empty_file_id(self, bot, chat_id): with pytest.raises(TelegramError): await bot.send_document(chat_id=chat_id, document="") - async def test_get_and_download(self, bot, document, chat_id): - path = Path("telegram.png") - if path.is_file(): - path.unlink() - + async def test_get_and_download(self, bot, document, chat_id, tmp_file): new_file = await bot.get_file(document.file_id) assert new_file.file_size == document.file_size assert new_file.file_unique_id == document.file_unique_id assert new_file.file_path.startswith("https://") - await new_file.download_to_drive("telegram.png") + await new_file.download_to_drive(tmp_file) - assert path.is_file() + assert tmp_file.is_file() async def test_send_resend(self, bot, chat_id, document): message = await bot.send_document(chat_id=chat_id, document=document.file_id) @@ -370,7 +355,7 @@ async def test_send_document_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="message not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_document( chat_id, document, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_files/test_file.py b/tests/_files/test_file.py index 05706bcd40c..f1091f6cd9d 100644 --- a/tests/_files/test_file.py +++ b/tests/_files/test_file.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -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/]. import os +from io import BytesIO from pathlib import Path from tempfile import TemporaryFile, mkstemp @@ -31,10 +32,10 @@ @pytest.fixture(scope="module") def file(bot): file = File( - TestFileBase.file_id, - TestFileBase.file_unique_id, - file_path=TestFileBase.file_path, - file_size=TestFileBase.file_size, + FileTestBase.file_id, + FileTestBase.file_unique_id, + file_path=FileTestBase.file_path, + file_size=FileTestBase.file_size, ) file.set_bot(bot) file._unfreeze() @@ -51,10 +52,10 @@ def encrypted_file(bot): "Pt7fKPgYWKA/7a8E64Ea1X8C+Wf7Ky1tF4ANBl63vl4=", ) ef = File( - TestFileBase.file_id, - TestFileBase.file_unique_id, - TestFileBase.file_size, - TestFileBase.file_path, + FileTestBase.file_id, + FileTestBase.file_unique_id, + FileTestBase.file_size, + FileTestBase.file_path, ) ef.set_bot(bot) ef.set_credentials(fc) @@ -69,9 +70,9 @@ def encrypted_local_file(bot): "Pt7fKPgYWKA/7a8E64Ea1X8C+Wf7Ky1tF4ANBl63vl4=", ) ef = File( - TestFileBase.file_id, - TestFileBase.file_unique_id, - TestFileBase.file_size, + FileTestBase.file_id, + FileTestBase.file_unique_id, + FileTestBase.file_size, file_path=str(data_file("image_encrypted.jpg")), ) ef.set_bot(bot) @@ -82,16 +83,16 @@ def encrypted_local_file(bot): @pytest.fixture(scope="module") def local_file(bot): file = File( - TestFileBase.file_id, - TestFileBase.file_unique_id, + FileTestBase.file_id, + FileTestBase.file_unique_id, file_path=str(data_file("local_file.txt")), - file_size=TestFileBase.file_size, + file_size=FileTestBase.file_size, ) file.set_bot(bot) return file -class TestFileBase: +class FileTestBase: file_id = "NOTVALIDDOESNOTMATTER" file_unique_id = "adc3145fd2e84d95b64d68eaa22aa33e" file_path = ( @@ -101,20 +102,20 @@ class TestFileBase: file_content = "Saint-Saëns".encode() # Intentionally contains unicode chars. -class TestFileWithoutRequest(TestFileBase): +class TestFileWithoutRequest(FileTestBase): def test_slot_behaviour(self, file): for attr in file.__slots__: assert getattr(file, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(file)) == len(set(mro_slots(file))), "duplicate slot" - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "file_id": self.file_id, "file_unique_id": self.file_unique_id, "file_path": self.file_path, "file_size": self.file_size, } - new_file = File.de_json(json_dict, bot) + new_file = File.de_json(json_dict, offline_bot) assert new_file.api_kwargs == {} assert new_file.file_id == self.file_id @@ -131,11 +132,11 @@ def test_to_dict(self, file): assert file_dict["file_path"] == file.file_path assert file_dict["file_size"] == file.file_size - def test_equality(self, bot): - a = File(self.file_id, self.file_unique_id, bot) - b = File("", self.file_unique_id, bot) + def test_equality(self, offline_bot): + a = File(self.file_id, self.file_unique_id, offline_bot) + b = File("", self.file_unique_id, offline_bot) c = File(self.file_id, self.file_unique_id, None) - d = File("", "", bot) + d = File("", "", offline_bot) e = Voice(self.file_id, self.file_unique_id, 0) assert a == b @@ -161,7 +162,7 @@ async def test(*args, **kwargs): try: assert out_file.read_bytes() == self.file_content finally: - out_file.unlink() + out_file.unlink(missing_ok=True) @pytest.mark.parametrize( "custom_path_type", [str, Path], ids=["str custom_path", "pathlib.Path custom_path"] @@ -179,22 +180,7 @@ async def test(*args, **kwargs): assert out_file.read_bytes() == self.file_content finally: os.close(file_handle) - custom_path.unlink() - - async def test_download_no_filename(self, monkeypatch, file): - async def test(*args, **kwargs): - return self.file_content - - file.file_path = None - - monkeypatch.setattr(file.get_bot().request, "retrieve", test) - out_file = await file.download_to_drive() - - assert str(out_file)[-len(file.file_id) :] == file.file_id - try: - assert out_file.read_bytes() == self.file_content - finally: - out_file.unlink() + custom_path.unlink(missing_ok=True) async def test_download_file_obj(self, monkeypatch, file): async def test(*args, **kwargs): @@ -223,7 +209,7 @@ async def test(*args, **kwargs): assert buf2[len(buf) :] == buf assert buf2[: len(buf)] == buf - async def test_download_encrypted(self, monkeypatch, bot, encrypted_file): + async def test_download_encrypted(self, monkeypatch, offline_bot, encrypted_file): async def test(*args, **kwargs): return data_file("image_encrypted.jpg").read_bytes() @@ -233,7 +219,7 @@ async def test(*args, **kwargs): try: assert out_file.read_bytes() == data_file("image_decrypted.jpg").read_bytes() finally: - out_file.unlink() + out_file.unlink(missing_ok=True) async def test_download_file_obj_encrypted(self, monkeypatch, encrypted_file): async def test(*args, **kwargs): @@ -272,14 +258,24 @@ async def test(*args, **kwargs): assert buf2[len(buf) :] == buf assert buf2[: len(buf)] == buf + async def test_download_no_file_path(self): + with pytest.raises(RuntimeError, match="No `file_path` available"): + await File(self.file_id, self.file_unique_id).download_to_drive() + with pytest.raises(RuntimeError, match="No `file_path` available"): + await File(self.file_id, self.file_unique_id).download_to_memory(BytesIO()) + with pytest.raises(RuntimeError, match="No `file_path` available"): + await File(self.file_id, self.file_unique_id).download_as_bytearray() + -class TestFileWithRequest(TestFileBase): +class TestFileWithRequest(FileTestBase): async def test_error_get_empty_file_id(self, bot): with pytest.raises(TelegramError): await bot.get_file(file_id="") async def test_download_local_file(self, local_file): assert await local_file.download_to_drive() == Path(local_file.file_path) + # Ensure that the file contents didn't change + assert Path(local_file.file_path).read_bytes() == self.file_content @pytest.mark.parametrize( "custom_path_type", [str, Path], ids=["str custom_path", "pathlib.Path custom_path"] @@ -293,7 +289,7 @@ async def test_download_custom_path_local_file(self, local_file, custom_path_typ assert out_file.read_bytes() == self.file_content finally: os.close(file_handle) - custom_path.unlink() + custom_path.unlink(missing_ok=True) async def test_download_file_obj_local_file(self, local_file): with TemporaryFile() as custom_fobj: @@ -315,14 +311,14 @@ async def test_download_custom_path_local_file_encrypted( assert out_file.read_bytes() == data_file("image_decrypted.jpg").read_bytes() finally: os.close(file_handle) - custom_path.unlink() + custom_path.unlink(missing_ok=True) async def test_download_local_file_encrypted(self, encrypted_local_file): out_file = await encrypted_local_file.download_to_drive() try: assert out_file.read_bytes() == data_file("image_decrypted.jpg").read_bytes() finally: - out_file.unlink() + out_file.unlink(missing_ok=True) async def test_download_bytearray_local_file(self, local_file): # Check that a download to a newly allocated bytearray works. diff --git a/tests/_files/test_inputfile.py b/tests/_files/test_inputfile.py index c772192dfae..70d26a50a17 100644 --- a/tests/_files/test_inputfile.py +++ b/tests/_files/test_inputfile.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,11 +19,12 @@ import contextlib import subprocess import sys -from io import BytesIO +from io import BufferedReader, BytesIO import pytest from telegram import InputFile +from telegram._utils.strings import TextEncoding from tests.auxil.files import data_file from tests.auxil.slots import mro_slots @@ -65,21 +66,45 @@ def test_attach(self, attach): assert input_file.attach_name is None assert input_file.attach_uri is None - def test_mimetypes(self): + @pytest.mark.parametrize("read_file_handle", [True, False]) + def test_mimetypes_file_handle(self, read_file_handle): # Only test a few to make sure logic works okay - assert InputFile(data_file("telegram.jpg").open("rb")).mimetype == "image/jpeg" + assert ( + InputFile( + data_file("telegram.jpg").open("rb"), read_file_handle=read_file_handle + ).mimetype + == "image/jpeg" + ) # For some reason python can guess the type on macOS - assert InputFile(data_file("telegram.webp").open("rb")).mimetype in [ + assert InputFile( + data_file("telegram.webp").open("rb"), read_file_handle=read_file_handle + ).mimetype in [ "application/octet-stream", "image/webp", ] - assert InputFile(data_file("telegram.mp3").open("rb")).mimetype == "audio/mpeg" + assert ( + InputFile( + data_file("telegram.mp3").open("rb"), read_file_handle=read_file_handle + ).mimetype + == "audio/mpeg" + ) # For some reason windows drops the trailing i - assert InputFile(data_file("telegram.midi").open("rb")).mimetype in [ + assert InputFile( + data_file("telegram.midi").open("rb"), read_file_handle=read_file_handle + ).mimetype in [ "audio/mid", "audio/midi", ] + # Test string file + assert ( + InputFile( + data_file("text_file.txt").open("rb"), read_file_handle=read_file_handle + ).mimetype + == "text/plain" + ) + + def test_mimetypes_other(self): # Test guess from file assert InputFile(BytesIO(b"blah"), filename="tg.jpg").mimetype == "image/jpeg" assert InputFile(BytesIO(b"blah"), filename="tg.mp3").mimetype == "audio/mpeg" @@ -91,20 +116,49 @@ def test_mimetypes(self): ) assert InputFile(BytesIO(b"blah")).mimetype == "application/octet-stream" - # Test string file - assert InputFile(data_file("text_file.txt").open()).mimetype == "text/plain" - - def test_filenames(self): - assert InputFile(data_file("telegram.jpg").open("rb")).filename == "telegram.jpg" - assert InputFile(data_file("telegram.jpg").open("rb"), filename="blah").filename == "blah" + @pytest.mark.parametrize("read_file_handle", [True, False]) + def test_filenames(self, read_file_handle): + assert ( + InputFile( + data_file("telegram.jpg").open("rb"), read_file_handle=read_file_handle + ).filename + == "telegram.jpg" + ) assert ( - InputFile(data_file("telegram.jpg").open("rb"), filename="blah.jpg").filename + InputFile( + data_file("telegram.jpg").open("rb"), + filename="blah", + read_file_handle=read_file_handle, + ).filename + == "blah" + ) + assert ( + InputFile( + data_file("telegram.jpg").open("rb"), + filename="blah.jpg", + read_file_handle=read_file_handle, + ).filename == "blah.jpg" ) - assert InputFile(data_file("telegram").open("rb")).filename == "telegram" - assert InputFile(data_file("telegram").open("rb"), filename="blah").filename == "blah" assert ( - InputFile(data_file("telegram").open("rb"), filename="blah.jpg").filename == "blah.jpg" + InputFile(data_file("telegram").open("rb"), read_file_handle=read_file_handle).filename + == "telegram" + ) + assert ( + InputFile( + data_file("telegram").open("rb"), + filename="blah", + read_file_handle=read_file_handle, + ).filename + == "blah" + ) + assert ( + InputFile( + data_file("telegram").open("rb"), + filename="blah.jpg", + read_file_handle=read_file_handle, + ).filename + == "blah.jpg" ) class MockedFileobject: @@ -139,6 +193,19 @@ def read(self): == "blah.jpg" ) + @pytest.mark.parametrize("read_file_handle", [True, False]) + def test_read_file_handle(self, read_file_handle): + input_file = InputFile( + data_file("telegram.jpg").open("rb"), read_file_handle=read_file_handle + ) + content = input_file.field_tuple[1] + if read_file_handle: + assert isinstance(content, bytes) + assert content == data_file("telegram.jpg").read_bytes() + else: + assert isinstance(content, BufferedReader) + assert content.read() == data_file("telegram.jpg").read_bytes() + class TestInputFileWithRequest: async def test_send_bytes(self, bot, chat_id): @@ -150,17 +217,17 @@ async def test_send_bytes(self, bot, chat_id): await (await message.document.get_file()).download_to_memory(out=out) out.seek(0) - assert out.read().decode("utf-8") == "PTB Rocks! ⅞" + assert out.read().decode(TextEncoding.UTF_8) == "PTB Rocks! ⅞" async def test_send_string(self, bot, chat_id): # We test this here and not at the respective test modules because it's not worth # duplicating the test for the different methods message = await bot.send_document( - chat_id, InputFile(data_file("text_file.txt").read_text(encoding="utf-8")) + chat_id, InputFile(data_file("text_file.txt").read_text(encoding=TextEncoding.UTF_8)) ) out = BytesIO() await (await message.document.get_file()).download_to_memory(out=out) out.seek(0) - assert out.read().decode("utf-8") == "PTB Rocks! ⅞" + assert out.read().decode(TextEncoding.UTF_8) == "PTB Rocks! ⅞" diff --git a/tests/_files/test_inputmedia.py b/tests/_files/test_inputmedia.py index bade46cf292..d43a853ec7d 100644 --- a/tests/_files/test_inputmedia.py +++ b/tests/_files/test_inputmedia.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,131 +18,146 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio import copy +import datetime as dtm from collections.abc import Sequence -from typing import Optional import pytest from telegram import ( InputFile, + InputMedia, InputMediaAnimation, InputMediaAudio, InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputPaidMediaPhoto, + InputPaidMediaVideo, Message, MessageEntity, + ReplyParameters, ) -from telegram.constants import ParseMode - -# noinspection PyUnresolvedReferences +from telegram.constants import InputMediaType, ParseMode from telegram.error import BadRequest from telegram.request import RequestData -from tests._files.test_animation import animation, animation_file # noqa: F401 +from telegram.warnings import PTBDeprecationWarning from tests.auxil.files import data_file from tests.auxil.networking import expect_bad_request from tests.auxil.slots import mro_slots -# noinspection PyUnresolvedReferences -from tests.test_forum import emoji_id, real_topic # noqa: F401 - -from ..auxil.deprecations import check_thumb_deprecation_warnings_for_args_and_attrs - -# noinspection PyUnresolvedReferences -from .test_audio import audio, audio_file # noqa: F401 - -# noinspection PyUnresolvedReferences -from .test_document import document, document_file # noqa: F401 - -# noinspection PyUnresolvedReferences -from .test_photo import photo, photo_file, photolist, thumb # noqa: F401 - -# noinspection PyUnresolvedReferences -from .test_video import video, video_file # noqa: F401 +from ..auxil.build_messages import make_message @pytest.fixture(scope="module") def input_media_video(class_thumb_file): return InputMediaVideo( - media=TestInputMediaVideoBase.media, - caption=TestInputMediaVideoBase.caption, - width=TestInputMediaVideoBase.width, - height=TestInputMediaVideoBase.height, - duration=TestInputMediaVideoBase.duration, - parse_mode=TestInputMediaVideoBase.parse_mode, - caption_entities=TestInputMediaVideoBase.caption_entities, + media=InputMediaVideoTestBase.media, + caption=InputMediaVideoTestBase.caption, + width=InputMediaVideoTestBase.width, + height=InputMediaVideoTestBase.height, + duration=InputMediaVideoTestBase.duration, + parse_mode=InputMediaVideoTestBase.parse_mode, + caption_entities=InputMediaVideoTestBase.caption_entities, thumbnail=class_thumb_file, - supports_streaming=TestInputMediaVideoBase.supports_streaming, - has_spoiler=TestInputMediaVideoBase.has_spoiler, + cover=class_thumb_file, + start_timestamp=InputMediaVideoTestBase.start_timestamp, + supports_streaming=InputMediaVideoTestBase.supports_streaming, + has_spoiler=InputMediaVideoTestBase.has_spoiler, + show_caption_above_media=InputMediaVideoTestBase.show_caption_above_media, ) @pytest.fixture(scope="module") def input_media_photo(): return InputMediaPhoto( - media=TestInputMediaPhotoBase.media, - caption=TestInputMediaPhotoBase.caption, - parse_mode=TestInputMediaPhotoBase.parse_mode, - caption_entities=TestInputMediaPhotoBase.caption_entities, - has_spoiler=TestInputMediaPhotoBase.has_spoiler, + media=InputMediaPhotoTestBase.media, + caption=InputMediaPhotoTestBase.caption, + parse_mode=InputMediaPhotoTestBase.parse_mode, + caption_entities=InputMediaPhotoTestBase.caption_entities, + has_spoiler=InputMediaPhotoTestBase.has_spoiler, + show_caption_above_media=InputMediaPhotoTestBase.show_caption_above_media, ) @pytest.fixture(scope="module") def input_media_animation(class_thumb_file): return InputMediaAnimation( - media=TestInputMediaAnimationBase.media, - caption=TestInputMediaAnimationBase.caption, - parse_mode=TestInputMediaAnimationBase.parse_mode, - caption_entities=TestInputMediaAnimationBase.caption_entities, - width=TestInputMediaAnimationBase.width, - height=TestInputMediaAnimationBase.height, + media=InputMediaAnimationTestBase.media, + caption=InputMediaAnimationTestBase.caption, + parse_mode=InputMediaAnimationTestBase.parse_mode, + caption_entities=InputMediaAnimationTestBase.caption_entities, + width=InputMediaAnimationTestBase.width, + height=InputMediaAnimationTestBase.height, thumbnail=class_thumb_file, - duration=TestInputMediaAnimationBase.duration, - has_spoiler=TestInputMediaAnimationBase.has_spoiler, + duration=InputMediaAnimationTestBase.duration, + has_spoiler=InputMediaAnimationTestBase.has_spoiler, + show_caption_above_media=InputMediaAnimationTestBase.show_caption_above_media, ) @pytest.fixture(scope="module") def input_media_audio(class_thumb_file): return InputMediaAudio( - media=TestInputMediaAudioBase.media, - caption=TestInputMediaAudioBase.caption, - duration=TestInputMediaAudioBase.duration, - performer=TestInputMediaAudioBase.performer, - title=TestInputMediaAudioBase.title, + media=InputMediaAudioTestBase.media, + caption=InputMediaAudioTestBase.caption, + duration=InputMediaAudioTestBase.duration, + performer=InputMediaAudioTestBase.performer, + title=InputMediaAudioTestBase.title, thumbnail=class_thumb_file, - parse_mode=TestInputMediaAudioBase.parse_mode, - caption_entities=TestInputMediaAudioBase.caption_entities, + parse_mode=InputMediaAudioTestBase.parse_mode, + caption_entities=InputMediaAudioTestBase.caption_entities, ) @pytest.fixture(scope="module") def input_media_document(class_thumb_file): return InputMediaDocument( - media=TestInputMediaDocumentBase.media, - caption=TestInputMediaDocumentBase.caption, + media=InputMediaDocumentTestBase.media, + caption=InputMediaDocumentTestBase.caption, + thumbnail=class_thumb_file, + parse_mode=InputMediaDocumentTestBase.parse_mode, + caption_entities=InputMediaDocumentTestBase.caption_entities, + disable_content_type_detection=InputMediaDocumentTestBase.disable_content_type_detection, + ) + + +@pytest.fixture(scope="module") +def input_paid_media_photo(): + return InputPaidMediaPhoto( + media=InputMediaPhotoTestBase.media, + ) + + +@pytest.fixture(scope="module") +def input_paid_media_video(class_thumb_file): + return InputPaidMediaVideo( + media=InputMediaVideoTestBase.media, thumbnail=class_thumb_file, - parse_mode=TestInputMediaDocumentBase.parse_mode, - caption_entities=TestInputMediaDocumentBase.caption_entities, - disable_content_type_detection=TestInputMediaDocumentBase.disable_content_type_detection, + cover=class_thumb_file, + start_timestamp=InputMediaVideoTestBase.start_timestamp, + width=InputMediaVideoTestBase.width, + height=InputMediaVideoTestBase.height, + duration=InputMediaVideoTestBase.duration, + supports_streaming=InputMediaVideoTestBase.supports_streaming, ) -class TestInputMediaVideoBase: +class InputMediaVideoTestBase: type_ = "video" media = "NOTAREALFILEID" caption = "My Caption" width = 3 height = 4 - duration = 5 + duration = dtm.timedelta(seconds=5) + start_timestamp = 3 parse_mode = "HTML" supports_streaming = True caption_entities = [MessageEntity(MessageEntity.BOLD, 0, 2)] has_spoiler = True + show_caption_above_media = True -class TestInputMediaVideoWithoutRequest(TestInputMediaVideoBase): +class TestInputMediaVideoWithoutRequest(InputMediaVideoTestBase): def test_slot_behaviour(self, input_media_video): inst = input_media_video for attr in inst.__slots__: @@ -155,18 +170,15 @@ def test_expected_values(self, input_media_video): assert input_media_video.caption == self.caption assert input_media_video.width == self.width assert input_media_video.height == self.height - assert input_media_video.duration == self.duration + assert input_media_video._duration == self.duration assert input_media_video.parse_mode == self.parse_mode assert input_media_video.caption_entities == tuple(self.caption_entities) assert input_media_video.supports_streaming == self.supports_streaming assert isinstance(input_media_video.thumbnail, InputFile) - assert input_media_video.thumb is input_media_video.thumbnail + assert isinstance(input_media_video.cover, InputFile) + assert input_media_video.start_timestamp == self.start_timestamp assert input_media_video.has_spoiler == self.has_spoiler - - def test_thumb_property_deprecation_warning(self, recwarn): - input_media_video = InputMediaVideo(self.media, thumb=object()) - assert input_media_video.thumb is input_media_video.thumbnail - check_thumb_deprecation_warnings_for_args_and_attrs(recwarn, __file__) + assert input_media_video.show_caption_above_media == self.show_caption_above_media def test_caption_entities_always_tuple(self): input_media_video = InputMediaVideo(self.media) @@ -179,15 +191,42 @@ def test_to_dict(self, input_media_video): assert input_media_video_dict["caption"] == input_media_video.caption assert input_media_video_dict["width"] == input_media_video.width assert input_media_video_dict["height"] == input_media_video.height - assert input_media_video_dict["duration"] == input_media_video.duration + assert input_media_video_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(input_media_video_dict["duration"], int) assert input_media_video_dict["parse_mode"] == input_media_video.parse_mode assert input_media_video_dict["caption_entities"] == [ ce.to_dict() for ce in input_media_video.caption_entities ] assert input_media_video_dict["supports_streaming"] == input_media_video.supports_streaming assert input_media_video_dict["has_spoiler"] == input_media_video.has_spoiler + assert ( + input_media_video_dict["show_caption_above_media"] + == input_media_video.show_caption_above_media + ) + assert input_media_video_dict["cover"] == input_media_video.cover + assert input_media_video_dict["start_timestamp"] == input_media_video.start_timestamp + + def test_time_period_properties(self, PTB_TIMEDELTA, input_media_video): + duration = input_media_video.duration + + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) - def test_with_video(self, video): # noqa: F811 + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_media_video): + input_media_video.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + + def test_with_video(self, video, PTB_TIMEDELTA): # fixture found in test_video input_media_video = InputMediaVideo(video, caption="test 3") assert input_media_video.type == self.type_ @@ -197,7 +236,7 @@ def test_with_video(self, video): # noqa: F811 assert input_media_video.duration == video.duration assert input_media_video.caption == "test 3" - def test_with_video_file(self, video_file): # noqa: F811 + def test_with_video_file(self, video_file): # fixture found in test_video input_media_video = InputMediaVideo(video_file, caption="test 3") assert input_media_video.type == self.type_ @@ -206,30 +245,46 @@ def test_with_video_file(self, video_file): # noqa: F811 def test_with_local_files(self): input_media_video = InputMediaVideo( - data_file("telegram.mp4"), thumbnail=data_file("telegram.jpg") + data_file("telegram.mp4"), + thumbnail=data_file("telegram.jpg"), + cover=data_file("telegram.jpg"), ) assert input_media_video.media == data_file("telegram.mp4").as_uri() assert input_media_video.thumbnail == data_file("telegram.jpg").as_uri() + assert input_media_video.cover == data_file("telegram.jpg").as_uri() - def test_with_local_files_throws_exception_with_different_thumb_and_thumbnail(self): - with pytest.raises(ValueError, match="You passed different entities as 'thumb' and "): - InputMediaVideo( - data_file("telegram.mp4"), - thumbnail=data_file("telegram.jpg"), - thumb=data_file("telegram_no_standard_header.jpg"), + def test_type_enum_conversion(self): + # Since we have a lot of different test classes for all the input media types, we test this + # conversion only here. It is independent of the specific class + assert ( + type( + InputMedia( + media_type="animation", + media="media", + ).type ) + is InputMediaType + ) + assert ( + InputMedia( + media_type="unknown", + media="media", + ).type + == "unknown" + ) -class TestInputMediaPhotoBase: +class InputMediaPhotoTestBase: type_ = "photo" media = "NOTAREALFILEID" caption = "My Caption" parse_mode = "Markdown" caption_entities = [MessageEntity(MessageEntity.BOLD, 0, 2)] has_spoiler = True + show_caption_above_media = True -class TestInputMediaPhotoWithoutRequest(TestInputMediaPhotoBase): +class TestInputMediaPhotoWithoutRequest(InputMediaPhotoTestBase): def test_slot_behaviour(self, input_media_photo): inst = input_media_photo for attr in inst.__slots__: @@ -243,6 +298,7 @@ def test_expected_values(self, input_media_photo): assert input_media_photo.parse_mode == self.parse_mode assert input_media_photo.caption_entities == tuple(self.caption_entities) assert input_media_photo.has_spoiler == self.has_spoiler + assert input_media_photo.show_caption_above_media == self.show_caption_above_media def test_caption_entities_always_tuple(self): input_media_photo = InputMediaPhoto(self.media) @@ -258,15 +314,19 @@ def test_to_dict(self, input_media_photo): ce.to_dict() for ce in input_media_photo.caption_entities ] assert input_media_photo_dict["has_spoiler"] == input_media_photo.has_spoiler + assert ( + input_media_photo_dict["show_caption_above_media"] + == input_media_photo.show_caption_above_media + ) - def test_with_photo(self, photo): # noqa: F811 + def test_with_photo(self, photo): # fixture found in test_photo input_media_photo = InputMediaPhoto(photo, caption="test 2") assert input_media_photo.type == self.type_ assert input_media_photo.media == photo.file_id assert input_media_photo.caption == "test 2" - def test_with_photo_file(self, photo_file): # noqa: F811 + def test_with_photo_file(self, photo_file): # fixture found in test_photo input_media_photo = InputMediaPhoto(photo_file, caption="test 2") assert input_media_photo.type == self.type_ @@ -278,7 +338,7 @@ def test_with_local_files(self): assert input_media_photo.media == data_file("telegram.mp4").as_uri() -class TestInputMediaAnimationBase: +class InputMediaAnimationTestBase: type_ = "animation" media = "NOTAREALFILEID" caption = "My Caption" @@ -286,11 +346,12 @@ class TestInputMediaAnimationBase: caption_entities = [MessageEntity(MessageEntity.BOLD, 0, 2)] width = 30 height = 30 - duration = 1 + duration = dtm.timedelta(seconds=1) has_spoiler = True + show_caption_above_media = True -class TestInputMediaAnimationWithoutRequest(TestInputMediaAnimationBase): +class TestInputMediaAnimationWithoutRequest(InputMediaAnimationTestBase): def test_slot_behaviour(self, input_media_animation): inst = input_media_animation for attr in inst.__slots__: @@ -304,13 +365,9 @@ def test_expected_values(self, input_media_animation): assert input_media_animation.parse_mode == self.parse_mode assert input_media_animation.caption_entities == tuple(self.caption_entities) assert isinstance(input_media_animation.thumbnail, InputFile) - assert input_media_animation.thumb is input_media_animation.thumbnail assert input_media_animation.has_spoiler == self.has_spoiler - - def test_thumb_property_deprecation_warning(self, recwarn): - input_media_animation = InputMediaAnimation(self.media, thumb=object()) - assert input_media_animation.thumb is input_media_animation.thumbnail - check_thumb_deprecation_warnings_for_args_and_attrs(recwarn, __file__) + assert input_media_animation.show_caption_above_media == self.show_caption_above_media + assert input_media_animation._duration == self.duration def test_caption_entities_always_tuple(self): input_media_animation = InputMediaAnimation(self.media) @@ -327,17 +384,42 @@ def test_to_dict(self, input_media_animation): ] assert input_media_animation_dict["width"] == input_media_animation.width assert input_media_animation_dict["height"] == input_media_animation.height - assert input_media_animation_dict["duration"] == input_media_animation.duration + assert input_media_animation_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(input_media_animation_dict["duration"], int) assert input_media_animation_dict["has_spoiler"] == input_media_animation.has_spoiler + assert ( + input_media_animation_dict["show_caption_above_media"] + == input_media_animation.show_caption_above_media + ) + + def test_time_period_properties(self, PTB_TIMEDELTA, input_media_animation): + duration = input_media_animation.duration - def test_with_animation(self, animation): # noqa: F811 + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_media_animation): + input_media_animation.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + + def test_with_animation(self, animation): # fixture found in test_animation input_media_animation = InputMediaAnimation(animation, caption="test 2") assert input_media_animation.type == self.type_ assert input_media_animation.media == animation.file_id assert input_media_animation.caption == "test 2" - def test_with_animation_file(self, animation_file): # noqa: F811 + def test_with_animation_file(self, animation_file): # fixture found in test_animation input_media_animation = InputMediaAnimation(animation_file, caption="test 2") assert input_media_animation.type == self.type_ @@ -351,27 +433,19 @@ def test_with_local_files(self): assert input_media_animation.media == data_file("telegram.mp4").as_uri() assert input_media_animation.thumbnail == data_file("telegram.jpg").as_uri() - def test_with_local_files_throws_exception_with_different_thumb_and_thumbnail(self): - with pytest.raises(ValueError, match="You passed different entities as 'thumb' and "): - InputMediaAnimation( - data_file("telegram.mp4"), - thumbnail=data_file("telegram.jpg"), - thumb=data_file("telegram_no_standard_header.jpg"), - ) - -class TestInputMediaAudioBase: +class InputMediaAudioTestBase: type_ = "audio" media = "NOTAREALFILEID" caption = "My Caption" - duration = 3 + duration = dtm.timedelta(seconds=3) performer = "performer" title = "title" parse_mode = "HTML" caption_entities = [MessageEntity(MessageEntity.BOLD, 0, 2)] -class TestInputMediaAudioWithoutRequest(TestInputMediaAudioBase): +class TestInputMediaAudioWithoutRequest(InputMediaAudioTestBase): def test_slot_behaviour(self, input_media_audio): inst = input_media_audio for attr in inst.__slots__: @@ -382,18 +456,12 @@ def test_expected_values(self, input_media_audio): assert input_media_audio.type == self.type_ assert input_media_audio.media == self.media assert input_media_audio.caption == self.caption - assert input_media_audio.duration == self.duration + assert input_media_audio._duration == self.duration assert input_media_audio.performer == self.performer assert input_media_audio.title == self.title assert input_media_audio.parse_mode == self.parse_mode assert input_media_audio.caption_entities == tuple(self.caption_entities) assert isinstance(input_media_audio.thumbnail, InputFile) - assert input_media_audio.thumb is input_media_audio.thumbnail - - def test_thumb_property_deprecation_warning(self, recwarn): - input_media_audio = InputMediaAudio(self.media, thumb=object()) - assert input_media_audio.thumb is input_media_audio.thumbnail - check_thumb_deprecation_warnings_for_args_and_attrs(recwarn, __file__) def test_caption_entities_always_tuple(self): input_media_audio = InputMediaAudio(self.media) @@ -404,7 +472,9 @@ def test_to_dict(self, input_media_audio): assert input_media_audio_dict["type"] == input_media_audio.type assert input_media_audio_dict["media"] == input_media_audio.media assert input_media_audio_dict["caption"] == input_media_audio.caption - assert input_media_audio_dict["duration"] == input_media_audio.duration + assert isinstance(input_media_audio_dict["duration"], int) + assert input_media_audio_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(input_media_audio_dict["duration"], int) assert input_media_audio_dict["performer"] == input_media_audio.performer assert input_media_audio_dict["title"] == input_media_audio.title assert input_media_audio_dict["parse_mode"] == input_media_audio.parse_mode @@ -412,7 +482,27 @@ def test_to_dict(self, input_media_audio): ce.to_dict() for ce in input_media_audio.caption_entities ] - def test_with_audio(self, audio): # noqa: F811 + def test_time_period_properties(self, PTB_TIMEDELTA, input_media_audio): + duration = input_media_audio.duration + + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_media_audio): + input_media_audio.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + + def test_with_audio(self, audio): # fixture found in test_audio input_media_audio = InputMediaAudio(audio, caption="test 3") assert input_media_audio.type == self.type_ @@ -422,7 +512,7 @@ def test_with_audio(self, audio): # noqa: F811 assert input_media_audio.title == audio.title assert input_media_audio.caption == "test 3" - def test_with_audio_file(self, audio_file): # noqa: F811 + def test_with_audio_file(self, audio_file): # fixture found in test_audio input_media_audio = InputMediaAudio(audio_file, caption="test 3") assert input_media_audio.type == self.type_ @@ -436,16 +526,8 @@ def test_with_local_files(self): assert input_media_audio.media == data_file("telegram.mp4").as_uri() assert input_media_audio.thumbnail == data_file("telegram.jpg").as_uri() - def test_with_local_files_throws_exception_with_different_thumb_and_thumbnail(self): - with pytest.raises(ValueError, match="You passed different entities as 'thumb' and "): - InputMediaAudio( - data_file("telegram.mp4"), - thumbnail=data_file("telegram.jpg"), - thumb=data_file("telegram_no_standard_header.jpg"), - ) - -class TestInputMediaDocumentBase: +class InputMediaDocumentTestBase: type_ = "document" media = "NOTAREALFILEID" caption = "My Caption" @@ -454,7 +536,7 @@ class TestInputMediaDocumentBase: disable_content_type_detection = True -class TestInputMediaDocumentWithoutRequest(TestInputMediaDocumentBase): +class TestInputMediaDocumentWithoutRequest(InputMediaDocumentTestBase): def test_slot_behaviour(self, input_media_document): inst = input_media_document for attr in inst.__slots__: @@ -472,12 +554,6 @@ def test_expected_values(self, input_media_document): == self.disable_content_type_detection ) assert isinstance(input_media_document.thumbnail, InputFile) - assert input_media_document.thumb is input_media_document.thumbnail - - def test_thumb_property_deprecation_warning(self, recwarn): - input_media_document = InputMediaDocument(self.media, thumb=object()) - assert input_media_document.thumb is input_media_document.thumbnail - check_thumb_deprecation_warnings_for_args_and_attrs(recwarn, __file__) def test_caption_entities_always_tuple(self): input_media_document = InputMediaDocument(self.media) @@ -497,14 +573,14 @@ def test_to_dict(self, input_media_document): == input_media_document.disable_content_type_detection ) - def test_with_document(self, document): # noqa: F811 + def test_with_document(self, document): # fixture found in test_document input_media_document = InputMediaDocument(document, caption="test 3") assert input_media_document.type == self.type_ assert input_media_document.media == document.file_id assert input_media_document.caption == "test 3" - def test_with_document_file(self, document_file): # noqa: F811 + def test_with_document_file(self, document_file): # fixture found in test_document input_media_document = InputMediaDocument(document_file, caption="test 3") assert input_media_document.type == self.type_ @@ -518,17 +594,125 @@ def test_with_local_files(self): assert input_media_document.media == data_file("telegram.mp4").as_uri() assert input_media_document.thumbnail == data_file("telegram.jpg").as_uri() - def test_with_local_files_throws_exception_with_different_thumb_and_thumbnail(self): - with pytest.raises(ValueError, match="You passed different entities as 'thumb' and "): - InputMediaDocument( - data_file("telegram.mp4"), - thumbnail=data_file("telegram.jpg"), - thumb=data_file("telegram_no_standard_header.jpg"), - ) + +class TestInputPaidMediaPhotoWithoutRequest(InputMediaPhotoTestBase): + 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): + # 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): + # 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(InputMediaVideoTestBase): + 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) + assert isinstance(input_paid_media_video.cover, InputFile) + assert input_paid_media_video.start_timestamp == self.start_timestamp + + 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"] == int(self.duration.total_seconds()) + assert isinstance(input_paid_media_video_dict["duration"], int) + 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 + assert input_paid_media_video_dict["cover"] == input_paid_media_video.cover + assert ( + input_paid_media_video_dict["start_timestamp"] + == input_paid_media_video.start_timestamp + ) + + def test_time_period_properties(self, PTB_TIMEDELTA, input_paid_media_video): + duration = input_paid_media_video.duration + + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, input_paid_media_video): + input_paid_media_video.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + + def test_with_video(self, video): + # 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): + # 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"), + cover=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() + assert input_paid_media_video.cover == data_file("telegram.jpg").as_uri() @pytest.fixture(scope="module") -def media_group(photo, thumb): # noqa: F811 +def media_group(photo, thumb): return [ InputMediaPhoto(photo, caption="*photo* 1", parse_mode="Markdown"), InputMediaPhoto(thumb, caption="photo 2", parse_mode="HTML"), @@ -539,12 +723,12 @@ def media_group(photo, thumb): # noqa: F811 @pytest.fixture(scope="module") -def media_group_no_caption_args(photo, thumb): # noqa: F811 +def media_group_no_caption_args(photo, thumb): return [InputMediaPhoto(photo), InputMediaPhoto(thumb), InputMediaPhoto(photo)] @pytest.fixture(scope="module") -def media_group_no_caption_only_caption_entities(photo, thumb): # noqa: F811 +def media_group_no_caption_only_caption_entities(photo, thumb): return [ InputMediaPhoto(photo, caption_entities=[MessageEntity(MessageEntity.BOLD, 0, 5)]), InputMediaPhoto(photo, caption_entities=[MessageEntity(MessageEntity.BOLD, 0, 5)]), @@ -552,7 +736,7 @@ def media_group_no_caption_only_caption_entities(photo, thumb): # noqa: F811 @pytest.fixture(scope="module") -def media_group_no_caption_only_parse_mode(photo, thumb): # noqa: F811 +def media_group_no_caption_only_parse_mode(photo, thumb): return [ InputMediaPhoto(photo, parse_mode="Markdown"), InputMediaPhoto(thumb, parse_mode="HTML"), @@ -562,7 +746,7 @@ def media_group_no_caption_only_parse_mode(photo, thumb): # noqa: F811 class TestSendMediaGroupWithoutRequest: async def test_send_media_group_throws_error_with_group_caption_and_individual_captions( self, - bot, + offline_bot, chat_id, media_group, media_group_no_caption_only_caption_entities, @@ -575,18 +759,18 @@ async def test_send_media_group_throws_error_with_group_caption_and_individual_c ): with pytest.raises( ValueError, - match="You can only supply either group caption or media with captions.", + match="You can only supply either group caption or media with captions\\.", ): - await bot.send_media_group(chat_id, group, caption="foo") + await offline_bot.send_media_group(chat_id, group, caption="foo") async def test_send_media_group_custom_filename( self, - bot, + offline_bot, chat_id, - photo_file, # noqa: F811 - animation_file, # noqa: F811 - audio_file, # noqa: F811 - video_file, # noqa: F811 + photo_file, + animation_file, + audio_file, + video_file, monkeypatch, ): async def make_assertion(url, request_data: RequestData, *args, **kwargs): @@ -597,7 +781,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): if result is True: raise Exception("Test was successful") - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) media = [ InputMediaAnimation(animation_file, filename="custom_filename"), @@ -607,13 +791,12 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): ] with pytest.raises(Exception, match="Test was successful"): - await bot.send_media_group(chat_id, media) + await offline_bot.send_media_group(chat_id, media) async def test_send_media_group_with_thumbs( - self, bot, chat_id, video_file, photo_file, monkeypatch # noqa: F811 + self, offline_bot, chat_id, video_file, photo_file, monkeypatch ): async def make_assertion(method, url, request_data: RequestData, *args, **kwargs): - nonlocal input_video files = request_data.multipart_data video_check = files[input_video.media.attach_name] == input_video.media.field_tuple thumb_check = ( @@ -622,16 +805,16 @@ async def make_assertion(method, url, request_data: RequestData, *args, **kwargs result = video_check and thumb_check raise Exception(f"Test was {'successful' if result else 'failing'}") - monkeypatch.setattr(bot.request, "_request_wrapper", make_assertion) + monkeypatch.setattr(offline_bot.request, "_request_wrapper", make_assertion) input_video = InputMediaVideo(video_file, thumbnail=photo_file) with pytest.raises(Exception, match="Test was successful"): - await bot.send_media_group(chat_id, [input_video, input_video]) + await offline_bot.send_media_group(chat_id, [input_video, input_video]) async def test_edit_message_media_with_thumb( - self, bot, chat_id, video_file, photo_file, monkeypatch # noqa: F811 + self, offline_bot, chat_id, video_file, photo_file, monkeypatch ): async def make_assertion( - method: str, url: str, request_data: Optional[RequestData] = None, *args, **kwargs + method: str, url: str, request_data: RequestData | None = None, *args, **kwargs ): files = request_data.multipart_data video_check = files[input_video.media.attach_name] == input_video.media.field_tuple @@ -641,10 +824,39 @@ async def make_assertion( result = video_check and thumb_check raise Exception(f"Test was {'successful' if result else 'failing'}") - monkeypatch.setattr(bot.request, "_request_wrapper", make_assertion) + monkeypatch.setattr(offline_bot.request, "_request_wrapper", make_assertion) input_video = InputMediaVideo(video_file, thumbnail=photo_file) with pytest.raises(Exception, match="Test was successful"): - await bot.edit_message_media(chat_id=chat_id, message_id=123, media=input_video) + await offline_bot.edit_message_media( + chat_id=chat_id, message_id=123, media=input_video + ) + + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_media_group_default_quote_parse_mode( + self, default_bot, chat_id, media_group, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return [make_message("dummy reply").to_dict()] + + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_media_group( + chat_id, media_group, reply_parameters=ReplyParameters(**kwargs) + ) class CustomSequence(Sequence): @@ -665,14 +877,12 @@ async def test_send_media_group_photo(self, bot, chat_id, media_group): assert len(messages) == 3 assert all(isinstance(mes, Message) for mes in messages) assert all(mes.media_group_id == messages[0].media_group_id for mes in messages) - assert all(mes.caption == f"photo {idx+1}" for idx, mes in enumerate(messages)) + assert all(mes.caption == f"photo {idx + 1}" for idx, mes in enumerate(messages)) assert all( mes.caption_entities == (MessageEntity(MessageEntity.BOLD, 0, 5),) for mes in messages ) - async def test_send_media_group_new_files( - self, bot, chat_id, video_file, photo_file # noqa: F811 - ): + async def test_send_media_group_new_files(self, bot, chat_id, video_file, photo_file): async def func(): return await bot.send_media_group( chat_id, @@ -708,7 +918,7 @@ async def test_send_media_group_different_sequences( assert all(mes.media_group_id == messages[0].media_group_id for mes in messages) async def test_send_media_group_with_message_thread_id( - self, bot, real_topic, forum_group_id, media_group # noqa: F811 + self, bot, real_topic, forum_group_id, media_group ): messages = await bot.send_media_group( forum_group_id, @@ -793,23 +1003,22 @@ async def test_send_media_group_all_args(self, bot, raw_bot, chat_id, media_grou # make sure that the media_group was not modified assert media_group == copied_media_group assert all( - a.parse_mode == b.parse_mode for a, b in zip(media_group, copied_media_group) + a.parse_mode == b.parse_mode + for a, b in zip(media_group, copied_media_group, strict=False) ) assert isinstance(messages, tuple) assert len(messages) == 3 assert all(isinstance(mes, Message) for mes in messages) assert all(mes.media_group_id == messages[0].media_group_id for mes in messages) - assert all(mes.caption == f"photo {idx+1}" for idx, mes in enumerate(messages)) + assert all(mes.caption == f"photo {idx + 1}" for idx, mes in enumerate(messages)) assert all( mes.caption_entities == (MessageEntity(MessageEntity.BOLD, 0, 5),) for mes in messages ) assert all(mes.has_protected_content for mes in messages) - async def test_send_media_group_with_spoiler( - self, bot, chat_id, photo_file, video_file # noqa: F811 - ): + async def test_send_media_group_with_spoiler(self, bot, chat_id, photo_file, video_file): # Media groups can't contain Animations, so that is tested in test_animation.py media = [ InputMediaPhoto(photo_file, has_spoiler=True), @@ -880,7 +1089,7 @@ async def test_send_media_group_default_allow_sending_without_reply( ) assert [m.reply_to_message is None for m in messages] else: - with pytest.raises(BadRequest, match="message not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_media_group( chat_id, media_group, reply_to_message_id=reply_to_message.message_id ) @@ -952,11 +1161,11 @@ async def test_edit_message_media_default_parse_mode( chat_id, default_bot, media_type, - animation, # noqa: F811 - document, # noqa: F811 - audio, # noqa: F811 - photo, # noqa: F811 - video, # noqa: F811 + animation, + document, + audio, + photo, + video, ): html_caption = "bold italic code" markdown_caption = "*bold* _italic_ `code`" @@ -1030,3 +1239,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, chat_id, photo_file, video_file): + msg = await bot.send_paid_media( + chat_id=chat_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/_files/test_inputprofilephoto.py b/tests/_files/test_inputprofilephoto.py new file mode 100644 index 00000000000..0eddcdf469d --- /dev/null +++ b/tests/_files/test_inputprofilephoto.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm + +import pytest + +from telegram import ( + InputFile, + InputProfilePhoto, + InputProfilePhotoAnimated, + InputProfilePhotoStatic, +) +from telegram.constants import InputProfilePhotoType +from tests.auxil.files import data_file +from tests.auxil.slots import mro_slots + + +class TestInputProfilePhotoWithoutRequest: + def test_type_enum_conversion(self): + instance = InputProfilePhoto(type="static") + assert isinstance(instance.type, InputProfilePhotoType) + assert instance.type is InputProfilePhotoType.STATIC + + instance = InputProfilePhoto(type="animated") + assert isinstance(instance.type, InputProfilePhotoType) + assert instance.type is InputProfilePhotoType.ANIMATED + + instance = InputProfilePhoto(type="unknown") + assert isinstance(instance.type, str) + assert instance.type == "unknown" + + +@pytest.fixture(scope="module") +def input_profile_photo_static(): + return InputProfilePhotoStatic(photo=InputProfilePhotoStaticTestBase.photo.read_bytes()) + + +class InputProfilePhotoStaticTestBase: + type_ = "static" + photo = data_file("telegram.jpg") + + +class TestInputProfilePhotoStaticWithoutRequest(InputProfilePhotoStaticTestBase): + def test_slot_behaviour(self, input_profile_photo_static): + inst = input_profile_photo_static + 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_profile_photo_static): + inst = input_profile_photo_static + assert inst.type == self.type_ + assert isinstance(inst.photo, InputFile) + + def test_to_dict(self, input_profile_photo_static): + inst = input_profile_photo_static + data = inst.to_dict() + assert data["type"] == self.type_ + assert data["photo"] == inst.photo + + def test_with_local_file(self): + inst = InputProfilePhotoStatic(photo=data_file("telegram.jpg")) + assert inst.photo == data_file("telegram.jpg").as_uri() + + def test_type_enum_conversion(self, input_profile_photo_static): + assert input_profile_photo_static.type is InputProfilePhotoType.STATIC + + +@pytest.fixture(scope="module") +def input_profile_photo_animated(): + return InputProfilePhotoAnimated( + animation=InputProfilePhotoAnimatedTestBase.animation.read_bytes(), + main_frame_timestamp=InputProfilePhotoAnimatedTestBase.main_frame_timestamp, + ) + + +class InputProfilePhotoAnimatedTestBase: + type_ = "animated" + animation = data_file("telegram2.mp4") + main_frame_timestamp = dtm.timedelta(seconds=42, milliseconds=43) + + +class TestInputProfilePhotoAnimatedWithoutRequest(InputProfilePhotoAnimatedTestBase): + def test_slot_behaviour(self, input_profile_photo_animated): + inst = input_profile_photo_animated + 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_profile_photo_animated): + inst = input_profile_photo_animated + assert inst.type == self.type_ + assert isinstance(inst.animation, InputFile) + assert inst.main_frame_timestamp == self.main_frame_timestamp + + def test_to_dict(self, input_profile_photo_animated): + inst = input_profile_photo_animated + data = inst.to_dict() + assert data["type"] == self.type_ + assert data["animation"] == inst.animation + assert data["main_frame_timestamp"] == self.main_frame_timestamp.total_seconds() + + def test_with_local_file(self): + inst = InputProfilePhotoAnimated( + animation=data_file("telegram2.mp4"), + main_frame_timestamp=self.main_frame_timestamp, + ) + assert inst.animation == data_file("telegram2.mp4").as_uri() + + def test_type_enum_conversion(self, input_profile_photo_animated): + assert input_profile_photo_animated.type is InputProfilePhotoType.ANIMATED + + @pytest.mark.parametrize( + "timestamp", + [ + dtm.timedelta(days=2), + dtm.timedelta(seconds=2 * 24 * 60 * 60), + 2 * 24 * 60 * 60, + float(2 * 24 * 60 * 60), + ], + ) + def test_main_frame_timestamp_conversion(self, timestamp): + inst = InputProfilePhotoAnimated( + animation=self.animation, + main_frame_timestamp=timestamp, + ) + assert isinstance(inst.main_frame_timestamp, dtm.timedelta) + assert inst.main_frame_timestamp == dtm.timedelta(days=2) + + assert ( + InputProfilePhotoAnimated( + animation=self.animation, + main_frame_timestamp=None, + ).main_frame_timestamp + is None + ) diff --git a/tests/_files/test_inputsticker.py b/tests/_files/test_inputsticker.py index 780a7fbbac3..43af66f356c 100644 --- a/tests/_files/test_inputsticker.py +++ b/tests/_files/test_inputsticker.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -21,7 +21,6 @@ from telegram import InputSticker, MaskPosition from telegram._files.inputfile import InputFile -from tests._files.test_sticker import video_sticker_file # noqa: F401 from tests.auxil.files import data_file from tests.auxil.slots import mro_slots @@ -29,21 +28,23 @@ @pytest.fixture(scope="module") def input_sticker(): return InputSticker( - sticker=TestInputStickerBase.sticker, - emoji_list=TestInputStickerBase.emoji_list, - mask_position=TestInputStickerBase.mask_position, - keywords=TestInputStickerBase.keywords, + sticker=InputStickerTestBase.sticker, + emoji_list=InputStickerTestBase.emoji_list, + mask_position=InputStickerTestBase.mask_position, + keywords=InputStickerTestBase.keywords, + format=InputStickerTestBase.format, ) -class TestInputStickerBase: +class InputStickerTestBase: sticker = "fake_file_id" emoji_list = ("👍", "👎") mask_position = MaskPosition("forehead", 0.5, 0.5, 0.5) keywords = ("thumbsup", "thumbsdown") + format = "static" -class TestInputStickerNoRequest(TestInputStickerBase): +class TestInputStickerWithoutRequest(InputStickerTestBase): def test_slot_behaviour(self, input_sticker): inst = input_sticker for attr in inst.__slots__: @@ -56,11 +57,12 @@ def test_expected_values(self, input_sticker): assert input_sticker.emoji_list == self.emoji_list assert input_sticker.mask_position == self.mask_position assert input_sticker.keywords == self.keywords + assert input_sticker.format == self.format def test_attributes_tuple(self, input_sticker): assert isinstance(input_sticker.keywords, tuple) assert isinstance(input_sticker.emoji_list, tuple) - a = InputSticker("sticker", ["emoji"]) + a = InputSticker("sticker", ["emoji"], "static") assert isinstance(a.emoji_list, tuple) assert a.keywords == () @@ -72,9 +74,10 @@ def test_to_dict(self, input_sticker): assert input_sticker_dict["emoji_list"] == list(input_sticker.emoji_list) assert input_sticker_dict["mask_position"] == input_sticker.mask_position.to_dict() assert input_sticker_dict["keywords"] == list(input_sticker.keywords) + assert input_sticker_dict["format"] == input_sticker.format - def test_with_sticker_input_types(self, video_sticker_file): # noqa: F811 - sticker = InputSticker(sticker=video_sticker_file, emoji_list=["👍"]) + def test_with_sticker_input_types(self, video_sticker_file): + sticker = InputSticker(sticker=video_sticker_file, emoji_list=["👍"], format="video") assert isinstance(sticker.sticker, InputFile) - sticker = InputSticker(data_file("telegram_video_sticker.webm"), ["👍"]) + sticker = InputSticker(data_file("telegram_video_sticker.webm"), ["👍"], "video") assert sticker.sticker == data_file("telegram_video_sticker.webm").as_uri() diff --git a/tests/_files/test_inputstorycontent.py b/tests/_files/test_inputstorycontent.py new file mode 100644 index 00000000000..7c923071b90 --- /dev/null +++ b/tests/_files/test_inputstorycontent.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import datetime as dtm + +import pytest + +from telegram import InputFile, InputStoryContent, InputStoryContentPhoto, InputStoryContentVideo +from telegram.constants import InputStoryContentType +from tests.auxil.files import data_file +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def input_story_content(): + return InputStoryContent( + type=InputStoryContentTestBase.type, + ) + + +class InputStoryContentTestBase: + type = InputStoryContent.PHOTO + + +class TestInputStoryContent(InputStoryContentTestBase): + def test_slot_behaviour(self, input_story_content): + inst = input_story_content + 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_type_enum_conversion(self): + assert type(InputStoryContent(type="video").type) is InputStoryContentType + assert InputStoryContent(type="unknown").type == "unknown" + + +@pytest.fixture(scope="module") +def input_story_content_photo(): + return InputStoryContentPhoto(photo=InputStoryContentPhotoTestBase.photo.read_bytes()) + + +class InputStoryContentPhotoTestBase: + type = InputStoryContentType.PHOTO + photo = data_file("telegram.jpg") + + +class TestInputStoryContentPhotoWithoutRequest(InputStoryContentPhotoTestBase): + def test_slot_behaviour(self, input_story_content_photo): + inst = input_story_content_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_story_content_photo): + inst = input_story_content_photo + assert inst.type is self.type + assert isinstance(inst.photo, InputFile) + + def test_to_dict(self, input_story_content_photo): + inst = input_story_content_photo + json_dict = inst.to_dict() + assert json_dict["type"] is self.type + assert json_dict["photo"] == inst.photo + + def test_with_photo_file(self, photo_file): + inst = InputStoryContentPhoto(photo=photo_file) + assert inst.type is self.type + assert isinstance(inst.photo, InputFile) + + def test_with_local_files(self): + inst = InputStoryContentPhoto(photo=data_file("telegram.jpg")) + assert inst.photo == data_file("telegram.jpg").as_uri() + + +@pytest.fixture(scope="module") +def input_story_content_video(): + return InputStoryContentVideo( + video=InputStoryContentVideoTestBase.video.read_bytes(), + duration=InputStoryContentVideoTestBase.duration, + cover_frame_timestamp=InputStoryContentVideoTestBase.cover_frame_timestamp, + is_animation=InputStoryContentVideoTestBase.is_animation, + ) + + +class InputStoryContentVideoTestBase: + type = InputStoryContentType.VIDEO + video = data_file("telegram.mp4") + duration = dtm.timedelta(seconds=30) + cover_frame_timestamp = dtm.timedelta(seconds=15) + is_animation = False + + +class TestInputStoryContentVideoWithoutRequest(InputStoryContentVideoTestBase): + def test_slot_behaviour(self, input_story_content_video): + inst = input_story_content_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_story_content_video): + inst = input_story_content_video + assert inst.type is self.type + assert isinstance(inst.video, InputFile) + assert inst.duration == self.duration + assert inst.cover_frame_timestamp == self.cover_frame_timestamp + assert inst.is_animation is self.is_animation + + def test_to_dict(self, input_story_content_video): + inst = input_story_content_video + json_dict = inst.to_dict() + assert json_dict["type"] is self.type + assert json_dict["video"] == inst.video + assert json_dict["duration"] == self.duration.total_seconds() + assert json_dict["cover_frame_timestamp"] == self.cover_frame_timestamp.total_seconds() + assert json_dict["is_animation"] is self.is_animation + + @pytest.mark.parametrize( + ("argument", "expected"), + [(4, 4), (4.0, 4), (dtm.timedelta(seconds=4), 4), (4.5, 4.5)], + ) + def test_to_dict_float_time_period(self, argument, expected): + # We test that whole number conversion works properly. Only tested here but + # relevant for some other classes too (e.g InputProfilePhotoAnimated.main_frame_timestamp) + inst = InputStoryContentVideo( + video=self.video.read_bytes(), + duration=argument, + cover_frame_timestamp=argument, + ) + json_dict = inst.to_dict() + + assert json_dict["duration"] == expected + assert type(json_dict["duration"]) is type(expected) + assert json_dict["cover_frame_timestamp"] == expected + assert type(json_dict["cover_frame_timestamp"]) is type(expected) + + def test_with_video_file(self, video_file): + inst = InputStoryContentVideo(video=video_file) + assert inst.type is self.type + assert isinstance(inst.video, InputFile) + + def test_with_local_files(self): + inst = InputStoryContentVideo(video=data_file("telegram.mp4")) + assert inst.video == data_file("telegram.mp4").as_uri() + + @pytest.mark.parametrize("timestamp", [dtm.timedelta(seconds=60), 60, float(60)]) + @pytest.mark.parametrize("field", ["duration", "cover_frame_timestamp"]) + def test_time_period_arg_conversion(self, field, timestamp): + inst = InputStoryContentVideo( + video=self.video, + **{field: timestamp}, + ) + value = getattr(inst, field) + assert isinstance(value, dtm.timedelta) + assert value == dtm.timedelta(seconds=60) + + inst = InputStoryContentVideo( + video=self.video, + **{field: None}, + ) + value = getattr(inst, field) + assert value is None diff --git a/tests/_files/test_location.py b/tests/_files/test_location.py index aef25c9706b..0deaea2e2f5 100644 --- a/tests/_files/test_location.py +++ b/tests/_files/test_location.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,58 +17,62 @@ # 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 asyncio +import datetime as dtm import pytest -from telegram import Location +from telegram import Location, ReplyParameters +from telegram.constants import ParseMode from telegram.error import BadRequest from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning +from tests.auxil.build_messages import make_message from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def location(): return Location( - latitude=TestLocationBase.latitude, - longitude=TestLocationBase.longitude, - horizontal_accuracy=TestLocationBase.horizontal_accuracy, - live_period=TestLocationBase.live_period, - heading=TestLocationBase.live_period, - proximity_alert_radius=TestLocationBase.proximity_alert_radius, + latitude=LocationTestBase.latitude, + longitude=LocationTestBase.longitude, + horizontal_accuracy=LocationTestBase.horizontal_accuracy, + live_period=LocationTestBase.live_period, + heading=LocationTestBase.live_period, + proximity_alert_radius=LocationTestBase.proximity_alert_radius, ) -class TestLocationBase: +class LocationTestBase: latitude = -23.691288 longitude = -46.788279 horizontal_accuracy = 999 - live_period = 60 + live_period = dtm.timedelta(seconds=60) heading = 90 proximity_alert_radius = 50 -class TestLocationWithoutRequest(TestLocationBase): +class TestLocationWithoutRequest(LocationTestBase): def test_slot_behaviour(self, location): for attr in location.__slots__: assert getattr(location, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(location)) == len(set(mro_slots(location))), "duplicate slot" - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "latitude": self.latitude, "longitude": self.longitude, "horizontal_accuracy": self.horizontal_accuracy, - "live_period": self.live_period, + "live_period": int(self.live_period.total_seconds()), "heading": self.heading, "proximity_alert_radius": self.proximity_alert_radius, } - location = Location.de_json(json_dict, bot) + location = Location.de_json(json_dict, offline_bot) assert location.api_kwargs == {} assert location.latitude == self.latitude assert location.longitude == self.longitude assert location.horizontal_accuracy == self.horizontal_accuracy - assert location.live_period == self.live_period + assert location._live_period == self.live_period assert location.heading == self.heading assert location.proximity_alert_radius == self.proximity_alert_radius @@ -78,10 +82,29 @@ def test_to_dict(self, location): assert location_dict["latitude"] == location.latitude assert location_dict["longitude"] == location.longitude assert location_dict["horizontal_accuracy"] == location.horizontal_accuracy - assert location_dict["live_period"] == location.live_period + assert location_dict["live_period"] == int(self.live_period.total_seconds()) + assert isinstance(location_dict["live_period"], int) assert location["heading"] == location.heading assert location["proximity_alert_radius"] == location.proximity_alert_radius + def test_time_period_properties(self, PTB_TIMEDELTA, location): + if PTB_TIMEDELTA: + assert location.live_period == self.live_period + assert isinstance(location.live_period, dtm.timedelta) + else: + assert location.live_period == int(self.live_period.total_seconds()) + assert isinstance(location.live_period, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, location): + location.live_period + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`live_period` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = Location(self.longitude, self.latitude) b = Location(self.longitude, self.latitude) @@ -94,26 +117,28 @@ def test_equality(self): assert a != d assert hash(a) != hash(d) - async def test_send_location_without_required(self, bot, chat_id): + async def test_send_location_without_required(self, offline_bot, chat_id): with pytest.raises(ValueError, match="Either location or latitude and longitude"): - await bot.send_location(chat_id=chat_id) + await offline_bot.send_location(chat_id=chat_id) - async def test_edit_location_without_required(self, bot): + async def test_edit_location_without_required(self, offline_bot): with pytest.raises(ValueError, match="Either location or latitude and longitude"): - await bot.edit_message_live_location(chat_id=2, message_id=3) + await offline_bot.edit_message_live_location(chat_id=2, message_id=3) - async def test_send_location_with_all_args(self, bot, location): + async def test_send_location_with_all_args(self, offline_bot, location): with pytest.raises(ValueError, match="Not both"): - await bot.send_location(chat_id=1, latitude=2.5, longitude=4.6, location=location) + await offline_bot.send_location( + chat_id=1, latitude=2.5, longitude=4.6, location=location + ) - async def test_edit_location_with_all_args(self, bot, location): + async def test_edit_location_with_all_args(self, offline_bot, location): with pytest.raises(ValueError, match="Not both"): - await bot.edit_message_live_location( + await offline_bot.edit_message_live_location( chat_id=1, message_id=7, latitude=2.5, longitude=4.6, location=location ) # TODO: Needs improvement with in inline sent live location. - async def test_edit_live_inline_message(self, monkeypatch, bot, location): + async def test_edit_live_inline_message(self, monkeypatch, offline_bot, location): async def make_assertion(url, request_data: RequestData, *args, **kwargs): data = request_data.json_parameters lat = data["latitude"] == str(location.latitude) @@ -122,42 +147,71 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): ha = data["horizontal_accuracy"] == "50" heading = data["heading"] == "90" prox_alert = data["proximity_alert_radius"] == "1000" - return lat and lon and id_ and ha and heading and prox_alert + live = data["live_period"] == "900" + return lat and lon and id_ and ha and heading and prox_alert and live - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.edit_message_live_location( + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.edit_message_live_location( inline_message_id=1234, location=location, horizontal_accuracy=50, heading=90, proximity_alert_radius=1000, + live_period=900, ) # TODO: Needs improvement with in inline sent live location. - async def test_stop_live_inline_message(self, monkeypatch, bot): + async def test_stop_live_inline_message(self, monkeypatch, offline_bot): async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.json_parameters["inline_message_id"] == "1234" - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.stop_message_live_location(inline_message_id=1234) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.stop_message_live_location(inline_message_id=1234) - async def test_send_with_location(self, monkeypatch, bot, chat_id, location): + async def test_send_with_location(self, monkeypatch, offline_bot, chat_id, location): async def make_assertion(url, request_data: RequestData, *args, **kwargs): lat = request_data.json_parameters["latitude"] == str(location.latitude) lon = request_data.json_parameters["longitude"] == str(location.longitude) return lat and lon - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.send_location(location=location, chat_id=chat_id) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_location(location=location, chat_id=chat_id) - async def test_edit_live_location_with_location(self, monkeypatch, bot, location): + async def test_edit_live_location_with_location(self, monkeypatch, offline_bot, location): async def make_assertion(url, request_data: RequestData, *args, **kwargs): lat = request_data.json_parameters["latitude"] == str(location.latitude) lon = request_data.json_parameters["longitude"] == str(location.longitude) return lat and lon - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.edit_message_live_location(None, None, location=location) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.edit_message_live_location(None, None, location=location) + + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_location_default_quote_parse_mode( + self, default_bot, chat_id, location, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() + + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_location( + chat_id, location=location, reply_parameters=ReplyParameters(**kwargs) + ) class TestLocationWithRequest: @@ -189,7 +243,7 @@ async def test_send_location_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="message not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_location( chat_id, location=location, reply_to_message_id=reply_to_message.message_id ) @@ -204,13 +258,17 @@ async def test_send_location_default_protect_content(self, chat_id, default_bot, assert protected.has_protected_content assert not unprotected.has_protected_content - @pytest.mark.xfail() - async def test_send_live_location(self, bot, chat_id): + @pytest.mark.xfail + @pytest.mark.parametrize( + ("live_period", "edit_live_period"), + [(80, 200), (dtm.timedelta(seconds=80), dtm.timedelta(seconds=200))], + ) + async def test_send_live_location(self, bot, chat_id, live_period, edit_live_period): message = await bot.send_location( chat_id=chat_id, latitude=52.223880, longitude=5.166146, - live_period=80, + live_period=live_period, horizontal_accuracy=50, heading=90, proximity_alert_radius=1000, @@ -233,6 +291,7 @@ async def test_send_live_location(self, bot, chat_id): horizontal_accuracy=30, heading=10, proximity_alert_radius=500, + live_period=edit_live_period, ) assert pytest.approx(message2.location.latitude, rel=1e-5) == 52.223098 @@ -240,8 +299,9 @@ async def test_send_live_location(self, bot, chat_id): assert message2.location.horizontal_accuracy == 30 assert message2.location.heading == 10 assert message2.location.proximity_alert_radius == 500 + assert message2.location.live_period == 200 - await bot.stop_message_live_location(message.chat_id, message.message_id) + assert await bot.stop_message_live_location(message.chat_id, message.message_id) with pytest.raises(BadRequest, match="Message can't be edited"): await bot.edit_message_live_location( message.chat_id, message.message_id, latitude=52.223880, longitude=5.164306 diff --git a/tests/_files/test_photo.py b/tests/_files/test_photo.py index 939b7d7abb8..83ba28ea701 100644 --- a/tests/_files/test_photo.py +++ b/tests/_files/test_photo.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -22,7 +22,8 @@ import pytest -from telegram import Bot, InputFile, MessageEntity, PhotoSize, Sticker +from telegram import Bot, InputFile, MessageEntity, PhotoSize, ReplyParameters, Sticker +from telegram.constants import ParseMode from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData @@ -31,49 +32,22 @@ check_shortcut_call, check_shortcut_signature, ) +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file -from tests.auxil.networking import expect_bad_request from tests.auxil.slots import mro_slots -@pytest.fixture() -def photo_file(): - with data_file("telegram.jpg").open("rb") as f: - yield f - - -@pytest.fixture(scope="module") -async def photolist(bot, chat_id): - async def func(): - with data_file("telegram.jpg").open("rb") as f: - return (await bot.send_photo(chat_id, photo=f, read_timeout=50)).photo - - return await expect_bad_request( - func, "Type of file mismatch", "Telegram did not accept the file." - ) - - -@pytest.fixture(scope="module") -def thumb(photolist): - return photolist[0] - - -@pytest.fixture(scope="module") -def photo(photolist): - return photolist[-1] - - -class TestPhotoBase: +class PhotoTestBase: width = 800 height = 800 caption = "PhotoTest - *Caption*" photo_file_url = "https://python-telegram-bot.org/static/testfiles/telegram_new.jpg" # For some reason the file size is not the same after switching to httpx # so we accept three different sizes here. Shouldn't be too much - file_size = [29176, 27662] + file_size = [29176, 27662, 27330] -class TestPhotoWithoutRequest(TestPhotoBase): +class TestPhotoWithoutRequest(PhotoTestBase): def test_slot_behaviour(self, photo): for attr in photo.__slots__: assert getattr(photo, attr, "err") != "err", f"got extra slot '{attr}'" @@ -99,9 +73,11 @@ def test_expected_values(self, photo, thumb): assert photo.file_size in self.file_size assert thumb.width == 90 assert thumb.height == 90 - assert thumb.file_size == 1477 + # File sizes don't seem to be consistent, so we use the values that we have observed + # so far + assert thumb.file_size in [1475, 1477] - def test_de_json(self, bot, photo): + def test_de_json(self, offline_bot, photo): json_dict = { "file_id": photo.file_id, "file_unique_id": photo.file_unique_id, @@ -109,7 +85,7 @@ def test_de_json(self, bot, photo): "height": self.height, "file_size": self.file_size, } - json_photo = PhotoSize.de_json(json_dict, bot) + json_photo = PhotoSize.de_json(json_dict, offline_bot) assert json_photo.api_kwargs == {} assert json_photo.file_id == photo.file_id @@ -156,22 +132,24 @@ def test_equality(self, photo): assert a != e assert hash(a) != hash(e) - async def test_error_without_required_args(self, bot, chat_id): + async def test_error_without_required_args(self, offline_bot, chat_id): with pytest.raises(TypeError): - await bot.send_photo(chat_id=chat_id) + await offline_bot.send_photo(chat_id=chat_id) - async def test_send_photo_custom_filename(self, bot, chat_id, photo_file, monkeypatch): + async def test_send_photo_custom_filename(self, offline_bot, chat_id, photo_file, monkeypatch): async def make_assertion(url, request_data: RequestData, *args, **kwargs): - return list(request_data.multipart_data.values())[0][0] == "custom_filename" + return next(iter(request_data.multipart_data.values()))[0] == "custom_filename" - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.send_photo(chat_id, photo_file, filename="custom_filename") + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_photo(chat_id, photo_file, filename="custom_filename") @pytest.mark.parametrize("local_mode", [True, False]) - async def test_send_photo_local_files(self, monkeypatch, bot, chat_id, local_mode): + async def test_send_photo_local_files( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): try: - bot._local_mode = local_mode - # For just test that the correct paths are passed as we have no local bot API set up + offline_bot._local_mode = local_mode + # For just test that the correct paths are passed as we have no local Bot API set up test_flag = False file = data_file("telegram.jpg") expected = file.as_uri() @@ -182,19 +160,20 @@ async def make_assertion(_, data, *args, **kwargs): test_flag = data.get("photo") == expected else: test_flag = isinstance(data.get("photo"), InputFile) + return dummy_message_dict - monkeypatch.setattr(bot, "_post", make_assertion) - await bot.send_photo(chat_id, file) + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.send_photo(chat_id, file) assert test_flag finally: - bot._local_mode = False + offline_bot._local_mode = False - async def test_send_with_photosize(self, monkeypatch, bot, chat_id, photo): + async def test_send_with_photosize(self, monkeypatch, offline_bot, chat_id, photo): async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.json_parameters["photo"] == photo.file_id - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.send_photo(photo=photo, chat_id=chat_id) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_photo(photo=photo, chat_id=chat_id) async def test_get_file_instance_method(self, monkeypatch, photo): async def make_assertion(*_, **kwargs): @@ -207,8 +186,33 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(photo.get_bot(), "get_file", make_assertion) assert await photo.get_file() + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_photo_default_quote_parse_mode( + self, default_bot, chat_id, photo, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() + + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom -class TestPhotoWithRequest(TestPhotoBase): + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_photo(chat_id, photo, reply_parameters=ReplyParameters(**kwargs)) + + +class TestPhotoWithRequest(PhotoTestBase): async def test_send_photo_all_args(self, bot, chat_id, photo_file): message = await bot.send_photo( chat_id, @@ -218,6 +222,7 @@ async def test_send_photo_all_args(self, bot, chat_id, photo_file): protect_content=True, parse_mode="Markdown", has_spoiler=True, + show_caption_above_media=True, ) assert isinstance(message.photo[-2], PhotoSize) @@ -235,6 +240,7 @@ async def test_send_photo_all_args(self, bot, chat_id, photo_file): assert message.caption == self.caption.replace("*", "") assert message.has_protected_content assert message.has_media_spoiler + assert message.show_caption_above_media async def test_send_photo_parse_mode_markdown(self, bot, chat_id, photo_file): message = await bot.send_photo( @@ -355,25 +361,21 @@ async def test_send_photo_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="message not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_photo( chat_id, photo_file, reply_to_message_id=reply_to_message.message_id ) - async def test_get_and_download(self, bot, photo): - path = Path("telegram.jpg") - if path.is_file(): - path.unlink() - + async def test_get_and_download(self, bot, photo, tmp_file): new_file = await bot.getFile(photo.file_id) assert new_file.file_size == photo.file_size assert new_file.file_unique_id == photo.file_unique_id assert new_file.file_path.startswith("https://") is True - await new_file.download_to_drive("telegram.jpg") + await new_file.download_to_drive(tmp_file) - assert path.is_file() + assert tmp_file.is_file() async def test_send_url_jpg_file(self, bot, chat_id): message = await bot.send_photo(chat_id, photo=self.photo_file_url) diff --git a/tests/_files/test_sticker.py b/tests/_files/test_sticker.py index 9db341305e4..798e3fc08f3 100644 --- a/tests/_files/test_sticker.py +++ b/tests/_files/test_sticker.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -32,64 +32,24 @@ InputSticker, MaskPosition, PhotoSize, + ReplyParameters, Sticker, StickerSet, ) -from telegram.constants import StickerFormat +from telegram.constants import ParseMode, StickerFormat, StickerType from telegram.error import BadRequest, TelegramError from telegram.request import RequestData -from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, check_shortcut_signature, ) -from tests.auxil.deprecations import check_thumb_deprecation_warnings_for_args_and_attrs +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file from tests.auxil.slots import mro_slots -@pytest.fixture() -def sticker_file(): - with data_file("telegram.webp").open("rb") as file: - yield file - - -@pytest.fixture(scope="module") -async def sticker(bot, chat_id): - with data_file("telegram.webp").open("rb") as f: - sticker = (await bot.send_sticker(chat_id, sticker=f, read_timeout=50)).sticker - # necessary to properly test needs_repainting - with sticker._unfrozen(): - sticker.needs_repainting = TestStickerBase.needs_repainting - return sticker - - -@pytest.fixture() -def animated_sticker_file(): - with data_file("telegram_animated_sticker.tgs").open("rb") as f: - yield f - - -@pytest.fixture(scope="module") -async def animated_sticker(bot, chat_id): - with data_file("telegram_animated_sticker.tgs").open("rb") as f: - return (await bot.send_sticker(chat_id, sticker=f, read_timeout=50)).sticker - - -@pytest.fixture() -def video_sticker_file(): - with data_file("telegram_video_sticker.webm").open("rb") as f: - yield f - - -@pytest.fixture(scope="module") -def video_sticker(bot, chat_id): - with data_file("telegram_video_sticker.webm").open("rb") as f: - return bot.send_sticker(chat_id, sticker=f, timeout=50).sticker - - -class TestStickerBase: +class StickerTestBase: # sticker_file_url = 'https://python-telegram-bot.org/static/testfiles/telegram.webp' # Serving sticker from gh since our server sends wrong content_type sticker_file_url = ( @@ -105,7 +65,7 @@ class TestStickerBase: file_size = 39518 thumb_width = 319 thumb_height = 320 - thumb_file_size = 21472 + thumb_file_size = 21448 type = Sticker.REGULAR custom_emoji_id = "ThisIsSuchACustomEmojiID" needs_repainting = True @@ -116,7 +76,7 @@ class TestStickerBase: premium_animation = File("this_is_an_id", "this_is_an_unique_id") -class TestStickerWithoutRequest(TestStickerBase): +class TestStickerWithoutRequest(StickerTestBase): def test_slot_behaviour(self, sticker): for attr in sticker.__slots__: assert getattr(sticker, attr, "err") != "err", f"got extra slot '{attr}'" @@ -150,20 +110,6 @@ def test_expected_values(self, sticker): # we need to be a premium TG user to send a premium sticker, so the below is not tested # assert sticker.premium_animation == self.premium_animation - def test_thumb_property_deprecation_warning(self, recwarn): - sticker = Sticker( - file_id="id", - file_unique_id="unique_id", - width=1, - height=1, - thumb=object(), - is_animated=False, - is_video=False, - type=Sticker.REGULAR, - ) - assert sticker.thumb is sticker.thumbnail - check_thumb_deprecation_warnings_for_args_and_attrs(recwarn, __file__) - def test_to_dict(self, sticker): sticker_dict = sticker.to_dict() @@ -179,7 +125,7 @@ def test_to_dict(self, sticker): assert sticker_dict["type"] == sticker.type assert sticker_dict["needs_repainting"] == sticker.needs_repainting - def test_de_json(self, bot, sticker): + def test_de_json(self, offline_bot, sticker): json_dict = { "file_id": self.sticker_file_id, "file_unique_id": self.sticker_file_unique_id, @@ -195,7 +141,7 @@ def test_de_json(self, bot, sticker): "custom_emoji_id": self.custom_emoji_id, "needs_repainting": self.needs_repainting, } - json_sticker = Sticker.de_json(json_dict, bot) + json_sticker = Sticker.de_json(json_dict, offline_bot) assert json_sticker.api_kwargs == {} assert json_sticker.file_id == self.sticker_file_id @@ -212,6 +158,34 @@ def test_de_json(self, bot, sticker): assert json_sticker.custom_emoji_id == self.custom_emoji_id assert json_sticker.needs_repainting == self.needs_repainting + def test_type_enum_conversion(self): + assert ( + type( + Sticker( + file_id=self.sticker_file_id, + file_unique_id=self.sticker_file_unique_id, + width=self.width, + height=self.height, + is_animated=self.is_animated, + is_video=self.is_video, + type="regular", + ).type + ) + is StickerType + ) + assert ( + Sticker( + file_id=self.sticker_file_id, + file_unique_id=self.sticker_file_unique_id, + width=self.width, + height=self.height, + is_animated=self.is_animated, + is_video=self.is_video, + type="unknown", + ).type + == "unknown" + ) + def test_equality(self, sticker): a = Sticker( sticker.file_id, @@ -270,22 +244,24 @@ def test_equality(self, sticker): assert a != e assert hash(a) != hash(e) - async def test_error_without_required_args(self, bot, chat_id): + async def test_error_without_required_args(self, offline_bot, chat_id): with pytest.raises(TypeError): - await bot.send_sticker(chat_id) + await offline_bot.send_sticker(chat_id) - async def test_send_with_sticker(self, monkeypatch, bot, chat_id, sticker): + async def test_send_with_sticker(self, monkeypatch, offline_bot, chat_id, sticker): async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.json_parameters["sticker"] == sticker.file_id - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.send_sticker(sticker=sticker, chat_id=chat_id) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_sticker(sticker=sticker, chat_id=chat_id) @pytest.mark.parametrize("local_mode", [True, False]) - async def test_send_sticker_local_files(self, monkeypatch, bot, chat_id, local_mode): + async def test_send_sticker_local_files( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): try: - bot._local_mode = local_mode - # For just test that the correct paths are passed as we have no local bot API set up + offline_bot._local_mode = local_mode + # For just test that the correct paths are passed as we have no local Bot API set up test_flag = False file = data_file("telegram.jpg") expected = file.as_uri() @@ -296,15 +272,43 @@ async def make_assertion(_, data, *args, **kwargs): test_flag = data.get("sticker") == expected else: test_flag = isinstance(data.get("sticker"), InputFile) + return dummy_message_dict - monkeypatch.setattr(bot, "_post", make_assertion) - await bot.send_sticker(chat_id, file) + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.send_sticker(chat_id, file) assert test_flag finally: - bot._local_mode = False + offline_bot._local_mode = False + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_sticker_default_quote_parse_mode( + self, default_bot, chat_id, sticker, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() -class TestStickerWithRequest(TestStickerBase): + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_sticker( + chat_id, sticker, reply_parameters=ReplyParameters(**kwargs) + ) + + +class TestStickerWithRequest(StickerTestBase): async def test_send_all_args(self, bot, chat_id, sticker_file, sticker): message = await bot.send_sticker( chat_id, sticker=sticker_file, disable_notification=False, protect_content=True @@ -334,20 +338,16 @@ async def test_send_all_args(self, bot, chat_id, sticker_file, sticker): assert message.sticker.thumbnail.height == sticker.thumbnail.height assert message.sticker.thumbnail.file_size == sticker.thumbnail.file_size - async def test_get_and_download(self, bot, sticker): - path = Path("telegram.webp") - if path.is_file(): - path.unlink() - + async def test_get_and_download(self, bot, sticker, tmp_file): new_file = await bot.get_file(sticker.file_id) assert new_file.file_size == sticker.file_size assert new_file.file_unique_id == sticker.file_unique_id assert new_file.file_path.startswith("https://") - await new_file.download_to_drive("telegram.webp") + await new_file.download_to_drive(tmp_file) - assert path.is_file() + assert tmp_file.is_file() async def test_resend(self, bot, chat_id, sticker): message = await bot.send_sticker(chat_id=chat_id, sticker=sticker.file_id) @@ -419,7 +419,7 @@ async def test_send_sticker_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="message not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_sticker( chat_id, sticker, reply_to_message_id=reply_to_message.message_id ) @@ -487,102 +487,35 @@ async def test_error_send_empty_file_id(self, bot, chat_id): await bot.send_sticker(chat_id, "") -@pytest.fixture() -async def sticker_set(bot): - ss = await bot.get_sticker_set(f"test_by_{bot.username}") - if len(ss.stickers) > 100: - try: - for i in range(1, 50): - await bot.delete_sticker_from_set(ss.stickers[-i].file_id) - except BadRequest as e: - if e.message == "Stickerset_not_modified": - return ss - raise Exception("stickerset is growing too large.") from None - return ss - - -@pytest.fixture() -async def animated_sticker_set(bot): - ss = await bot.get_sticker_set(f"animated_test_by_{bot.username}") - if len(ss.stickers) > 100: - try: - for i in range(1, 50): - await bot.delete_sticker_from_set(ss.stickers[-i].file_id) - except BadRequest as e: - if e.message == "Stickerset_not_modified": - return ss - raise Exception("stickerset is growing too large.") from None - return ss - - -@pytest.fixture() -async def video_sticker_set(bot): - ss = await bot.get_sticker_set(f"video_test_by_{bot.username}") - if len(ss.stickers) > 100: - try: - for i in range(1, 50): - await bot.delete_sticker_from_set(ss.stickers[-i].file_id) - except BadRequest as e: - if e.message == "Stickerset_not_modified": - return ss - raise Exception("stickerset is growing too large.") from None - return ss - - -@pytest.fixture() -def sticker_set_thumb_file(): - with data_file("sticker_set_thumb.png").open("rb") as file: - yield file - - -class TestStickerSetBase: +class StickerSetTestBase: title = "Test stickers" - is_animated = True - is_video = True stickers = [Sticker("file_id", "file_un_id", 512, 512, True, True, Sticker.REGULAR)] name = "NOTAREALNAME" sticker_type = Sticker.REGULAR contains_masks = True -class TestStickerSetWithoutRequest(TestStickerSetBase): +class TestStickerSetWithoutRequest(StickerSetTestBase): def test_slot_behaviour(self): - inst = StickerSet("this", "is", True, self.stickers, True, "not") + inst = StickerSet("this", "is", self.stickers, "not") 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_thumb_property_deprecation_warning(self, recwarn): - sticker_set = StickerSet( - name=self.name, - title=self.title, - is_animated=self.is_animated, - stickers=self.stickers, - is_video=self.is_video, - sticker_type=self.sticker_type, - thumb=object(), - ) - assert sticker_set.thumb is sticker_set.thumbnail - check_thumb_deprecation_warnings_for_args_and_attrs(recwarn, __file__) - - def test_de_json(self, bot, sticker): - name = f"test_by_{bot.username}" + def test_de_json(self, offline_bot, sticker): + name = f"test_by_{offline_bot.username}" json_dict = { "name": name, "title": self.title, - "is_animated": self.is_animated, - "is_video": self.is_video, "stickers": [x.to_dict() for x in self.stickers], "thumbnail": sticker.thumbnail.to_dict(), "sticker_type": self.sticker_type, "contains_masks": self.contains_masks, } - sticker_set = StickerSet.de_json(json_dict, bot) + sticker_set = StickerSet.de_json(json_dict, offline_bot) assert sticker_set.name == name assert sticker_set.title == self.title - assert sticker_set.is_animated == self.is_animated - assert sticker_set.is_video == self.is_video assert sticker_set.stickers == tuple(self.stickers) assert sticker_set.thumbnail == sticker.thumbnail assert sticker_set.sticker_type == self.sticker_type @@ -594,8 +527,6 @@ def test_sticker_set_to_dict(self, sticker_set): assert isinstance(sticker_set_dict, dict) assert sticker_set_dict["name"] == sticker_set.name assert sticker_set_dict["title"] == sticker_set.title - assert sticker_set_dict["is_animated"] == sticker_set.is_animated - assert sticker_set_dict["is_video"] == sticker_set.is_video assert sticker_set_dict["stickers"][0] == sticker_set.stickers[0].to_dict() assert sticker_set_dict["thumbnail"] == sticker_set.thumbnail.to_dict() assert sticker_set_dict["sticker_type"] == sticker_set.sticker_type @@ -604,26 +535,20 @@ def test_equality(self): a = StickerSet( self.name, self.title, - self.is_animated, self.stickers, - self.is_video, self.sticker_type, ) b = StickerSet( self.name, self.title, - self.is_animated, self.stickers, - self.is_video, self.sticker_type, ) - c = StickerSet(self.name, "title", False, [], True, Sticker.CUSTOM_EMOJI) + c = StickerSet(self.name, "title", [], Sticker.CUSTOM_EMOJI) d = StickerSet( "blah", self.title, - self.is_animated, self.stickers, - self.is_video, self.sticker_type, ) e = Audio(self.name, "", 0, None, None) @@ -641,287 +566,113 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) - @pytest.mark.parametrize("bot_class", ["Bot", "ExtBot"]) - async def test_upload_sticker_file_warning( - self, bot, raw_bot, monkeypatch, chat_id, recwarn, bot_class - ): - async def make_assertion(*args, **kwargs): - return {"file_id": "file_id", "file_unique_id": "file_unique_id"} - - bot = raw_bot if bot_class == "Bot" else bot - monkeypatch.setattr(bot, "_post", make_assertion) - - await bot.upload_sticker_file(chat_id, "png_sticker_file_id") - assert len(recwarn) == 1 - assert "Since Bot API 6.6, the parameter" in str(recwarn[0].message) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__ - - async def test_upload_sticker_file_missing_required_args(self, bot, chat_id): - with pytest.raises(TypeError, match="are required, please pass them as well"): - await bot.upload_sticker_file(chat_id) - with pytest.raises(TypeError, match="are required, please pass them as well"): - await bot.upload_sticker_file(chat_id, sticker="something") - - async def test_upload_sticker_file_mutually_exclusive(self, bot, chat_id): - with pytest.raises(TypeError, match="mutually exclusive with the deprecated parameter"): - await bot.upload_sticker_file( - chat_id, "png_sticker_file_id", sticker="s", sticker_format="static" - ) - @pytest.mark.parametrize("local_mode", [True, False]) async def test_upload_sticker_file_local_files( - self, monkeypatch, bot, chat_id, local_mode, recwarn + self, monkeypatch, offline_bot, chat_id, local_mode, recwarn ): try: - bot._local_mode = local_mode - # For just test that the correct paths are passed as we have no local bot API set up + offline_bot._local_mode = local_mode + # For just test that the correct paths are passed as we have no local Bot API set up test_flag = False file = data_file("telegram.jpg") expected = file.as_uri() async def make_assertion(_, data, *args, **kwargs): nonlocal test_flag - if local_mode: - test_flag = ( - data.get("png_sticker") == expected or data.get("sticker") == expected - ) - else: - test_flag = isinstance(data.get("png_sticker"), InputFile) or isinstance( - data.get("sticker"), InputFile - ) - - monkeypatch.setattr(bot, "_post", make_assertion) - await bot.upload_sticker_file(chat_id, sticker=file, sticker_format="static") - assert test_flag - # Now test with the deprecated parameters - test_flag = False - await bot.upload_sticker_file(chat_id, file) - assert test_flag + test_flag = ( + data.get("sticker") == expected + if local_mode + else isinstance(data.get("sticker"), InputFile) + ) + return File(file_id="file_id", file_unique_id="file_unique_id").to_dict() - warnings = [w for w in recwarn if w.category is not ResourceWarning] - assert len(warnings) == 1 - assert warnings[0].category is PTBDeprecationWarning - assert warnings[0].filename == __file__ - assert str(warnings[0].message).startswith( - "Since Bot API 6.6, the parameter `png_sticker` for " + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.upload_sticker_file( + chat_id, sticker=file, sticker_format=StickerFormat.STATIC ) + assert test_flag finally: - bot._local_mode = False - - @pytest.mark.parametrize("bot_class", ["Bot", "ExtBot"]) - async def test_create_new_sticker_set_warning( - self, bot, raw_bot, bot_class, monkeypatch, chat_id, recwarn - ): - async def make_assertion(*args, **kwargs): - return True - - bot = raw_bot if bot_class == "Bot" else bot - monkeypatch.setattr(bot, "_post", make_assertion) - - await bot.create_new_sticker_set(chat_id, "name", "title", "some_str_emoji") - assert len(recwarn) == 1 - assert "Since Bot API 6.6, the parameters" in str(recwarn[0].message) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__ - - async def test_create_new_sticker_set_missing_required_args(self, bot, chat_id): - with pytest.raises(TypeError, match="are required, please pass them as well"): - await bot.create_new_sticker_set(chat_id, "name", "title") - with pytest.raises(TypeError, match="are required, please pass them as well"): - await bot.create_new_sticker_set(chat_id, "name", "title", stickers=[]) - - async def test_create_new_sticker_set_mutually_exclusive(self, bot, chat_id): - with pytest.raises(TypeError, match="mutually exclusive with the deprecated parameters"): - await bot.create_new_sticker_set( - chat_id, "name", "title", "some_str_emoji", stickers=["s"], tgs_sticker="some_tgs" - ) + offline_bot._local_mode = False @pytest.mark.parametrize("local_mode", [True, False]) async def test_create_new_sticker_set_local_files( - self, monkeypatch, bot, chat_id, local_mode, recwarn + self, + monkeypatch, + offline_bot, + chat_id, + local_mode, ): - monkeypatch.setattr(bot, "_local_mode", local_mode) - # For just test that the correct paths are passed as we have no local bot API set up + monkeypatch.setattr(offline_bot, "_local_mode", local_mode) + # For just test that the correct paths are passed as we have no local Bot API set up test_flag = False file = data_file("telegram.jpg") + # always assumed to be local mode because we don't have access to local_mode setting + # within InputFile expected = file.as_uri() async def make_assertion(_, data, *args, **kwargs): nonlocal test_flag - if data.get("stickers"): # because we don't have access to local_mode setting - test_flag = data.get("stickers")[0].sticker == expected - elif local_mode: - test_flag = ( - data.get("png_sticker") == expected - and data.get("tgs_sticker") == expected - and data.get("webm_sticker") == expected - ) - else: - test_flag = ( - isinstance(data.get("png_sticker"), InputFile) - and isinstance(data.get("tgs_sticker"), InputFile) - and isinstance(data.get("webm_sticker"), InputFile) - ) + test_flag = data.get("stickers")[0].sticker == expected - monkeypatch.setattr(bot, "_post", make_assertion) - await bot.create_new_sticker_set( + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.create_new_sticker_set( chat_id, "name", "title", - "emoji", - png_sticker=file, - tgs_sticker=file, - webm_sticker=file, + stickers=[InputSticker(file, emoji_list=["emoji"], format=StickerFormat.STATIC)], ) assert test_flag - assert len(recwarn) in (1, 2) # The second one is an unclosed file warning - test_flag = False - await bot.create_new_sticker_set( - chat_id, - "name", - "title", - stickers=[InputSticker(file, emoji_list=["emoji"])], - sticker_format=StickerFormat.STATIC, - ) - assert test_flag - - warnings = [w for w in recwarn if w.category is not ResourceWarning] - assert len(warnings) == 1 - assert warnings[0].category is PTBDeprecationWarning - assert warnings[0].filename == __file__ - assert str(warnings[0].message).startswith("Since Bot API 6.6, the parameters") - assert "for `create_new_sticker_set` are deprecated" in str(warnings[0].message) async def test_create_new_sticker_all_params( - self, monkeypatch, bot, chat_id, mask_position, recwarn + self, monkeypatch, offline_bot, chat_id, mask_position ): - async def make_assertion_old_params(_, data, *args, **kwargs): - assert data["user_id"] == chat_id - assert data["name"] == "name" - assert data["title"] == "title" - assert data["emojis"] == "emoji" - assert data["mask_position"] == mask_position - assert data["png_sticker"] == "wow.png" - assert data["tgs_sticker"] == "wow.tgs" - assert data["webm_sticker"] == "wow.webm" - assert data["sticker_type"] == Sticker.MASK - - async def make_assertion_new_params(_, data, *args, **kwargs): + async def make_assertion(_, data, *args, **kwargs): assert data["user_id"] == chat_id assert data["name"] == "name" assert data["title"] == "title" assert data["stickers"] == ["wow.png", "wow.tgs", "wow.webp"] - assert data["sticker_format"] == "static" assert data["needs_repainting"] is True - monkeypatch.setattr(bot, "_post", make_assertion_old_params) - await bot.create_new_sticker_set( - chat_id, - "name", - "title", - "emoji", - mask_position=mask_position, - png_sticker="wow.png", - tgs_sticker="wow.tgs", - webm_sticker="wow.webm", - sticker_type=Sticker.MASK, - ) - assert len(recwarn) == 1 - assert recwarn[0].filename == __file__, "wrong stacklevel" - assert recwarn[0].category is PTBDeprecationWarning - assert str(recwarn[0].message).startswith("Since Bot API 6.6, the parameters") - assert "for `create_new_sticker_set` are deprecated" in str(recwarn[0].message) - - recwarn.clear() - monkeypatch.setattr(bot, "_post", make_assertion_new_params) - await bot.create_new_sticker_set( + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.create_new_sticker_set( chat_id, "name", "title", stickers=["wow.png", "wow.tgs", "wow.webp"], - sticker_format=StickerFormat.STATIC, needs_repainting=True, ) - assert len(recwarn) == 0 - - @pytest.mark.parametrize("bot_class", ["Bot", "ExtBot"]) - async def test_add_sticker_to_set_warning( - self, bot, raw_bot, monkeypatch, bot_class, chat_id, recwarn - ): - async def make_assertion(*args, **kwargs): - return True - - bot = raw_bot if bot_class == "Bot" else bot - monkeypatch.setattr(bot, "_post", make_assertion) - - await bot.add_sticker_to_set(chat_id, "name", "emoji", "fake_file_id") - assert len(recwarn) == 1 - assert "Since Bot API 6.6, the parameters" in str(recwarn[0].message) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__ - - async def test_add_sticker_to_set_missing_required_arg(self, bot, chat_id): - with pytest.raises(TypeError, match="The parameter `sticker` is a required"): - await bot.add_sticker_to_set(chat_id, "name") - - async def test_add_sticker_to_set_mutually_exclusive(self, bot, chat_id): - with pytest.raises(TypeError, match="mutually exclusive with the deprecated parameters"): - await bot.add_sticker_to_set(chat_id, "name", "emojis", sticker="something") @pytest.mark.parametrize("local_mode", [True, False]) async def test_add_sticker_to_set_local_files( - self, monkeypatch, bot, chat_id, local_mode, recwarn + self, monkeypatch, offline_bot, chat_id, local_mode ): - monkeypatch.setattr(bot, "_local_mode", local_mode) - # For just test that the correct paths are passed as we have no local bot API set up + monkeypatch.setattr(offline_bot, "_local_mode", local_mode) + # For just test that the correct paths are passed as we have no local Bot API set up test_flag = False file = data_file("telegram.jpg") + # always assumed to be local mode because we don't have access to local_mode setting + # within InputFile expected = file.as_uri() async def make_assertion(_, data, *args, **kwargs): nonlocal test_flag - if data.get("sticker"): # because we don't have access to local_mode setting - test_flag = data.get("sticker").sticker == expected - elif local_mode: - test_flag = ( - data.get("png_sticker") == expected and data.get("tgs_sticker") == expected - ) - else: - test_flag = isinstance(data.get("png_sticker"), InputFile) and isinstance( - data.get("tgs_sticker"), InputFile - ) + test_flag = data.get("sticker").sticker == expected - monkeypatch.setattr(bot, "_post", make_assertion) - await bot.add_sticker_to_set( + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.add_sticker_to_set( chat_id, "name", - "emoji", - png_sticker=file, - tgs_sticker=file, - ) - assert test_flag - assert len(recwarn) in (1, 2) # The second one is an unclosed file warning - test_flag = False - await bot.add_sticker_to_set( - chat_id, "name", sticker=InputSticker(sticker=file, emoji_list=["this"]) + sticker=InputSticker(sticker=file, emoji_list=["this"], format="static"), ) assert test_flag - warnings = [w for w in recwarn if w.category is not ResourceWarning] - assert len(warnings) == 1 - assert warnings[0].category is PTBDeprecationWarning - assert warnings[0].filename == __file__ - assert str(warnings[0].message).startswith("Since Bot API 6.6, the parameters") - assert "for `add_sticker_to_set` are deprecated" in str(warnings[0].message) - @pytest.mark.parametrize("local_mode", [True, False]) async def test_set_sticker_set_thumbnail_local_files( - self, monkeypatch, bot, chat_id, local_mode + self, monkeypatch, offline_bot, chat_id, local_mode ): try: - bot._local_mode = local_mode - # For just test that the correct paths are passed as we have no local bot API set up + offline_bot._local_mode = local_mode + # For just test that the correct paths are passed as we have no local Bot API set up test_flag = False file = data_file("telegram.jpg") expected = file.as_uri() @@ -933,32 +684,13 @@ async def make_assertion(_, data, *args, **kwargs): else: test_flag = isinstance(data.get("thumbnail"), InputFile) - monkeypatch.setattr(bot, "_post", make_assertion) - await bot.set_sticker_set_thumbnail("name", chat_id, thumbnail=file) + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.set_sticker_set_thumbnail( + "name", chat_id, thumbnail=file, format="static" + ) assert test_flag finally: - bot._local_mode = False - - @pytest.mark.parametrize("bot_class", ["Bot", "ExtBot"]) - async def test_set_sticker_set_thumb_deprecation_warning( - self, monkeypatch, bot, raw_bot, recwarn, bot_class - ): - bot = bot if bot_class == "ExtBot" else raw_bot - - async def _post(*args, **kwargs): - return True - - monkeypatch.setattr(bot, "_post", _post) - await bot.set_sticker_set_thumb("name", "user_id", "thumb") - - assert len(recwarn) == 1 - assert recwarn[0].category is PTBDeprecationWarning - assert "renamed the method 'setStickerSetThumb' to 'setStickerSetThumbnail'" in str( - recwarn[0].message - ) - - assert recwarn[0].filename == __file__, "incorrect stacklevel!" - recwarn.clear() + offline_bot._local_mode = False async def test_get_file_instance_method(self, monkeypatch, sticker): async def make_assertion(*_, **kwargs): @@ -971,6 +703,54 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(sticker.get_bot(), "get_file", make_assertion) assert await sticker.get_file() + async def test_delete_sticker_from_set_sticker_input(self, offline_bot, sticker, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + return request_data.json_parameters["sticker"] == sticker.file_id + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.delete_sticker_from_set(sticker) + + async def test_replace_sticker_in_set_sticker_input(self, offline_bot, sticker, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + return request_data.json_parameters["old_sticker"] == sticker.file_id + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.replace_sticker_in_set( + user_id=1, name="name", sticker="sticker", old_sticker=sticker + ) + + async def test_set_sticker_emoji_list_sticker_input(self, offline_bot, sticker, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + return request_data.json_parameters["sticker"] == sticker.file_id + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.set_sticker_emoji_list(sticker, ["emoji"]) + + async def test_set_sticker_mask_position_sticker_input( + self, offline_bot, sticker, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + return request_data.json_parameters["sticker"] == sticker.file_id + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.set_sticker_mask_position(sticker, MaskPosition("eyes", 1, 2, 3)) + + async def test_set_sticker_position_in_set_sticker_input( + self, offline_bot, sticker, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + return request_data.json_parameters["sticker"] == sticker.file_id + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.set_sticker_position_in_set(sticker, 1) + + async def test_set_sticker_keywords_sticker_input(self, offline_bot, sticker, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + return request_data.json_parameters["sticker"] == sticker.file_id + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.set_sticker_keywords(sticker, ["keyword"]) + @pytest.mark.xdist_group("stickerset") class TestStickerSetWithRequest: @@ -985,6 +765,14 @@ async def test_create_sticker_set( try: ss = await bot.get_sticker_set(sticker_set) assert isinstance(ss, StickerSet) + + if len(ss.stickers) > 100: + try: + for i in range(1, 50): + await bot.delete_sticker_from_set(ss.stickers[-i].file_id) + except BadRequest as e: + if e.message != "Stickerset_not_modified": + raise Exception("stickerset is growing too large.") from None except BadRequest as e: if not e.message == "Stickerset_invalid": raise e @@ -994,8 +782,11 @@ async def test_create_sticker_set( chat_id, name=sticker_set, title="Sticker Test", - stickers=[InputSticker(sticker_file, emoji_list=["😄"])], - sticker_format=StickerFormat.STATIC, + stickers=[ + InputSticker( + sticker_file, emoji_list=["😄"], format=StickerFormat.STATIC + ) + ], ) assert s elif sticker_set.startswith("animated"): @@ -1003,8 +794,13 @@ async def test_create_sticker_set( chat_id, name=sticker_set, title="Animated Test", - stickers=[InputSticker(animated_sticker_file, emoji_list=["😄"])], - sticker_format=StickerFormat.ANIMATED, + stickers=[ + InputSticker( + animated_sticker_file, + emoji_list=["😄"], + format=StickerFormat.ANIMATED, + ) + ], ) assert a elif sticker_set.startswith("video"): @@ -1012,8 +808,11 @@ async def test_create_sticker_set( chat_id, name=sticker_set, title="Video Test", - stickers=[InputSticker(video_sticker_file, emoji_list=["😄"])], - sticker_format=StickerFormat.VIDEO, + stickers=[ + InputSticker( + video_sticker_file, emoji_list=["😄"], format=StickerFormat.VIDEO + ) + ], ) assert v @@ -1027,8 +826,7 @@ async def test_delete_sticker_set(self, bot, chat_id, sticker_file): chat_id, name=name, title="Stickerset delete Test", - stickers=[InputSticker(sticker_file, emoji_list=["😄"])], - sticker_format=StickerFormat.STATIC, + stickers=[InputSticker(sticker_file, emoji_list=["😄"], format="static")], ) # this prevents a second issue when calling delete too soon after creating the set leads # to it failing as well @@ -1047,8 +845,11 @@ async def test_set_custom_emoji_sticker_set_thumbnail( chat_id, name=ss_name, title="Custom Emoji Sticker Set", - stickers=[InputSticker(animated_sticker_file, emoji_list=["😄"])], - sticker_format=StickerFormat.ANIMATED, + stickers=[ + InputSticker( + animated_sticker_file, emoji_list=["😄"], format=StickerFormat.ANIMATED + ) + ], sticker_type=Sticker.CUSTOM_EMOJI, ) assert await bot.set_custom_emoji_sticker_set_thumbnail(ss_name, "") @@ -1067,7 +868,9 @@ async def test_bot_methods_1_png(self, bot, chat_id, sticker_file): bot.add_sticker_to_set( chat_id, f"test_by_{bot.username}", - sticker=InputSticker(sticker=file.file_id, emoji_list=["😄"]), + sticker=InputSticker( + sticker=file.file_id, emoji_list=["😄"], format=StickerFormat.STATIC + ), ), bot.add_sticker_to_set( # Also test with file input and mask chat_id, @@ -1076,6 +879,7 @@ async def test_bot_methods_1_png(self, bot, chat_id, sticker_file): sticker=sticker_file, emoji_list=["😄"], mask_position=MaskPosition(MaskPosition.EYES, -1, 1, 2), + format=StickerFormat.STATIC, ), ), ) @@ -1087,7 +891,9 @@ async def test_bot_methods_1_tgs(self, bot, chat_id): chat_id, f"animated_test_by_{bot.username}", sticker=InputSticker( - sticker=data_file("telegram_animated_sticker.tgs").open("rb"), emoji_list=["😄"] + sticker=data_file("telegram_animated_sticker.tgs").open("rb"), + emoji_list=["😄"], + format=StickerFormat.ANIMATED, ), ) @@ -1097,7 +903,7 @@ async def test_bot_methods_1_webm(self, bot, chat_id): assert await bot.add_sticker_to_set( chat_id, f"video_test_by_{bot.username}", - sticker=InputSticker(sticker=f, emoji_list=["🤔"]), + sticker=InputSticker(sticker=f, emoji_list=["🤔"], format=StickerFormat.VIDEO), ) # Test set_sticker_position_in_set @@ -1120,7 +926,7 @@ async def test_bot_methods_2_webm(self, bot, video_sticker_set): async def test_bot_methods_3_png(self, bot, chat_id, sticker_set_thumb_file): await asyncio.sleep(1) assert await bot.set_sticker_set_thumbnail( - f"test_by_{bot.username}", chat_id, sticker_set_thumb_file + f"test_by_{bot.username}", chat_id, format="static", thumbnail=sticker_set_thumb_file ) async def test_bot_methods_3_tgs( @@ -1130,8 +936,13 @@ async def test_bot_methods_3_tgs( animated_test = f"animated_test_by_{bot.username}" file_id = animated_sticker_set.stickers[-1].file_id tasks = asyncio.gather( - bot.set_sticker_set_thumbnail(animated_test, chat_id, animated_sticker_file), - bot.set_sticker_set_thumbnail(animated_test, chat_id, file_id), + bot.set_sticker_set_thumbnail( + animated_test, + chat_id, + "animated", + thumbnail=animated_sticker_file, + ), + bot.set_sticker_set_thumbnail(animated_test, chat_id, "animated", thumbnail=file_id), ) assert all(await tasks) @@ -1214,39 +1025,52 @@ async def test_bot_methods_7_webm(self, bot, video_sticker_set): file_id = video_sticker_set.stickers[-1].file_id assert await bot.set_sticker_keywords(file_id, ["test", "test2"]) - -@pytest.fixture(scope="module") -def mask_position(): - return MaskPosition( - TestMaskPositionBase.point, - TestMaskPositionBase.x_shift, - TestMaskPositionBase.y_shift, - TestMaskPositionBase.scale, - ) + async def test_bot_methods_8_png(self, bot, sticker_set, sticker_file): + file_id = sticker_set.stickers[-1].file_id + assert await bot.replace_sticker_in_set( + bot.id, + f"test_by_{bot.username}", + file_id, + sticker=InputSticker( + sticker=sticker_file, + emoji_list=["😄"], + format=StickerFormat.STATIC, + ), + ) -class TestMaskPositionBase: +class MaskPositionTestBase: point = MaskPosition.EYES x_shift = -1 y_shift = 1 scale = 2 -class TestMaskPositionWithoutRequest(TestMaskPositionBase): +@pytest.fixture(scope="module") +def mask_position(): + return MaskPosition( + MaskPositionTestBase.point, + MaskPositionTestBase.x_shift, + MaskPositionTestBase.y_shift, + MaskPositionTestBase.scale, + ) + + +class TestMaskPositionWithoutRequest(MaskPositionTestBase): def test_slot_behaviour(self, mask_position): inst = mask_position 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_mask_position_de_json(self, bot): + def test_mask_position_de_json(self, offline_bot): json_dict = { "point": self.point, "x_shift": self.x_shift, "y_shift": self.y_shift, "scale": self.scale, } - mask_position = MaskPosition.de_json(json_dict, bot) + mask_position = MaskPosition.de_json(json_dict, offline_bot) assert mask_position.api_kwargs == {} assert mask_position.point == self.point @@ -1284,7 +1108,7 @@ def test_equality(self): assert hash(a) != hash(e) -class TestMaskPositionWithRequest(TestMaskPositionBase): +class TestMaskPositionWithRequest(MaskPositionTestBase): async def test_create_new_mask_sticker_set(self, bot, chat_id, sticker_file, mask_position): name = f"masks_by_{bot.username}" try: @@ -1303,9 +1127,9 @@ async def test_create_new_mask_sticker_set(self, bot, chat_id, sticker_file, mas emoji_list=["😔"], mask_position=mask_position, keywords=["sad"], + format=StickerFormat.STATIC, ) ], - sticker_format=StickerFormat.STATIC, sticker_type=Sticker.MASK, ) assert sticker_set diff --git a/tests/_files/test_venue.py b/tests/_files/test_venue.py index 9631d562d82..894cc97bfa3 100644 --- a/tests/_files/test_venue.py +++ b/tests/_files/test_venue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,26 +20,28 @@ import pytest -from telegram import Location, Venue +from telegram import Location, ReplyParameters, Venue +from telegram.constants import ParseMode from telegram.error import BadRequest from telegram.request import RequestData +from tests.auxil.build_messages import make_message from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def venue(): return Venue( - TestVenueBase.location, - TestVenueBase.title, - TestVenueBase.address, - foursquare_id=TestVenueBase.foursquare_id, - foursquare_type=TestVenueBase.foursquare_type, - google_place_id=TestVenueBase.google_place_id, - google_place_type=TestVenueBase.google_place_type, + VenueTestBase.location, + VenueTestBase.title, + VenueTestBase.address, + foursquare_id=VenueTestBase.foursquare_id, + foursquare_type=VenueTestBase.foursquare_type, + google_place_id=VenueTestBase.google_place_id, + google_place_type=VenueTestBase.google_place_type, ) -class TestVenueBase: +class VenueTestBase: location = Location(longitude=-46.788279, latitude=-23.691288) title = "title" address = "address" @@ -49,13 +51,13 @@ class TestVenueBase: google_place_type = "google place type" -class TestVenueWithoutRequest(TestVenueBase): +class TestVenueWithoutRequest(VenueTestBase): def test_slot_behaviour(self, venue): for attr in venue.__slots__: assert getattr(venue, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(venue)) == len(set(mro_slots(venue))), "duplicate slot" - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "location": self.location.to_dict(), "title": self.title, @@ -65,7 +67,7 @@ def test_de_json(self, bot): "google_place_id": self.google_place_id, "google_place_type": self.google_place_type, } - venue = Venue.de_json(json_dict, bot) + venue = Venue.de_json(json_dict, offline_bot) assert venue.api_kwargs == {} assert venue.location == self.location @@ -108,13 +110,13 @@ def test_equality(self): assert a != d2 assert hash(a) != hash(d2) - async def test_send_venue_without_required(self, bot, chat_id): + async def test_send_venue_without_required(self, offline_bot, chat_id): with pytest.raises(ValueError, match="Either venue or latitude, longitude, address and"): - await bot.send_venue(chat_id=chat_id) + await offline_bot.send_venue(chat_id=chat_id) - async def test_send_venue_mutually_exclusive(self, bot, chat_id, venue): + async def test_send_venue_mutually_exclusive(self, offline_bot, chat_id, venue): with pytest.raises(ValueError, match="Not both"): - await bot.send_venue( + await offline_bot.send_venue( chat_id=chat_id, latitude=1, longitude=1, @@ -123,7 +125,7 @@ async def test_send_venue_mutually_exclusive(self, bot, chat_id, venue): venue=venue, ) - async def test_send_with_venue(self, monkeypatch, bot, chat_id, venue): + async def test_send_with_venue(self, monkeypatch, offline_bot, chat_id, venue): async def make_assertion(url, request_data: RequestData, *args, **kwargs): data = request_data.json_parameters return ( @@ -137,12 +139,39 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): and data["google_place_type"] == self.google_place_type ) - monkeypatch.setattr(bot.request, "post", make_assertion) - message = await bot.send_venue(chat_id, venue=venue) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + message = await offline_bot.send_venue(chat_id, venue=venue) assert message + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_venue_default_quote_parse_mode( + self, default_bot, chat_id, venue, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() + + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_venue( + chat_id, venue=venue, reply_parameters=ReplyParameters(**kwargs) + ) + -class TestVenueWithRequest(TestVenueBase): +class TestVenueWithRequest(VenueTestBase): @pytest.mark.parametrize( ("default_bot", "custom"), [ @@ -171,7 +200,7 @@ async def test_send_venue_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="message not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_venue( chat_id, venue=venue, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_files/test_video.py b/tests/_files/test_video.py index 773f7e21006..366e7d1a9fb 100644 --- a/tests/_files/test_video.py +++ b/tests/_files/test_video.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,48 +17,49 @@ # 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 asyncio +import datetime as dtm import os from pathlib import Path import pytest -from telegram import Bot, InputFile, MessageEntity, PhotoSize, Video, Voice +from telegram import Bot, InputFile, MessageEntity, PhotoSize, ReplyParameters, Video, Voice +from telegram.constants import ParseMode from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, check_shortcut_signature, ) -from tests.auxil.deprecations import ( - check_thumb_deprecation_warning_for_method_args, - check_thumb_deprecation_warnings_for_args_and_attrs, -) +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file from tests.auxil.slots import mro_slots -@pytest.fixture() -def video_file(): - with data_file("telegram.mp4").open("rb") as f: - yield f - - +# Override `video` fixture to provide start_timestamp @pytest.fixture(scope="module") async def video(bot, chat_id): with data_file("telegram.mp4").open("rb") as f: - return (await bot.send_video(chat_id, video=f, read_timeout=50)).video + return ( + await bot.send_video( + chat_id, video=f, start_timestamp=VideoTestBase.start_timestamp, read_timeout=50 + ) + ).video -class TestVideoBase: +class VideoTestBase: width = 360 height = 640 - duration = 5 + duration = dtm.timedelta(seconds=5) file_size = 326534 mime_type = "video/mp4" supports_streaming = True file_name = "telegram.mp4" + start_timestamp = dtm.timedelta(seconds=3) + cover = (PhotoSize("file_id", "unique_id", 640, 360, file_size=0),) thumb_width = 180 thumb_height = 320 thumb_file_size = 1767 @@ -68,7 +69,7 @@ class TestVideoBase: video_file_unique_id = "adc3145fd2e84d95b64d68eaa22aa33e" -class TestVideoWithoutRequest(TestVideoBase): +class TestVideoWithoutRequest(VideoTestBase): def test_slot_behaviour(self, video): for attr in video.__slots__: assert getattr(video, attr, "err") != "err", f"got extra slot '{attr}'" @@ -91,44 +92,37 @@ def test_creation(self, video): def test_expected_values(self, video): assert video.width == self.width assert video.height == self.height - assert video.duration == self.duration + assert video._duration == self.duration assert video.file_size == self.file_size assert video.mime_type == self.mime_type + assert video._start_timestamp == self.start_timestamp - def test_thumb_property_deprecation_warning(self, recwarn): - video = Video( - self.video_file_id, - self.video_file_unique_id, - self.width, - self.height, - self.duration, - thumb=object(), - ) - assert video.thumb is video.thumbnail - check_thumb_deprecation_warnings_for_args_and_attrs(recwarn, __file__) - - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "file_id": self.video_file_id, "file_unique_id": self.video_file_unique_id, "width": self.width, "height": self.height, - "duration": self.duration, + "duration": int(self.duration.total_seconds()), "mime_type": self.mime_type, "file_size": self.file_size, "file_name": self.file_name, + "start_timestamp": int(self.start_timestamp.total_seconds()), + "cover": [photo_size.to_dict() for photo_size in self.cover], } - json_video = Video.de_json(json_dict, bot) + json_video = Video.de_json(json_dict, offline_bot) assert json_video.api_kwargs == {} assert json_video.file_id == self.video_file_id assert json_video.file_unique_id == self.video_file_unique_id assert json_video.width == self.width assert json_video.height == self.height - assert json_video.duration == self.duration + assert json_video._duration == self.duration assert json_video.mime_type == self.mime_type assert json_video.file_size == self.file_size assert json_video.file_name == self.file_name + assert json_video._start_timestamp == self.start_timestamp + assert json_video.cover == self.cover def test_to_dict(self, video): video_dict = video.to_dict() @@ -138,10 +132,39 @@ def test_to_dict(self, video): assert video_dict["file_unique_id"] == video.file_unique_id assert video_dict["width"] == video.width assert video_dict["height"] == video.height - assert video_dict["duration"] == video.duration + assert video_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(video_dict["duration"], int) assert video_dict["mime_type"] == video.mime_type assert video_dict["file_size"] == video.file_size assert video_dict["file_name"] == video.file_name + assert video_dict["start_timestamp"] == int(self.start_timestamp.total_seconds()) + assert isinstance(video_dict["start_timestamp"], int) + + def test_time_period_properties(self, PTB_TIMEDELTA, video): + if PTB_TIMEDELTA: + assert video.duration == self.duration + assert isinstance(video.duration, dtm.timedelta) + + assert video.start_timestamp == self.start_timestamp + assert isinstance(video.start_timestamp, dtm.timedelta) + else: + assert video.duration == int(self.duration.total_seconds()) + assert isinstance(video.duration, int) + + assert video.start_timestamp == int(self.start_timestamp.total_seconds()) + assert isinstance(video.start_timestamp, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, video): + video.duration + video.start_timestamp + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 2 + for i, attr in enumerate(["duration", "start_timestamp"]): + assert f"`{attr}` will be of type `datetime.timedelta`" in str(recwarn[i].message) + assert recwarn[i].category is PTBDeprecationWarning def test_equality(self, video): a = Video(video.file_id, video.file_unique_id, self.width, self.height, self.duration) @@ -163,43 +186,32 @@ def test_equality(self, video): assert a != e assert hash(a) != hash(e) - async def test_error_without_required_args(self, bot, chat_id): + async def test_error_without_required_args(self, offline_bot, chat_id): with pytest.raises(TypeError): - await bot.send_video(chat_id=chat_id) + await offline_bot.send_video(chat_id=chat_id) - async def test_send_with_video(self, monkeypatch, bot, chat_id, video): + async def test_send_with_video(self, monkeypatch, offline_bot, chat_id, video): async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.json_parameters["video"] == video.file_id - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.send_video(chat_id, video=video) - - @pytest.mark.parametrize("bot_class", ["Bot", "ExtBot"]) - async def test_send_video_thumb_deprecation_warning( - self, recwarn, monkeypatch, bot_class, bot, raw_bot, chat_id, video - ): - async def make_assertion(url, request_data: RequestData, *args, **kwargs): - return True - - bot = raw_bot if bot_class == "Bot" else bot + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_video(chat_id, video=video) - monkeypatch.setattr(bot.request, "post", make_assertion) - await bot.send_video(chat_id, video, thumb="thumb") - check_thumb_deprecation_warning_for_method_args(recwarn, __file__) - - async def test_send_video_custom_filename(self, bot, chat_id, video_file, monkeypatch): + async def test_send_video_custom_filename(self, offline_bot, chat_id, video_file, monkeypatch): async def make_assertion(url, request_data: RequestData, *args, **kwargs): - return list(request_data.multipart_data.values())[0][0] == "custom_filename" + return next(iter(request_data.multipart_data.values()))[0] == "custom_filename" - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - assert await bot.send_video(chat_id, video_file, filename="custom_filename") + assert await offline_bot.send_video(chat_id, video_file, filename="custom_filename") @pytest.mark.parametrize("local_mode", [True, False]) - async def test_send_video_local_files(self, monkeypatch, bot, chat_id, local_mode): + async def test_send_video_local_files( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): try: - bot._local_mode = local_mode - # For just test that the correct paths are passed as we have no local bot API set up + offline_bot._local_mode = local_mode + # For just test that the correct paths are passed as we have no local Bot API set up test_flag = False file = data_file("telegram.jpg") expected = file.as_uri() @@ -212,21 +224,13 @@ async def make_assertion(_, data, *args, **kwargs): test_flag = isinstance(data.get("video"), InputFile) and isinstance( data.get("thumbnail"), InputFile ) + return dummy_message_dict - monkeypatch.setattr(bot, "_post", make_assertion) - await bot.send_video(chat_id, file, thumbnail=file) + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.send_video(chat_id, file, thumbnail=file) assert test_flag finally: - bot._local_mode = False - - async def test_send_video_with_local_files_throws_exception_with_different_thumb_and_thumbnail( - self, bot, chat_id - ): - file = data_file("telegram.jpg") - different_file = data_file("telegram_no_standard_header.jpg") - - with pytest.raises(ValueError, match="different entities as 'thumb' and 'thumbnail'"): - await bot.send_video(chat_id, file, thumbnail=file, thumb=different_file) + offline_bot._local_mode = False async def test_get_file_instance_method(self, monkeypatch, video): async def make_assertion(*_, **kwargs): @@ -239,13 +243,41 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(video.get_bot(), "get_file", make_assertion) assert await video.get_file() + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_video_default_quote_parse_mode( + self, default_bot, chat_id, video, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() + + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_video(chat_id, video, reply_parameters=ReplyParameters(**kwargs)) -class TestVideoWithRequest(TestVideoBase): - async def test_send_all_args(self, bot, chat_id, video_file, video, thumb_file): + +class TestVideoWithRequest(VideoTestBase): + @pytest.mark.parametrize("duration", [dtm.timedelta(seconds=5), 5]) + async def test_send_all_args( + self, bot, chat_id, video_file, video, thumb_file, photo_file, duration + ): message = await bot.send_video( chat_id, video_file, - duration=self.duration, + duration=duration, caption=self.caption, supports_streaming=self.supports_streaming, disable_notification=False, @@ -254,7 +286,10 @@ async def test_send_all_args(self, bot, chat_id, video_file, video, thumb_file): height=video.height, parse_mode="Markdown", thumbnail=thumb_file, + cover=photo_file, + start_timestamp=self.start_timestamp, has_spoiler=True, + show_caption_above_media=True, ) assert isinstance(message.video, Video) @@ -273,24 +308,26 @@ async def test_send_all_args(self, bot, chat_id, video_file, video, thumb_file): assert message.video.thumbnail.width == self.thumb_width assert message.video.thumbnail.height == self.thumb_height + assert message.video._start_timestamp == self.start_timestamp + + assert isinstance(message.video.cover, tuple) + assert isinstance(message.video.cover[0], PhotoSize) + assert message.video.file_name == self.file_name assert message.has_protected_content assert message.has_media_spoiler + assert message.show_caption_above_media - async def test_get_and_download(self, bot, video, chat_id): - path = Path("telegram.mp4") - if path.is_file(): - path.unlink() - + async def test_get_and_download(self, bot, video, chat_id, tmp_file): new_file = await bot.get_file(video.file_id) assert new_file.file_size == self.file_size assert new_file.file_unique_id == video.file_unique_id assert new_file.file_path.startswith("https://") - await new_file.download_to_drive("telegram.mp4") + await new_file.download_to_drive(tmp_file) - assert path.is_file() + assert tmp_file.is_file() async def test_send_mp4_file_url(self, bot, chat_id, video): message = await bot.send_video(chat_id, self.video_file_url, caption=self.caption) @@ -401,7 +438,7 @@ async def test_send_video_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="message not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_video( chat_id, video, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_files/test_videonote.py b/tests/_files/test_videonote.py index aee0a042d17..3af79baf497 100644 --- a/tests/_files/test_videonote.py +++ b/tests/_files/test_videonote.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,28 +17,28 @@ # 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 asyncio +import datetime as dtm import os from pathlib import Path import pytest -from telegram import Bot, InputFile, PhotoSize, VideoNote, Voice +from telegram import Bot, InputFile, PhotoSize, ReplyParameters, VideoNote, Voice +from telegram.constants import ParseMode from telegram.error import BadRequest, TelegramError from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, check_shortcut_signature, ) -from tests.auxil.deprecations import ( - check_thumb_deprecation_warning_for_method_args, - check_thumb_deprecation_warnings_for_args_and_attrs, -) +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file from tests.auxil.slots import mro_slots -@pytest.fixture() +@pytest.fixture def video_note_file(): with data_file("telegram2.mp4").open("rb") as f: yield f @@ -50,9 +50,9 @@ async def video_note(bot, chat_id): return (await bot.send_video_note(chat_id, video_note=f, read_timeout=50)).video_note -class TestVideoNoteBase: +class VideoNoteTestBase: length = 240 - duration = 3 + duration = dtm.timedelta(seconds=3) file_size = 132084 thumb_width = 240 thumb_height = 240 @@ -62,7 +62,7 @@ class TestVideoNoteBase: videonote_file_unique_id = "adc3145fd2e84d95b64d68eaa22aa33e" -class TestVideoNoteWithoutRequest(TestVideoNoteBase): +class TestVideoNoteWithoutRequest(VideoNoteTestBase): def test_slot_behaviour(self, video_note): for attr in video_note.__slots__: assert getattr(video_note, attr, "err") != "err", f"got extra slot '{attr}'" @@ -82,33 +82,21 @@ def test_creation(self, video_note): assert video_note.thumbnail.file_id assert video_note.thumbnail.file_unique_id - def test_expected_values(self, video_note): - assert video_note.length == self.length - assert video_note.duration == self.duration - assert video_note.file_size == self.file_size - - def test_thumb_property_deprecation_warning(self, recwarn): - video_note = VideoNote( - file_id="id", file_unique_id="unique_id", length=1, duration=1, thumb=object() - ) - assert video_note.thumb is video_note.thumbnail - check_thumb_deprecation_warnings_for_args_and_attrs(recwarn, __file__) - - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "file_id": self.videonote_file_id, "file_unique_id": self.videonote_file_unique_id, "length": self.length, - "duration": self.duration, + "duration": int(self.duration.total_seconds()), "file_size": self.file_size, } - json_video_note = VideoNote.de_json(json_dict, bot) + json_video_note = VideoNote.de_json(json_dict, offline_bot) assert json_video_note.api_kwargs == {} assert json_video_note.file_id == self.videonote_file_id assert json_video_note.file_unique_id == self.videonote_file_unique_id assert json_video_note.length == self.length - assert json_video_note.duration == self.duration + assert json_video_note._duration == self.duration assert json_video_note.file_size == self.file_size def test_to_dict(self, video_note): @@ -118,9 +106,28 @@ def test_to_dict(self, video_note): assert video_note_dict["file_id"] == video_note.file_id assert video_note_dict["file_unique_id"] == video_note.file_unique_id assert video_note_dict["length"] == video_note.length - assert video_note_dict["duration"] == video_note.duration + assert video_note_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(video_note_dict["duration"], int) assert video_note_dict["file_size"] == video_note.file_size + def test_time_period_properties(self, PTB_TIMEDELTA, video_note): + if PTB_TIMEDELTA: + assert video_note.duration == self.duration + assert isinstance(video_note.duration, dtm.timedelta) + else: + assert video_note.duration == int(self.duration.total_seconds()) + assert isinstance(video_note.duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, video_note): + video_note.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self, video_note): a = VideoNote(video_note.file_id, video_note.file_unique_id, self.length, self.duration) b = VideoNote("", video_note.file_unique_id, self.length, self.duration) @@ -141,45 +148,36 @@ def test_equality(self, video_note): assert a != e assert hash(a) != hash(e) - async def test_error_without_required_args(self, bot, chat_id): + async def test_error_without_required_args(self, offline_bot, chat_id): with pytest.raises(TypeError): - await bot.send_video_note(chat_id=chat_id) + await offline_bot.send_video_note(chat_id=chat_id) - async def test_send_with_video_note(self, monkeypatch, bot, chat_id, video_note): + async def test_send_with_video_note(self, monkeypatch, offline_bot, chat_id, video_note): async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.json_parameters["video_note"] == video_note.file_id - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.send_video_note(chat_id, video_note=video_note) - - @pytest.mark.parametrize("bot_class", ["Bot", "ExtBot"]) - async def test_send_video_note_thumb_deprecation_warning( - self, recwarn, monkeypatch, bot_class, bot, raw_bot, chat_id, video_note - ): - async def make_assertion(url, request_data: RequestData, *args, **kwargs): - return True - - bot = raw_bot if bot_class == "Bot" else bot - - monkeypatch.setattr(bot.request, "post", make_assertion) - await bot.send_video_note(chat_id, video_note, thumb="thumb") - check_thumb_deprecation_warning_for_method_args(recwarn, __file__) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_video_note(chat_id, video_note=video_note) async def test_send_video_note_custom_filename( - self, bot, chat_id, video_note_file, monkeypatch + self, offline_bot, chat_id, video_note_file, monkeypatch ): async def make_assertion(url, request_data: RequestData, *args, **kwargs): - return list(request_data.multipart_data.values())[0][0] == "custom_filename" + return next(iter(request_data.multipart_data.values()))[0] == "custom_filename" - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - assert await bot.send_video_note(chat_id, video_note_file, filename="custom_filename") + assert await offline_bot.send_video_note( + chat_id, video_note_file, filename="custom_filename" + ) @pytest.mark.parametrize("local_mode", [True, False]) - async def test_send_video_note_local_files(self, monkeypatch, bot, chat_id, local_mode): + async def test_send_video_note_local_files( + self, monkeypatch, offline_bot, chat_id, local_mode, dummy_message_dict + ): try: - bot._local_mode = local_mode - # For just test that the correct paths are passed as we have no local bot API set up + offline_bot._local_mode = local_mode + # For just test that the correct paths are passed as we have no local Bot API set up test_flag = False file = data_file("telegram.jpg") expected = file.as_uri() @@ -194,21 +192,13 @@ async def make_assertion(_, data, *args, **kwargs): test_flag = isinstance(data.get("video_note"), InputFile) and isinstance( data.get("thumbnail"), InputFile ) + return dummy_message_dict - monkeypatch.setattr(bot, "_post", make_assertion) - await bot.send_video_note(chat_id, file, thumbnail=file) + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.send_video_note(chat_id, file, thumbnail=file) assert test_flag finally: - bot._local_mode = False - - async def test_send_videonote_local_files_throws_exception_with_different_thumb_and_thumbnail( - self, bot, chat_id - ): - file = data_file("telegram.jpg") - different_file = data_file("telegram_no_standard_header.jpg") - - with pytest.raises(ValueError, match="different entities as 'thumb' and 'thumbnail'"): - await bot.send_video_note(chat_id, file, thumbnail=file, thumb=different_file) + offline_bot._local_mode = False async def test_get_file_instance_method(self, monkeypatch, video_note): async def make_assertion(*_, **kwargs): @@ -221,13 +211,43 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(video_note.get_bot(), "get_file", make_assertion) assert await video_note.get_file() + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_video_note_default_quote_parse_mode( + self, default_bot, chat_id, video_note, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() + + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_video_note( + chat_id, video_note, reply_parameters=ReplyParameters(**kwargs) + ) + -class TestVideoNoteWithRequest(TestVideoNoteBase): - async def test_send_all_args(self, bot, chat_id, video_note_file, video_note, thumb_file): +class TestVideoNoteWithRequest(VideoNoteTestBase): + @pytest.mark.parametrize("duration", [3, dtm.timedelta(seconds=3)]) + async def test_send_all_args( + self, bot, chat_id, video_note_file, video_note, thumb_file, duration + ): message = await bot.send_video_note( chat_id, video_note_file, - duration=self.duration, + duration=duration, length=self.length, disable_notification=False, protect_content=True, @@ -248,20 +268,16 @@ async def test_send_all_args(self, bot, chat_id, video_note_file, video_note, th assert message.video_note.thumbnail.height == self.thumb_height assert message.has_protected_content - async def test_get_and_download(self, bot, video_note, chat_id): - path = Path("telegram2.mp4") - if path.is_file(): - path.unlink() - + async def test_get_and_download(self, bot, video_note, tmp_file): new_file = await bot.get_file(video_note.file_id) assert new_file.file_size == self.file_size assert new_file.file_unique_id == video_note.file_unique_id assert new_file.file_path.startswith("https://") - await new_file.download_to_drive("telegram2.mp4") + await new_file.download_to_drive(tmp_file) - assert path.is_file() + assert tmp_file.is_file() async def test_resend(self, bot, chat_id, video_note): message = await bot.send_video_note(chat_id, video_note.file_id) @@ -295,7 +311,7 @@ async def test_send_video_note_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="message not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_video_note( chat_id, video_note, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_files/test_voice.py b/tests/_files/test_voice.py index 69ed43d02c3..e1e47759db9 100644 --- a/tests/_files/test_voice.py +++ b/tests/_files/test_voice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,25 +17,29 @@ # 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 asyncio +import datetime as dtm import os from pathlib import Path import pytest -from telegram import Audio, Bot, InputFile, MessageEntity, Voice +from telegram import Audio, Bot, InputFile, MessageEntity, ReplyParameters, Voice +from telegram.constants import ParseMode from telegram.error import BadRequest, TelegramError from telegram.helpers import escape_markdown from telegram.request import RequestData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, check_shortcut_signature, ) +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file from tests.auxil.slots import mro_slots -@pytest.fixture() +@pytest.fixture def voice_file(): with data_file("telegram.ogg").open("rb") as f: yield f @@ -47,8 +51,8 @@ async def voice(bot, chat_id): return (await bot.send_voice(chat_id, voice=f, read_timeout=50)).voice -class TestVoiceBase: - duration = 3 +class VoiceTestBase: + duration = dtm.timedelta(seconds=3) mime_type = "audio/ogg" file_size = 9199 caption = "Test *voice*" @@ -57,7 +61,7 @@ class TestVoiceBase: voice_file_unique_id = "adc3145fd2e84d95b64d68eaa22aa33e" -class TestVoiceWithoutRequest(TestVoiceBase): +class TestVoiceWithoutRequest(VoiceTestBase): def test_slot_behaviour(self, voice): for attr in voice.__slots__: assert getattr(voice, attr, "err") != "err", f"got extra slot '{attr}'" @@ -72,24 +76,24 @@ async def test_creation(self, voice): assert voice.file_unique_id def test_expected_values(self, voice): - assert voice.duration == self.duration + assert voice._duration == self.duration assert voice.mime_type == self.mime_type assert voice.file_size == self.file_size - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "file_id": self.voice_file_id, "file_unique_id": self.voice_file_unique_id, - "duration": self.duration, + "duration": int(self.duration.total_seconds()), "mime_type": self.mime_type, "file_size": self.file_size, } - json_voice = Voice.de_json(json_dict, bot) + json_voice = Voice.de_json(json_dict, offline_bot) assert json_voice.api_kwargs == {} assert json_voice.file_id == self.voice_file_id assert json_voice.file_unique_id == self.voice_file_unique_id - assert json_voice.duration == self.duration + assert json_voice._duration == self.duration assert json_voice.mime_type == self.mime_type assert json_voice.file_size == self.file_size @@ -99,10 +103,29 @@ def test_to_dict(self, voice): assert isinstance(voice_dict, dict) assert voice_dict["file_id"] == voice.file_id assert voice_dict["file_unique_id"] == voice.file_unique_id - assert voice_dict["duration"] == voice.duration + assert voice_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(voice_dict["duration"], int) assert voice_dict["mime_type"] == voice.mime_type assert voice_dict["file_size"] == voice.file_size + def test_time_period_properties(self, PTB_TIMEDELTA, voice): + if PTB_TIMEDELTA: + assert voice.duration == self.duration + assert isinstance(voice.duration, dtm.timedelta) + else: + assert voice.duration == int(self.duration.total_seconds()) + assert isinstance(voice.duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, voice): + voice.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self, voice): a = Voice(voice.file_id, voice.file_unique_id, self.duration) b = Voice("", voice.file_unique_id, self.duration) @@ -123,30 +146,32 @@ def test_equality(self, voice): assert a != e assert hash(a) != hash(e) - async def test_error_without_required_args(self, bot, chat_id): + async def test_error_without_required_args(self, offline_bot, chat_id): with pytest.raises(TypeError): - await bot.sendVoice(chat_id) + await offline_bot.sendVoice(chat_id) - async def test_send_voice_custom_filename(self, bot, chat_id, voice_file, monkeypatch): + async def test_send_voice_custom_filename(self, offline_bot, chat_id, voice_file, monkeypatch): async def make_assertion(url, request_data: RequestData, *args, **kwargs): - return list(request_data.multipart_data.values())[0][0] == "custom_filename" + return next(iter(request_data.multipart_data.values()))[0] == "custom_filename" - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - assert await bot.send_voice(chat_id, voice_file, filename="custom_filename") + assert await offline_bot.send_voice(chat_id, voice_file, filename="custom_filename") - async def test_send_with_voice(self, monkeypatch, bot, chat_id, voice): + async def test_send_with_voice(self, monkeypatch, offline_bot, chat_id, voice): async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.json_parameters["voice"] == voice.file_id - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.send_voice(chat_id, voice=voice) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_voice(chat_id, voice=voice) @pytest.mark.parametrize("local_mode", [True, False]) - async def test_send_voice_local_files(self, monkeypatch, bot, chat_id, local_mode): + async def test_send_voice_local_files( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): try: - bot._local_mode = local_mode - # For just test that the correct paths are passed as we have no local bot API set up + offline_bot._local_mode = local_mode + # For just test that the correct paths are passed as we have no local Bot API set up test_flag = False file = data_file("telegram.jpg") expected = file.as_uri() @@ -157,12 +182,13 @@ async def make_assertion(_, data, *args, **kwargs): test_flag = data.get("voice") == expected else: test_flag = isinstance(data.get("voice"), InputFile) + return dummy_message_dict - monkeypatch.setattr(bot, "_post", make_assertion) - await bot.send_voice(chat_id, file) + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.send_voice(chat_id, file) assert test_flag finally: - bot._local_mode = False + offline_bot._local_mode = False async def test_get_file_instance_method(self, monkeypatch, voice): async def make_assertion(*_, **kwargs): @@ -175,13 +201,39 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(voice.get_bot(), "get_file", make_assertion) assert await voice.get_file() + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_voice_default_quote_parse_mode( + self, default_bot, chat_id, voice, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() + + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_voice(chat_id, voice, reply_parameters=ReplyParameters(**kwargs)) -class TestVoiceWithRequest(TestVoiceBase): - async def test_send_all_args(self, bot, chat_id, voice_file, voice): + +class TestVoiceWithRequest(VoiceTestBase): + @pytest.mark.parametrize("duration", [3, dtm.timedelta(seconds=3)]) + async def test_send_all_args(self, bot, chat_id, voice_file, voice, duration): message = await bot.send_voice( chat_id, voice_file, - duration=self.duration, + duration=duration, caption=self.caption, disable_notification=False, protect_content=True, @@ -199,20 +251,16 @@ async def test_send_all_args(self, bot, chat_id, voice_file, voice): assert message.caption == self.caption.replace("*", "") assert message.has_protected_content - async def test_get_and_download(self, bot, voice, chat_id): - path = Path("telegram.ogg") - if path.is_file(): - path.unlink() - + async def test_get_and_download(self, bot, voice, chat_id, tmp_file): new_file = await bot.get_file(voice.file_id) assert new_file.file_size == voice.file_size assert new_file.file_unique_id == voice.file_unique_id assert new_file.file_path.startswith("https://") - await new_file.download_to_drive("telegram.ogg") + await new_file.download_to_drive(tmp_file) - assert path.is_file() + assert tmp_file.is_file() async def test_send_ogg_url_file(self, bot, chat_id, voice): message = await bot.sendVoice(chat_id, self.voice_file_url, duration=self.duration) @@ -312,7 +360,7 @@ async def test_send_voice_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="message not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_voice( chat_id, voice, reply_to_message_id=reply_to_message.message_id ) diff --git a/tests/_games/__init__.py b/tests/_games/__init__.py index 1eaba12c869..c95cb3c9741 100644 --- a/tests/_games/__init__.py +++ b/tests/_games/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_games/test_game.py b/tests/_games/test_game.py index d9f686a1841..4db32d4c9aa 100644 --- a/tests/_games/test_game.py +++ b/tests/_games/test_game.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -26,18 +26,18 @@ @pytest.fixture(scope="module") def game(): game = Game( - TestGameBase.title, - TestGameBase.description, - TestGameBase.photo, - text=TestGameBase.text, - text_entities=TestGameBase.text_entities, - animation=TestGameBase.animation, + GameTestBase.title, + GameTestBase.description, + GameTestBase.photo, + text=GameTestBase.text, + text_entities=GameTestBase.text_entities, + animation=GameTestBase.animation, ) game._unfreeze() return game -class TestGameBase: +class GameTestBase: title = "Python-telegram-bot Test Game" description = "description" photo = [PhotoSize("Blah", "ElseBlah", 640, 360, file_size=0)] @@ -49,26 +49,26 @@ class TestGameBase: animation = Animation("blah", "unique_id", 320, 180, 1) -class TestGameWithoutRequest(TestGameBase): +class TestGameWithoutRequest(GameTestBase): def test_slot_behaviour(self, game): for attr in game.__slots__: assert getattr(game, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(game)) == len(set(mro_slots(game))), "duplicate slot" - def test_de_json_required(self, bot): + def test_de_json_required(self, offline_bot): json_dict = { "title": self.title, "description": self.description, "photo": [self.photo[0].to_dict()], } - game = Game.de_json(json_dict, bot) + game = Game.de_json(json_dict, offline_bot) assert game.api_kwargs == {} assert game.title == self.title assert game.description == self.description assert game.photo == tuple(self.photo) - def test_de_json_all(self, bot): + def test_de_json_all(self, offline_bot): json_dict = { "title": self.title, "description": self.description, @@ -77,7 +77,7 @@ def test_de_json_all(self, bot): "text_entities": [self.text_entities[0].to_dict()], "animation": self.animation.to_dict(), } - game = Game.de_json(json_dict, bot) + game = Game.de_json(json_dict, offline_bot) assert game.api_kwargs == {} assert game.title == self.title diff --git a/tests/_games/test_gamehighscore.py b/tests/_games/test_gamehighscore.py index 3ca03ef4498..d0d6008b449 100644 --- a/tests/_games/test_gamehighscore.py +++ b/tests/_games/test_gamehighscore.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -26,37 +26,35 @@ @pytest.fixture(scope="module") def game_highscore(): return GameHighScore( - TestGameHighScoreBase.position, TestGameHighScoreBase.user, TestGameHighScoreBase.score + GameHighScoreTestBase.position, GameHighScoreTestBase.user, GameHighScoreTestBase.score ) -class TestGameHighScoreBase: +class GameHighScoreTestBase: position = 12 user = User(2, "test user", False) score = 42 -class TestGameHighScoreWithoutRequest(TestGameHighScoreBase): +class TestGameHighScoreWithoutRequest(GameHighScoreTestBase): def test_slot_behaviour(self, game_highscore): for attr in game_highscore.__slots__: assert getattr(game_highscore, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(game_highscore)) == len(set(mro_slots(game_highscore))), "same slot" - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "position": self.position, "user": self.user.to_dict(), "score": self.score, } - highscore = GameHighScore.de_json(json_dict, bot) + highscore = GameHighScore.de_json(json_dict, offline_bot) assert highscore.api_kwargs == {} assert highscore.position == self.position assert highscore.user == self.user assert highscore.score == self.score - assert GameHighScore.de_json(None, bot) is None - def test_to_dict(self, game_highscore): game_highscore_dict = game_highscore.to_dict() diff --git a/tests/_inline/__init__.py b/tests/_inline/__init__.py index 1eaba12c869..c95cb3c9741 100644 --- a/tests/_inline/__init__.py +++ b/tests/_inline/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_inline/test_inlinekeyboardbutton.py b/tests/_inline/test_inlinekeyboardbutton.py index 15c7ef8bf5d..f1f0f798931 100644 --- a/tests/_inline/test_inlinekeyboardbutton.py +++ b/tests/_inline/test_inlinekeyboardbutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -21,6 +21,7 @@ from telegram import ( CallbackGame, + CopyTextButton, InlineKeyboardButton, LoginUrl, SwitchInlineQueryChosenChat, @@ -32,20 +33,25 @@ @pytest.fixture(scope="module") def inline_keyboard_button(): return InlineKeyboardButton( - TestInlineKeyboardButtonBase.text, - url=TestInlineKeyboardButtonBase.url, - callback_data=TestInlineKeyboardButtonBase.callback_data, - switch_inline_query=TestInlineKeyboardButtonBase.switch_inline_query, - switch_inline_query_current_chat=TestInlineKeyboardButtonBase.switch_inline_query_current_chat, # noqa: E501 - callback_game=TestInlineKeyboardButtonBase.callback_game, - pay=TestInlineKeyboardButtonBase.pay, - login_url=TestInlineKeyboardButtonBase.login_url, - web_app=TestInlineKeyboardButtonBase.web_app, - switch_inline_query_chosen_chat=TestInlineKeyboardButtonBase.switch_inline_query_chosen_chat, # noqa: E501 + InlineKeyboardButtonTestBase.text, + url=InlineKeyboardButtonTestBase.url, + callback_data=InlineKeyboardButtonTestBase.callback_data, + switch_inline_query=InlineKeyboardButtonTestBase.switch_inline_query, + switch_inline_query_current_chat=( + InlineKeyboardButtonTestBase.switch_inline_query_current_chat + ), + callback_game=InlineKeyboardButtonTestBase.callback_game, + pay=InlineKeyboardButtonTestBase.pay, + login_url=InlineKeyboardButtonTestBase.login_url, + web_app=InlineKeyboardButtonTestBase.web_app, + switch_inline_query_chosen_chat=( + InlineKeyboardButtonTestBase.switch_inline_query_chosen_chat + ), + copy_text=InlineKeyboardButtonTestBase.copy_text, ) -class TestInlineKeyboardButtonBase: +class InlineKeyboardButtonTestBase: text = "text" url = "url" callback_data = "callback data" @@ -56,9 +62,10 @@ class TestInlineKeyboardButtonBase: login_url = LoginUrl("http://google.com") web_app = WebAppInfo(url="https://example.com") switch_inline_query_chosen_chat = SwitchInlineQueryChosenChat("a_bot", True, False, True, True) + copy_text = CopyTextButton("python-telegram-bot") -class TestInlineKeyboardButtonWithoutRequest(TestInlineKeyboardButtonBase): +class TestInlineKeyboardButtonWithoutRequest(InlineKeyboardButtonTestBase): def test_slot_behaviour(self, inline_keyboard_button): inst = inline_keyboard_button for attr in inst.__slots__: @@ -82,6 +89,7 @@ def test_expected_values(self, inline_keyboard_button): inline_keyboard_button.switch_inline_query_chosen_chat == self.switch_inline_query_chosen_chat ) + assert inline_keyboard_button.copy_text == self.copy_text def test_to_dict(self, inline_keyboard_button): inline_keyboard_button_dict = inline_keyboard_button.to_dict() @@ -111,8 +119,11 @@ def test_to_dict(self, inline_keyboard_button): inline_keyboard_button_dict["switch_inline_query_chosen_chat"] == inline_keyboard_button.switch_inline_query_chosen_chat.to_dict() ) + assert ( + inline_keyboard_button_dict["copy_text"] == inline_keyboard_button.copy_text.to_dict() + ) - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "text": self.text, "url": self.url, @@ -124,6 +135,7 @@ def test_de_json(self, bot): "login_url": self.login_url.to_dict(), "pay": self.pay, "switch_inline_query_chosen_chat": self.switch_inline_query_chosen_chat.to_dict(), + "copy_text": self.copy_text.to_dict(), } inline_keyboard_button = InlineKeyboardButton.de_json(json_dict, None) @@ -145,9 +157,7 @@ def test_de_json(self, bot): inline_keyboard_button.switch_inline_query_chosen_chat == self.switch_inline_query_chosen_chat ) - - none = InlineKeyboardButton.de_json({}, bot) - assert none is None + assert inline_keyboard_button.copy_text == self.copy_text def test_equality(self): a = InlineKeyboardButton("text", callback_data="data") diff --git a/tests/_inline/test_inlinekeyboardmarkup.py b/tests/_inline/test_inlinekeyboardmarkup.py index 3e67e4db22d..3de2901ca6f 100644 --- a/tests/_inline/test_inlinekeyboardmarkup.py +++ b/tests/_inline/test_inlinekeyboardmarkup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -31,10 +31,10 @@ @pytest.fixture(scope="module") def inline_keyboard_markup(): - return InlineKeyboardMarkup(TestInlineKeyboardMarkupBase.inline_keyboard) + return InlineKeyboardMarkup(InlineKeyboardMarkupTestBase.inline_keyboard) -class TestInlineKeyboardMarkupBase: +class InlineKeyboardMarkupTestBase: inline_keyboard = [ [ InlineKeyboardButton(text="button1", callback_data="data1"), @@ -43,7 +43,7 @@ class TestInlineKeyboardMarkupBase: ] -class TestInlineKeyboardMarkupWithoutRequest(TestInlineKeyboardMarkupBase): +class TestInlineKeyboardMarkupWithoutRequest(InlineKeyboardMarkupTestBase): def test_slot_behaviour(self, inline_keyboard_markup): inst = inline_keyboard_markup for attr in inst.__slots__: @@ -192,7 +192,9 @@ def test_wrong_keyboard_inputs(self): with pytest.raises(ValueError, match="should be a sequence of sequences"): InlineKeyboardMarkup([[[InlineKeyboardButton("only_2d_array_is_allowed", "1")]]]) - async def test_expected_values_empty_switch(self, inline_keyboard_markup, bot, monkeypatch): + async def test_expected_values_empty_switch( + self, inline_keyboard_markup, offline_bot, monkeypatch + ): async def make_assertion( url, data, @@ -224,11 +226,11 @@ async def make_assertion( inline_keyboard_markup.inline_keyboard[0][1].callback_data = None inline_keyboard_markup.inline_keyboard[0][1].switch_inline_query_current_chat = "" - monkeypatch.setattr(bot, "_send_message", make_assertion) - await bot.send_message(123, "test", reply_markup=inline_keyboard_markup) + monkeypatch.setattr(offline_bot, "_send_message", make_assertion) + await offline_bot.send_message(123, "test", reply_markup=inline_keyboard_markup) -class TestInlineKeyborardMarkupWithRequest(TestInlineKeyboardMarkupBase): +class TestInlineKeyborardMarkupWithRequest(InlineKeyboardMarkupTestBase): async def test_send_message_with_inline_keyboard_markup( self, bot, chat_id, inline_keyboard_markup ): diff --git a/tests/_inline/test_inlinequery.py b/tests/_inline/test_inlinequery.py index c03cb5efe3d..9e16035117b 100644 --- a/tests/_inline/test_inlinequery.py +++ b/tests/_inline/test_inlinequery.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -31,17 +31,17 @@ @pytest.fixture(scope="module") def inline_query(bot): ilq = InlineQuery( - TestInlineQueryBase.id_, - TestInlineQueryBase.from_user, - TestInlineQueryBase.query, - TestInlineQueryBase.offset, - location=TestInlineQueryBase.location, + InlineQueryTestBase.id_, + InlineQueryTestBase.from_user, + InlineQueryTestBase.query, + InlineQueryTestBase.offset, + location=InlineQueryTestBase.location, ) ilq.set_bot(bot) return ilq -class TestInlineQueryBase: +class InlineQueryTestBase: id_ = 1234 from_user = User(1, "First name", False) query = "query text" @@ -49,13 +49,13 @@ class TestInlineQueryBase: location = Location(8.8, 53.1) -class TestInlineQueryWithoutRequest(TestInlineQueryBase): +class TestInlineQueryWithoutRequest(InlineQueryTestBase): def test_slot_behaviour(self, inline_query): for attr in inline_query.__slots__: assert getattr(inline_query, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(inline_query)) == len(set(mro_slots(inline_query))), "duplicate slot" - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "id": self.id_, "from": self.from_user.to_dict(), @@ -63,7 +63,7 @@ def test_de_json(self, bot): "offset": self.offset, "location": self.location.to_dict(), } - inline_query_json = InlineQuery.de_json(json_dict, bot) + inline_query_json = InlineQuery.de_json(json_dict, offline_bot) assert inline_query_json.api_kwargs == {} assert inline_query_json.id == self.id_ diff --git a/tests/_inline/test_inlinequeryresultarticle.py b/tests/_inline/test_inlinequeryresultarticle.py index c74ffe457e3..9d99e4a52c0 100644 --- a/tests/_inline/test_inlinequeryresultarticle.py +++ b/tests/_inline/test_inlinequeryresultarticle.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -22,45 +22,44 @@ from telegram import ( InlineKeyboardButton, InlineKeyboardMarkup, + InlineQueryResult, InlineQueryResultArticle, InlineQueryResultAudio, InputTextMessageContent, ) -from tests.auxil.deprecations import check_thumb_deprecation_warnings_for_args_and_attrs +from telegram.constants import InlineQueryResultType from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def inline_query_result_article(): return InlineQueryResultArticle( - TestInlineQueryResultArticleBase.id_, - TestInlineQueryResultArticleBase.title, - input_message_content=TestInlineQueryResultArticleBase.input_message_content, - reply_markup=TestInlineQueryResultArticleBase.reply_markup, - url=TestInlineQueryResultArticleBase.url, - hide_url=TestInlineQueryResultArticleBase.hide_url, - description=TestInlineQueryResultArticleBase.description, - thumbnail_url=TestInlineQueryResultArticleBase.thumbnail_url, - thumbnail_height=TestInlineQueryResultArticleBase.thumbnail_height, - thumbnail_width=TestInlineQueryResultArticleBase.thumbnail_width, + InlineQueryResultArticleTestBase.id_, + InlineQueryResultArticleTestBase.title, + input_message_content=InlineQueryResultArticleTestBase.input_message_content, + reply_markup=InlineQueryResultArticleTestBase.reply_markup, + url=InlineQueryResultArticleTestBase.url, + description=InlineQueryResultArticleTestBase.description, + thumbnail_url=InlineQueryResultArticleTestBase.thumbnail_url, + thumbnail_height=InlineQueryResultArticleTestBase.thumbnail_height, + thumbnail_width=InlineQueryResultArticleTestBase.thumbnail_width, ) -class TestInlineQueryResultArticleBase: +class InlineQueryResultArticleTestBase: id_ = "id" type_ = "article" title = "title" input_message_content = InputTextMessageContent("input_message_content") reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) url = "url" - hide_url = True description = "description" thumbnail_url = "thumb url" thumbnail_height = 10 thumbnail_width = 15 -class TestInlineQueryResultArticleWithoutRequest(TestInlineQueryResultArticleBase): +class TestInlineQueryResultArticleWithoutRequest(InlineQueryResultArticleTestBase): def test_slot_behaviour(self, inline_query_result_article): inst = inline_query_result_article for attr in inst.__slots__: @@ -77,126 +76,11 @@ def test_expected_values(self, inline_query_result_article): ) assert inline_query_result_article.reply_markup.to_dict() == self.reply_markup.to_dict() assert inline_query_result_article.url == self.url - assert inline_query_result_article.hide_url == self.hide_url assert inline_query_result_article.description == self.description assert inline_query_result_article.thumbnail_url == self.thumbnail_url assert inline_query_result_article.thumbnail_height == self.thumbnail_height assert inline_query_result_article.thumbnail_width == self.thumbnail_width - def test_thumb_url_property_deprecation_warning(self, recwarn): - inline_query_result_article = InlineQueryResultArticle( - TestInlineQueryResultArticleBase.id_, - TestInlineQueryResultArticleBase.title, - input_message_content=TestInlineQueryResultArticleBase.input_message_content, - reply_markup=TestInlineQueryResultArticleBase.reply_markup, - url=TestInlineQueryResultArticleBase.url, - hide_url=TestInlineQueryResultArticleBase.hide_url, - description=TestInlineQueryResultArticleBase.description, - thumb_url=TestInlineQueryResultArticleBase.thumbnail_url, # deprecated arg - thumbnail_height=TestInlineQueryResultArticleBase.thumbnail_height, - thumbnail_width=TestInlineQueryResultArticleBase.thumbnail_width, - ) - assert inline_query_result_article.thumb_url == inline_query_result_article.thumbnail_url - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_url", new_name="thumbnail_url" - ) - - def test_thumb_height_property_deprecation_warning(self, recwarn): - inline_query_result_article = InlineQueryResultArticle( - TestInlineQueryResultArticleBase.id_, - TestInlineQueryResultArticleBase.title, - input_message_content=TestInlineQueryResultArticleBase.input_message_content, - reply_markup=TestInlineQueryResultArticleBase.reply_markup, - url=TestInlineQueryResultArticleBase.url, - hide_url=TestInlineQueryResultArticleBase.hide_url, - description=TestInlineQueryResultArticleBase.description, - thumbnail_url=TestInlineQueryResultArticleBase.thumbnail_url, - thumb_height=TestInlineQueryResultArticleBase.thumbnail_height, # deprecated arg - thumbnail_width=TestInlineQueryResultArticleBase.thumbnail_width, - ) - assert ( - inline_query_result_article.thumb_height - == inline_query_result_article.thumbnail_height - ) - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_height", new_name="thumbnail_height" - ) - - def test_thumb_width_property_deprecation_warning(self, recwarn): - inline_query_result_article = InlineQueryResultArticle( - TestInlineQueryResultArticleBase.id_, - TestInlineQueryResultArticleBase.title, - input_message_content=TestInlineQueryResultArticleBase.input_message_content, - reply_markup=TestInlineQueryResultArticleBase.reply_markup, - url=TestInlineQueryResultArticleBase.url, - hide_url=TestInlineQueryResultArticleBase.hide_url, - description=TestInlineQueryResultArticleBase.description, - thumbnail_url=TestInlineQueryResultArticleBase.thumbnail_url, - thumbnail_height=TestInlineQueryResultArticleBase.thumbnail_height, - thumb_width=TestInlineQueryResultArticleBase.thumbnail_width, # deprecated arg - ) - assert ( - inline_query_result_article.thumb_width == inline_query_result_article.thumbnail_width - ) - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_width", new_name="thumbnail_width" - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_url(self): - with pytest.raises( - ValueError, - match="different entities as 'thumb_url' and 'thumbnail_url'", - ): - InlineQueryResultArticle( - TestInlineQueryResultArticleBase.id_, - TestInlineQueryResultArticleBase.title, - input_message_content=TestInlineQueryResultArticleBase.input_message_content, - reply_markup=TestInlineQueryResultArticleBase.reply_markup, - url=TestInlineQueryResultArticleBase.url, - hide_url=TestInlineQueryResultArticleBase.hide_url, - description=TestInlineQueryResultArticleBase.description, - thumbnail_url=TestInlineQueryResultArticleBase.thumbnail_url, - thumb_url="some other url", - thumbnail_height=TestInlineQueryResultArticleBase.thumbnail_height, - thumbnail_width=TestInlineQueryResultArticleBase.thumbnail_width, - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_height(self): - with pytest.raises( - ValueError, - match="different entities as 'thumb_height' and 'thumbnail_height'", - ): - InlineQueryResultArticle( - TestInlineQueryResultArticleBase.id_, - TestInlineQueryResultArticleBase.title, - input_message_content=TestInlineQueryResultArticleBase.input_message_content, - reply_markup=TestInlineQueryResultArticleBase.reply_markup, - url=TestInlineQueryResultArticleBase.url, - hide_url=TestInlineQueryResultArticleBase.hide_url, - description=TestInlineQueryResultArticleBase.description, - thumbnail_height=TestInlineQueryResultArticleBase.thumbnail_height, - thumb_height=TestInlineQueryResultArticleBase.thumbnail_height + 1, - thumbnail_width=TestInlineQueryResultArticleBase.thumbnail_width, - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_width(self): - with pytest.raises( - ValueError, - match="different entities as 'thumb_width' and 'thumbnail_width'", - ): - InlineQueryResultArticle( - TestInlineQueryResultArticleBase.id_, - TestInlineQueryResultArticleBase.title, - input_message_content=TestInlineQueryResultArticleBase.input_message_content, - reply_markup=TestInlineQueryResultArticleBase.reply_markup, - url=TestInlineQueryResultArticleBase.url, - hide_url=TestInlineQueryResultArticleBase.hide_url, - description=TestInlineQueryResultArticleBase.description, - thumbnail_height=TestInlineQueryResultArticleBase.thumbnail_height, - thumbnail_width=TestInlineQueryResultArticleBase.thumbnail_width, - thumb_width=TestInlineQueryResultArticleBase.thumbnail_width + 1, - ) - def test_to_dict(self, inline_query_result_article): inline_query_result_article_dict = inline_query_result_article.to_dict() @@ -213,7 +97,6 @@ def test_to_dict(self, inline_query_result_article): == inline_query_result_article.reply_markup.to_dict() ) assert inline_query_result_article_dict["url"] == inline_query_result_article.url - assert inline_query_result_article_dict["hide_url"] == inline_query_result_article.hide_url assert ( inline_query_result_article_dict["description"] == inline_query_result_article.description @@ -231,6 +114,26 @@ def test_to_dict(self, inline_query_result_article): == inline_query_result_article.thumbnail_width ) + def test_type_enum_conversion(self): + # Since we have a lot of different test files for all the result types, we test this + # conversion only here. It is independent of the specific class + assert ( + type( + InlineQueryResult( + id="id", + type="article", + ).type + ) + is InlineQueryResultType + ) + assert ( + InlineQueryResult( + id="id", + type="unknown", + ).type + == "unknown" + ) + def test_equality(self): a = InlineQueryResultArticle(self.id_, self.title, self.input_message_content) b = InlineQueryResultArticle(self.id_, self.title, self.input_message_content) diff --git a/tests/_inline/test_inlinequeryresultaudio.py b/tests/_inline/test_inlinequeryresultaudio.py index e1331d6900b..896d1f263bd 100644 --- a/tests/_inline/test_inlinequeryresultaudio.py +++ b/tests/_inline/test_inlinequeryresultaudio.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,6 +17,8 @@ # 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 as dtm + import pytest from telegram import ( @@ -27,32 +29,33 @@ InputTextMessageContent, MessageEntity, ) +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def inline_query_result_audio(): return InlineQueryResultAudio( - TestInlineQueryResultAudioBase.id_, - TestInlineQueryResultAudioBase.audio_url, - TestInlineQueryResultAudioBase.title, - performer=TestInlineQueryResultAudioBase.performer, - audio_duration=TestInlineQueryResultAudioBase.audio_duration, - caption=TestInlineQueryResultAudioBase.caption, - parse_mode=TestInlineQueryResultAudioBase.parse_mode, - caption_entities=TestInlineQueryResultAudioBase.caption_entities, - input_message_content=TestInlineQueryResultAudioBase.input_message_content, - reply_markup=TestInlineQueryResultAudioBase.reply_markup, + InlineQueryResultAudioTestBase.id_, + InlineQueryResultAudioTestBase.audio_url, + InlineQueryResultAudioTestBase.title, + performer=InlineQueryResultAudioTestBase.performer, + audio_duration=InlineQueryResultAudioTestBase.audio_duration, + caption=InlineQueryResultAudioTestBase.caption, + parse_mode=InlineQueryResultAudioTestBase.parse_mode, + caption_entities=InlineQueryResultAudioTestBase.caption_entities, + input_message_content=InlineQueryResultAudioTestBase.input_message_content, + reply_markup=InlineQueryResultAudioTestBase.reply_markup, ) -class TestInlineQueryResultAudioBase: +class InlineQueryResultAudioTestBase: id_ = "id" type_ = "audio" audio_url = "audio url" title = "title" performer = "performer" - audio_duration = "audio_duration" + audio_duration = dtm.timedelta(seconds=10) caption = "caption" parse_mode = "Markdown" caption_entities = [MessageEntity(MessageEntity.ITALIC, 0, 7)] @@ -60,7 +63,7 @@ class TestInlineQueryResultAudioBase: reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) -class TestInlineQueryResultAudioWithoutRequest(TestInlineQueryResultAudioBase): +class TestInlineQueryResultAudioWithoutRequest(InlineQueryResultAudioTestBase): def test_slot_behaviour(self, inline_query_result_audio): inst = inline_query_result_audio for attr in inst.__slots__: @@ -73,7 +76,7 @@ def test_expected_values(self, inline_query_result_audio): assert inline_query_result_audio.audio_url == self.audio_url assert inline_query_result_audio.title == self.title assert inline_query_result_audio.performer == self.performer - assert inline_query_result_audio.audio_duration == self.audio_duration + assert inline_query_result_audio._audio_duration == self.audio_duration assert inline_query_result_audio.caption == self.caption assert inline_query_result_audio.parse_mode == self.parse_mode assert inline_query_result_audio.caption_entities == tuple(self.caption_entities) @@ -92,10 +95,10 @@ def test_to_dict(self, inline_query_result_audio): assert inline_query_result_audio_dict["audio_url"] == inline_query_result_audio.audio_url assert inline_query_result_audio_dict["title"] == inline_query_result_audio.title assert inline_query_result_audio_dict["performer"] == inline_query_result_audio.performer - assert ( - inline_query_result_audio_dict["audio_duration"] - == inline_query_result_audio.audio_duration + assert inline_query_result_audio_dict["audio_duration"] == int( + self.audio_duration.total_seconds() ) + assert isinstance(inline_query_result_audio_dict["audio_duration"], int) assert inline_query_result_audio_dict["caption"] == inline_query_result_audio.caption assert inline_query_result_audio_dict["parse_mode"] == inline_query_result_audio.parse_mode assert inline_query_result_audio_dict["caption_entities"] == [ @@ -114,6 +117,28 @@ def test_caption_entities_always_tuple(self): inline_query_result_audio = InlineQueryResultAudio(self.id_, self.audio_url, self.title) assert inline_query_result_audio.caption_entities == () + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_audio): + audio_duration = inline_query_result_audio.audio_duration + + if PTB_TIMEDELTA: + assert audio_duration == self.audio_duration + assert isinstance(audio_duration, dtm.timedelta) + else: + assert audio_duration == int(self.audio_duration.total_seconds()) + assert isinstance(audio_duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, inline_query_result_audio): + inline_query_result_audio.audio_duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`audio_duration` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = InlineQueryResultAudio(self.id_, self.audio_url, self.title) b = InlineQueryResultAudio(self.id_, self.title, self.title) diff --git a/tests/_inline/test_inlinequeryresultcachedaudio.py b/tests/_inline/test_inlinequeryresultcachedaudio.py index 3896fe6dc79..6bbbfac4abb 100644 --- a/tests/_inline/test_inlinequeryresultcachedaudio.py +++ b/tests/_inline/test_inlinequeryresultcachedaudio.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -33,17 +33,17 @@ @pytest.fixture(scope="module") def inline_query_result_cached_audio(): return InlineQueryResultCachedAudio( - TestInlineQueryResultCachedAudioBase.id_, - TestInlineQueryResultCachedAudioBase.audio_file_id, - caption=TestInlineQueryResultCachedAudioBase.caption, - parse_mode=TestInlineQueryResultCachedAudioBase.parse_mode, - caption_entities=TestInlineQueryResultCachedAudioBase.caption_entities, - input_message_content=TestInlineQueryResultCachedAudioBase.input_message_content, - reply_markup=TestInlineQueryResultCachedAudioBase.reply_markup, + InlineQueryResultCachedAudioTestBase.id_, + InlineQueryResultCachedAudioTestBase.audio_file_id, + caption=InlineQueryResultCachedAudioTestBase.caption, + parse_mode=InlineQueryResultCachedAudioTestBase.parse_mode, + caption_entities=InlineQueryResultCachedAudioTestBase.caption_entities, + input_message_content=InlineQueryResultCachedAudioTestBase.input_message_content, + reply_markup=InlineQueryResultCachedAudioTestBase.reply_markup, ) -class TestInlineQueryResultCachedAudioBase: +class InlineQueryResultCachedAudioTestBase: id_ = "id" type_ = "audio" audio_file_id = "audio file id" @@ -54,7 +54,7 @@ class TestInlineQueryResultCachedAudioBase: reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) -class TestInlineQueryResultCachedAudioWithoutRequest(TestInlineQueryResultCachedAudioBase): +class TestInlineQueryResultCachedAudioWithoutRequest(InlineQueryResultCachedAudioTestBase): def test_slot_behaviour(self, inline_query_result_cached_audio): inst = inline_query_result_cached_audio for attr in inst.__slots__: diff --git a/tests/_inline/test_inlinequeryresultcacheddocument.py b/tests/_inline/test_inlinequeryresultcacheddocument.py index 3b4477b4da4..f9a7fd59233 100644 --- a/tests/_inline/test_inlinequeryresultcacheddocument.py +++ b/tests/_inline/test_inlinequeryresultcacheddocument.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -33,19 +33,19 @@ @pytest.fixture(scope="module") def inline_query_result_cached_document(): return InlineQueryResultCachedDocument( - TestInlineQueryResultCachedDocumentBase.id_, - TestInlineQueryResultCachedDocumentBase.title, - TestInlineQueryResultCachedDocumentBase.document_file_id, - caption=TestInlineQueryResultCachedDocumentBase.caption, - parse_mode=TestInlineQueryResultCachedDocumentBase.parse_mode, - caption_entities=TestInlineQueryResultCachedDocumentBase.caption_entities, - description=TestInlineQueryResultCachedDocumentBase.description, - input_message_content=TestInlineQueryResultCachedDocumentBase.input_message_content, - reply_markup=TestInlineQueryResultCachedDocumentBase.reply_markup, + InlineQueryResultCachedDocumentTestBase.id_, + InlineQueryResultCachedDocumentTestBase.title, + InlineQueryResultCachedDocumentTestBase.document_file_id, + caption=InlineQueryResultCachedDocumentTestBase.caption, + parse_mode=InlineQueryResultCachedDocumentTestBase.parse_mode, + caption_entities=InlineQueryResultCachedDocumentTestBase.caption_entities, + description=InlineQueryResultCachedDocumentTestBase.description, + input_message_content=InlineQueryResultCachedDocumentTestBase.input_message_content, + reply_markup=InlineQueryResultCachedDocumentTestBase.reply_markup, ) -class TestInlineQueryResultCachedDocumentBase: +class InlineQueryResultCachedDocumentTestBase: id_ = "id" type_ = "document" document_file_id = "document file id" @@ -58,7 +58,7 @@ class TestInlineQueryResultCachedDocumentBase: reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) -class TestInlineQueryResultCachedDocumentWithoutRequest(TestInlineQueryResultCachedDocumentBase): +class TestInlineQueryResultCachedDocumentWithoutRequest(InlineQueryResultCachedDocumentTestBase): def test_slot_behaviour(self, inline_query_result_cached_document): inst = inline_query_result_cached_document for attr in inst.__slots__: diff --git a/tests/_inline/test_inlinequeryresultcachedgif.py b/tests/_inline/test_inlinequeryresultcachedgif.py index 9a2e7db00c8..481b100ae90 100644 --- a/tests/_inline/test_inlinequeryresultcachedgif.py +++ b/tests/_inline/test_inlinequeryresultcachedgif.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -32,18 +32,19 @@ @pytest.fixture(scope="module") def inline_query_result_cached_gif(): return InlineQueryResultCachedGif( - TestInlineQueryResultCachedGifBase.id_, - TestInlineQueryResultCachedGifBase.gif_file_id, - title=TestInlineQueryResultCachedGifBase.title, - caption=TestInlineQueryResultCachedGifBase.caption, - parse_mode=TestInlineQueryResultCachedGifBase.parse_mode, - caption_entities=TestInlineQueryResultCachedGifBase.caption_entities, - input_message_content=TestInlineQueryResultCachedGifBase.input_message_content, - reply_markup=TestInlineQueryResultCachedGifBase.reply_markup, + InlineQueryResultCachedGifTestBase.id_, + InlineQueryResultCachedGifTestBase.gif_file_id, + title=InlineQueryResultCachedGifTestBase.title, + caption=InlineQueryResultCachedGifTestBase.caption, + parse_mode=InlineQueryResultCachedGifTestBase.parse_mode, + caption_entities=InlineQueryResultCachedGifTestBase.caption_entities, + input_message_content=InlineQueryResultCachedGifTestBase.input_message_content, + reply_markup=InlineQueryResultCachedGifTestBase.reply_markup, + show_caption_above_media=InlineQueryResultCachedGifTestBase.show_caption_above_media, ) -class TestInlineQueryResultCachedGifBase: +class InlineQueryResultCachedGifTestBase: id_ = "id" type_ = "gif" gif_file_id = "gif file id" @@ -53,9 +54,10 @@ class TestInlineQueryResultCachedGifBase: caption_entities = [MessageEntity(MessageEntity.ITALIC, 0, 7)] input_message_content = InputTextMessageContent("input_message_content") reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) + show_caption_above_media = True -class TestInlineQueryResultCachedGifWithoutRequest(TestInlineQueryResultCachedGifBase): +class TestInlineQueryResultCachedGifWithoutRequest(InlineQueryResultCachedGifTestBase): def test_slot_behaviour(self, inline_query_result_cached_gif): inst = inline_query_result_cached_gif for attr in inst.__slots__: @@ -75,6 +77,10 @@ def test_expected_values(self, inline_query_result_cached_gif): == self.input_message_content.to_dict() ) assert inline_query_result_cached_gif.reply_markup.to_dict() == self.reply_markup.to_dict() + assert ( + inline_query_result_cached_gif.show_caption_above_media + == self.show_caption_above_media + ) def test_caption_entities_always_tuple(self): result = InlineQueryResultCachedGif(self.id_, self.gif_file_id) @@ -110,6 +116,10 @@ def test_to_dict(self, inline_query_result_cached_gif): inline_query_result_cached_gif_dict["reply_markup"] == inline_query_result_cached_gif.reply_markup.to_dict() ) + assert ( + inline_query_result_cached_gif_dict["show_caption_above_media"] + == inline_query_result_cached_gif.show_caption_above_media + ) def test_equality(self): a = InlineQueryResultCachedGif(self.id_, self.gif_file_id) diff --git a/tests/_inline/test_inlinequeryresultcachedmpeg4gif.py b/tests/_inline/test_inlinequeryresultcachedmpeg4gif.py index 52ccbf64701..b3712bc6eaf 100644 --- a/tests/_inline/test_inlinequeryresultcachedmpeg4gif.py +++ b/tests/_inline/test_inlinequeryresultcachedmpeg4gif.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -32,18 +32,19 @@ @pytest.fixture(scope="module") def inline_query_result_cached_mpeg4_gif(): return InlineQueryResultCachedMpeg4Gif( - TestInlineQueryResultCachedMpeg4GifBase.id_, - TestInlineQueryResultCachedMpeg4GifBase.mpeg4_file_id, - title=TestInlineQueryResultCachedMpeg4GifBase.title, - caption=TestInlineQueryResultCachedMpeg4GifBase.caption, - parse_mode=TestInlineQueryResultCachedMpeg4GifBase.parse_mode, - caption_entities=TestInlineQueryResultCachedMpeg4GifBase.caption_entities, - input_message_content=TestInlineQueryResultCachedMpeg4GifBase.input_message_content, - reply_markup=TestInlineQueryResultCachedMpeg4GifBase.reply_markup, + InlineQueryResultCachedMpeg4GifTestBase.id_, + InlineQueryResultCachedMpeg4GifTestBase.mpeg4_file_id, + title=InlineQueryResultCachedMpeg4GifTestBase.title, + caption=InlineQueryResultCachedMpeg4GifTestBase.caption, + parse_mode=InlineQueryResultCachedMpeg4GifTestBase.parse_mode, + caption_entities=InlineQueryResultCachedMpeg4GifTestBase.caption_entities, + input_message_content=InlineQueryResultCachedMpeg4GifTestBase.input_message_content, + reply_markup=InlineQueryResultCachedMpeg4GifTestBase.reply_markup, + show_caption_above_media=InlineQueryResultCachedMpeg4GifTestBase.show_caption_above_media, ) -class TestInlineQueryResultCachedMpeg4GifBase: +class InlineQueryResultCachedMpeg4GifTestBase: id_ = "id" type_ = "mpeg4_gif" mpeg4_file_id = "mpeg4 file id" @@ -53,9 +54,10 @@ class TestInlineQueryResultCachedMpeg4GifBase: caption_entities = [MessageEntity(MessageEntity.ITALIC, 0, 7)] input_message_content = InputTextMessageContent("input_message_content") reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) + show_caption_above_media = True -class TestInlineQueryResultCachedMpeg4GifWithoutRequest(TestInlineQueryResultCachedMpeg4GifBase): +class TestInlineQueryResultCachedMpeg4GifWithoutRequest(InlineQueryResultCachedMpeg4GifTestBase): def test_slot_behaviour(self, inline_query_result_cached_mpeg4_gif): inst = inline_query_result_cached_mpeg4_gif for attr in inst.__slots__: @@ -80,6 +82,10 @@ def test_expected_values(self, inline_query_result_cached_mpeg4_gif): inline_query_result_cached_mpeg4_gif.reply_markup.to_dict() == self.reply_markup.to_dict() ) + assert ( + inline_query_result_cached_mpeg4_gif.show_caption_above_media + == self.show_caption_above_media + ) def test_caption_entities_always_tuple(self): result = InlineQueryResultCachedMpeg4Gif(self.id_, self.mpeg4_file_id) @@ -124,6 +130,10 @@ def test_to_dict(self, inline_query_result_cached_mpeg4_gif): inline_query_result_cached_mpeg4_gif_dict["reply_markup"] == inline_query_result_cached_mpeg4_gif.reply_markup.to_dict() ) + assert ( + inline_query_result_cached_mpeg4_gif_dict["show_caption_above_media"] + == inline_query_result_cached_mpeg4_gif.show_caption_above_media + ) def test_equality(self): a = InlineQueryResultCachedMpeg4Gif(self.id_, self.mpeg4_file_id) diff --git a/tests/_inline/test_inlinequeryresultcachedphoto.py b/tests/_inline/test_inlinequeryresultcachedphoto.py index 0b4e422548d..0433a6b6cc6 100644 --- a/tests/_inline/test_inlinequeryresultcachedphoto.py +++ b/tests/_inline/test_inlinequeryresultcachedphoto.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -32,19 +32,20 @@ @pytest.fixture(scope="module") def inline_query_result_cached_photo(): return InlineQueryResultCachedPhoto( - TestInlineQueryResultCachedPhotoBase.id_, - TestInlineQueryResultCachedPhotoBase.photo_file_id, - title=TestInlineQueryResultCachedPhotoBase.title, - description=TestInlineQueryResultCachedPhotoBase.description, - caption=TestInlineQueryResultCachedPhotoBase.caption, - parse_mode=TestInlineQueryResultCachedPhotoBase.parse_mode, - caption_entities=TestInlineQueryResultCachedPhotoBase.caption_entities, - input_message_content=TestInlineQueryResultCachedPhotoBase.input_message_content, - reply_markup=TestInlineQueryResultCachedPhotoBase.reply_markup, + InlineQueryResultCachedPhotoTestBase.id_, + InlineQueryResultCachedPhotoTestBase.photo_file_id, + title=InlineQueryResultCachedPhotoTestBase.title, + description=InlineQueryResultCachedPhotoTestBase.description, + caption=InlineQueryResultCachedPhotoTestBase.caption, + parse_mode=InlineQueryResultCachedPhotoTestBase.parse_mode, + caption_entities=InlineQueryResultCachedPhotoTestBase.caption_entities, + input_message_content=InlineQueryResultCachedPhotoTestBase.input_message_content, + reply_markup=InlineQueryResultCachedPhotoTestBase.reply_markup, + show_caption_above_media=InlineQueryResultCachedPhotoTestBase.show_caption_above_media, ) -class TestInlineQueryResultCachedPhotoBase: +class InlineQueryResultCachedPhotoTestBase: id_ = "id" type_ = "photo" photo_file_id = "photo file id" @@ -55,9 +56,10 @@ class TestInlineQueryResultCachedPhotoBase: caption_entities = [MessageEntity(MessageEntity.ITALIC, 0, 7)] input_message_content = InputTextMessageContent("input_message_content") reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) + show_caption_above_media = True -class TestInlineQueryResultCachedPhotoWithoutRequest(TestInlineQueryResultCachedPhotoBase): +class TestInlineQueryResultCachedPhotoWithoutRequest(InlineQueryResultCachedPhotoTestBase): def test_slot_behaviour(self, inline_query_result_cached_photo): inst = inline_query_result_cached_photo for attr in inst.__slots__: @@ -80,6 +82,10 @@ def test_expected_values(self, inline_query_result_cached_photo): assert ( inline_query_result_cached_photo.reply_markup.to_dict() == self.reply_markup.to_dict() ) + assert ( + inline_query_result_cached_photo.show_caption_above_media + == self.show_caption_above_media + ) def test_caption_entities_always_tuple(self): result = InlineQueryResultCachedPhoto(self.id_, self.photo_file_id) @@ -124,6 +130,10 @@ def test_to_dict(self, inline_query_result_cached_photo): inline_query_result_cached_photo_dict["reply_markup"] == inline_query_result_cached_photo.reply_markup.to_dict() ) + assert ( + inline_query_result_cached_photo_dict["show_caption_above_media"] + == inline_query_result_cached_photo.show_caption_above_media + ) def test_equality(self): a = InlineQueryResultCachedPhoto(self.id_, self.photo_file_id) diff --git a/tests/_inline/test_inlinequeryresultcachedsticker.py b/tests/_inline/test_inlinequeryresultcachedsticker.py index deded624d06..062db4f4d00 100644 --- a/tests/_inline/test_inlinequeryresultcachedsticker.py +++ b/tests/_inline/test_inlinequeryresultcachedsticker.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -31,14 +31,14 @@ @pytest.fixture(scope="module") def inline_query_result_cached_sticker(): return InlineQueryResultCachedSticker( - TestInlineQueryResultCachedStickerBase.id_, - TestInlineQueryResultCachedStickerBase.sticker_file_id, - input_message_content=TestInlineQueryResultCachedStickerBase.input_message_content, - reply_markup=TestInlineQueryResultCachedStickerBase.reply_markup, + InlineQueryResultCachedStickerTestBase.id_, + InlineQueryResultCachedStickerTestBase.sticker_file_id, + input_message_content=InlineQueryResultCachedStickerTestBase.input_message_content, + reply_markup=InlineQueryResultCachedStickerTestBase.reply_markup, ) -class TestInlineQueryResultCachedStickerBase: +class InlineQueryResultCachedStickerTestBase: id_ = "id" type_ = "sticker" sticker_file_id = "sticker file id" @@ -46,7 +46,7 @@ class TestInlineQueryResultCachedStickerBase: reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) -class TestInlineQueryResultCachedStickerWithoutRequest(TestInlineQueryResultCachedStickerBase): +class TestInlineQueryResultCachedStickerWithoutRequest(InlineQueryResultCachedStickerTestBase): def test_slot_behaviour(self, inline_query_result_cached_sticker): inst = inline_query_result_cached_sticker for attr in inst.__slots__: diff --git a/tests/_inline/test_inlinequeryresultcachedvideo.py b/tests/_inline/test_inlinequeryresultcachedvideo.py index 5609032f9c0..3b1b7dfbef3 100644 --- a/tests/_inline/test_inlinequeryresultcachedvideo.py +++ b/tests/_inline/test_inlinequeryresultcachedvideo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -32,19 +32,20 @@ @pytest.fixture(scope="module") def inline_query_result_cached_video(): return InlineQueryResultCachedVideo( - TestInlineQueryResultCachedVideoBase.id_, - TestInlineQueryResultCachedVideoBase.video_file_id, - TestInlineQueryResultCachedVideoBase.title, - caption=TestInlineQueryResultCachedVideoBase.caption, - parse_mode=TestInlineQueryResultCachedVideoBase.parse_mode, - caption_entities=TestInlineQueryResultCachedVideoBase.caption_entities, - description=TestInlineQueryResultCachedVideoBase.description, - input_message_content=TestInlineQueryResultCachedVideoBase.input_message_content, - reply_markup=TestInlineQueryResultCachedVideoBase.reply_markup, + InlineQueryResultCachedVideoTestBase.id_, + InlineQueryResultCachedVideoTestBase.video_file_id, + InlineQueryResultCachedVideoTestBase.title, + caption=InlineQueryResultCachedVideoTestBase.caption, + parse_mode=InlineQueryResultCachedVideoTestBase.parse_mode, + caption_entities=InlineQueryResultCachedVideoTestBase.caption_entities, + description=InlineQueryResultCachedVideoTestBase.description, + input_message_content=InlineQueryResultCachedVideoTestBase.input_message_content, + reply_markup=InlineQueryResultCachedVideoTestBase.reply_markup, + show_caption_above_media=InlineQueryResultCachedVideoTestBase.show_caption_above_media, ) -class TestInlineQueryResultCachedVideoBase: +class InlineQueryResultCachedVideoTestBase: id_ = "id" type_ = "video" video_file_id = "video file id" @@ -55,9 +56,10 @@ class TestInlineQueryResultCachedVideoBase: description = "description" input_message_content = InputTextMessageContent("input_message_content") reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) + show_caption_above_media = True -class TestInlineQueryResultCachedVideoWithoutRequest(TestInlineQueryResultCachedVideoBase): +class TestInlineQueryResultCachedVideoWithoutRequest(InlineQueryResultCachedVideoTestBase): def test_slot_behaviour(self, inline_query_result_cached_video): inst = inline_query_result_cached_video for attr in inst.__slots__: @@ -80,6 +82,10 @@ def test_expected_values(self, inline_query_result_cached_video): assert ( inline_query_result_cached_video.reply_markup.to_dict() == self.reply_markup.to_dict() ) + assert ( + inline_query_result_cached_video.show_caption_above_media + == self.show_caption_above_media + ) def test_caption_entities_always_tuple(self): video = InlineQueryResultCachedVideo(self.id_, self.video_file_id, self.title) @@ -125,6 +131,10 @@ def test_to_dict(self, inline_query_result_cached_video): inline_query_result_cached_video_dict["reply_markup"] == inline_query_result_cached_video.reply_markup.to_dict() ) + assert ( + inline_query_result_cached_video_dict["show_caption_above_media"] + == inline_query_result_cached_video.show_caption_above_media + ) def test_equality(self): a = InlineQueryResultCachedVideo(self.id_, self.video_file_id, self.title) diff --git a/tests/_inline/test_inlinequeryresultcachedvoice.py b/tests/_inline/test_inlinequeryresultcachedvoice.py index 79746a7525b..4fe608aa047 100644 --- a/tests/_inline/test_inlinequeryresultcachedvoice.py +++ b/tests/_inline/test_inlinequeryresultcachedvoice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -32,18 +32,18 @@ @pytest.fixture(scope="module") def inline_query_result_cached_voice(): return InlineQueryResultCachedVoice( - TestInlineQueryResultCachedVoiceBase.id_, - TestInlineQueryResultCachedVoiceBase.voice_file_id, - TestInlineQueryResultCachedVoiceBase.title, - caption=TestInlineQueryResultCachedVoiceBase.caption, - parse_mode=TestInlineQueryResultCachedVoiceBase.parse_mode, - caption_entities=TestInlineQueryResultCachedVoiceBase.caption_entities, - input_message_content=TestInlineQueryResultCachedVoiceBase.input_message_content, - reply_markup=TestInlineQueryResultCachedVoiceBase.reply_markup, + InlineQueryResultCachedVoiceTestBase.id_, + InlineQueryResultCachedVoiceTestBase.voice_file_id, + InlineQueryResultCachedVoiceTestBase.title, + caption=InlineQueryResultCachedVoiceTestBase.caption, + parse_mode=InlineQueryResultCachedVoiceTestBase.parse_mode, + caption_entities=InlineQueryResultCachedVoiceTestBase.caption_entities, + input_message_content=InlineQueryResultCachedVoiceTestBase.input_message_content, + reply_markup=InlineQueryResultCachedVoiceTestBase.reply_markup, ) -class TestInlineQueryResultCachedVoiceBase: +class InlineQueryResultCachedVoiceTestBase: id_ = "id" type_ = "voice" voice_file_id = "voice file id" @@ -55,7 +55,7 @@ class TestInlineQueryResultCachedVoiceBase: reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) -class TestInlineQueryResultCachedVoiceWithoutRequest(TestInlineQueryResultCachedVoiceBase): +class TestInlineQueryResultCachedVoiceWithoutRequest(InlineQueryResultCachedVoiceTestBase): def test_slot_behaviour(self, inline_query_result_cached_voice): inst = inline_query_result_cached_voice for attr in inst.__slots__: diff --git a/tests/_inline/test_inlinequeryresultcontact.py b/tests/_inline/test_inlinequeryresultcontact.py index 9720cabd00d..3fee14f7a17 100644 --- a/tests/_inline/test_inlinequeryresultcontact.py +++ b/tests/_inline/test_inlinequeryresultcontact.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,26 +25,25 @@ InlineQueryResultVoice, InputTextMessageContent, ) -from tests.auxil.deprecations import check_thumb_deprecation_warnings_for_args_and_attrs from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def inline_query_result_contact(): return InlineQueryResultContact( - TestInlineQueryResultContactBase.id_, - TestInlineQueryResultContactBase.phone_number, - TestInlineQueryResultContactBase.first_name, - last_name=TestInlineQueryResultContactBase.last_name, - thumbnail_url=TestInlineQueryResultContactBase.thumbnail_url, - thumbnail_width=TestInlineQueryResultContactBase.thumbnail_width, - thumbnail_height=TestInlineQueryResultContactBase.thumbnail_height, - input_message_content=TestInlineQueryResultContactBase.input_message_content, - reply_markup=TestInlineQueryResultContactBase.reply_markup, + InlineQueryResultContactTestBase.id_, + InlineQueryResultContactTestBase.phone_number, + InlineQueryResultContactTestBase.first_name, + last_name=InlineQueryResultContactTestBase.last_name, + thumbnail_url=InlineQueryResultContactTestBase.thumbnail_url, + thumbnail_width=InlineQueryResultContactTestBase.thumbnail_width, + thumbnail_height=InlineQueryResultContactTestBase.thumbnail_height, + input_message_content=InlineQueryResultContactTestBase.input_message_content, + reply_markup=InlineQueryResultContactTestBase.reply_markup, ) -class TestInlineQueryResultContactBase: +class InlineQueryResultContactTestBase: id_ = "id" type_ = "contact" phone_number = "phone_number" @@ -57,7 +56,7 @@ class TestInlineQueryResultContactBase: reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) -class TestInlineQueryResultContactWithoutRequest(TestInlineQueryResultContactBase): +class TestInlineQueryResultContactWithoutRequest(InlineQueryResultContactTestBase): def test_slot_behaviour(self, inline_query_result_contact): inst = inline_query_result_contact for attr in inst.__slots__: @@ -79,116 +78,6 @@ def test_expected_values(self, inline_query_result_contact): ) assert inline_query_result_contact.reply_markup.to_dict() == self.reply_markup.to_dict() - def test_thumb_url_property_deprecation_warning(self, recwarn): - inline_query_result_contact = InlineQueryResultContact( - TestInlineQueryResultContactBase.id_, - TestInlineQueryResultContactBase.phone_number, - TestInlineQueryResultContactBase.first_name, - last_name=TestInlineQueryResultContactBase.last_name, - input_message_content=TestInlineQueryResultContactBase.input_message_content, - reply_markup=TestInlineQueryResultContactBase.reply_markup, - thumb_url=TestInlineQueryResultContactBase.thumbnail_url, # deprecated arg - thumbnail_height=TestInlineQueryResultContactBase.thumbnail_height, - thumbnail_width=TestInlineQueryResultContactBase.thumbnail_width, - ) - assert inline_query_result_contact.thumb_url == inline_query_result_contact.thumbnail_url - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_url", new_name="thumbnail_url" - ) - - def test_thumb_height_property_deprecation_warning(self, recwarn): - inline_query_result_contact = InlineQueryResultContact( - TestInlineQueryResultContactBase.id_, - TestInlineQueryResultContactBase.phone_number, - TestInlineQueryResultContactBase.first_name, - last_name=TestInlineQueryResultContactBase.last_name, - input_message_content=TestInlineQueryResultContactBase.input_message_content, - reply_markup=TestInlineQueryResultContactBase.reply_markup, - thumbnail_url=TestInlineQueryResultContactBase.thumbnail_url, - thumb_height=TestInlineQueryResultContactBase.thumbnail_height, # deprecated arg - thumbnail_width=TestInlineQueryResultContactBase.thumbnail_width, - ) - assert ( - inline_query_result_contact.thumb_height - == inline_query_result_contact.thumbnail_height - ) - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_height", new_name="thumbnail_height" - ) - - def test_thumb_width_property_deprecation_warning(self, recwarn): - inline_query_result_contact = InlineQueryResultContact( - TestInlineQueryResultContactBase.id_, - TestInlineQueryResultContactBase.phone_number, - TestInlineQueryResultContactBase.first_name, - last_name=TestInlineQueryResultContactBase.last_name, - input_message_content=TestInlineQueryResultContactBase.input_message_content, - reply_markup=TestInlineQueryResultContactBase.reply_markup, - thumbnail_url=TestInlineQueryResultContactBase.thumbnail_url, - thumbnail_height=TestInlineQueryResultContactBase.thumbnail_height, - thumb_width=TestInlineQueryResultContactBase.thumbnail_width, # deprecated arg - ) - assert ( - inline_query_result_contact.thumb_width == inline_query_result_contact.thumbnail_width - ) - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_width", new_name="thumbnail_width" - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_url(self): - with pytest.raises( - ValueError, - match="different entities as 'thumb_url' and 'thumbnail_url'", - ): - InlineQueryResultContact( - TestInlineQueryResultContactBase.id_, - TestInlineQueryResultContactBase.phone_number, - TestInlineQueryResultContactBase.first_name, - last_name=TestInlineQueryResultContactBase.last_name, - input_message_content=TestInlineQueryResultContactBase.input_message_content, - reply_markup=TestInlineQueryResultContactBase.reply_markup, - thumbnail_url=TestInlineQueryResultContactBase.thumbnail_url, - thumb_url="some other url", - thumbnail_height=TestInlineQueryResultContactBase.thumbnail_height, - thumbnail_width=TestInlineQueryResultContactBase.thumbnail_width, - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_height(self): - with pytest.raises( - ValueError, - match="different entities as 'thumb_height' and 'thumbnail_height'", - ): - InlineQueryResultContact( - TestInlineQueryResultContactBase.id_, - TestInlineQueryResultContactBase.phone_number, - TestInlineQueryResultContactBase.first_name, - last_name=TestInlineQueryResultContactBase.last_name, - input_message_content=TestInlineQueryResultContactBase.input_message_content, - reply_markup=TestInlineQueryResultContactBase.reply_markup, - thumbnail_url=TestInlineQueryResultContactBase.thumbnail_url, - thumbnail_height=TestInlineQueryResultContactBase.thumbnail_height, - thumb_height=TestInlineQueryResultContactBase.thumbnail_height + 1, - thumbnail_width=TestInlineQueryResultContactBase.thumbnail_width, - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_width(self): - with pytest.raises( - ValueError, - match="different entities as 'thumb_width' and 'thumbnail_width'", - ): - InlineQueryResultContact( - TestInlineQueryResultContactBase.id_, - TestInlineQueryResultContactBase.phone_number, - TestInlineQueryResultContactBase.first_name, - last_name=TestInlineQueryResultContactBase.last_name, - input_message_content=TestInlineQueryResultContactBase.input_message_content, - reply_markup=TestInlineQueryResultContactBase.reply_markup, - thumbnail_url=TestInlineQueryResultContactBase.thumbnail_url, - thumbnail_height=TestInlineQueryResultContactBase.thumbnail_height, - thumbnail_width=TestInlineQueryResultContactBase.thumbnail_width, - thumb_width=TestInlineQueryResultContactBase.thumbnail_width + 1, - ) - def test_to_dict(self, inline_query_result_contact): inline_query_result_contact_dict = inline_query_result_contact.to_dict() diff --git a/tests/_inline/test_inlinequeryresultdocument.py b/tests/_inline/test_inlinequeryresultdocument.py index dc727ed3932..326e330b36d 100644 --- a/tests/_inline/test_inlinequeryresultdocument.py +++ b/tests/_inline/test_inlinequeryresultdocument.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -26,30 +26,29 @@ InputTextMessageContent, MessageEntity, ) -from tests.auxil.deprecations import check_thumb_deprecation_warnings_for_args_and_attrs from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def inline_query_result_document(): return InlineQueryResultDocument( - TestInlineQueryResultDocumentBase.id_, - TestInlineQueryResultDocumentBase.document_url, - TestInlineQueryResultDocumentBase.title, - TestInlineQueryResultDocumentBase.mime_type, - caption=TestInlineQueryResultDocumentBase.caption, - parse_mode=TestInlineQueryResultDocumentBase.parse_mode, - caption_entities=TestInlineQueryResultDocumentBase.caption_entities, - description=TestInlineQueryResultDocumentBase.description, - thumbnail_url=TestInlineQueryResultDocumentBase.thumbnail_url, - thumbnail_width=TestInlineQueryResultDocumentBase.thumbnail_width, - thumbnail_height=TestInlineQueryResultDocumentBase.thumbnail_height, - input_message_content=TestInlineQueryResultDocumentBase.input_message_content, - reply_markup=TestInlineQueryResultDocumentBase.reply_markup, + InlineQueryResultDocumentTestBase.id_, + InlineQueryResultDocumentTestBase.document_url, + InlineQueryResultDocumentTestBase.title, + InlineQueryResultDocumentTestBase.mime_type, + caption=InlineQueryResultDocumentTestBase.caption, + parse_mode=InlineQueryResultDocumentTestBase.parse_mode, + caption_entities=InlineQueryResultDocumentTestBase.caption_entities, + description=InlineQueryResultDocumentTestBase.description, + thumbnail_url=InlineQueryResultDocumentTestBase.thumbnail_url, + thumbnail_width=InlineQueryResultDocumentTestBase.thumbnail_width, + thumbnail_height=InlineQueryResultDocumentTestBase.thumbnail_height, + input_message_content=InlineQueryResultDocumentTestBase.input_message_content, + reply_markup=InlineQueryResultDocumentTestBase.reply_markup, ) -class TestInlineQueryResultDocumentBase: +class InlineQueryResultDocumentTestBase: id_ = "id" type_ = "document" document_url = "document url" @@ -66,7 +65,7 @@ class TestInlineQueryResultDocumentBase: reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) -class TestInlineQueryResultDocumentWithoutRequest(TestInlineQueryResultDocumentBase): +class TestInlineQueryResultDocumentWithoutRequest(InlineQueryResultDocumentTestBase): def test_slot_behaviour(self, inline_query_result_document): inst = inline_query_result_document for attr in inst.__slots__: @@ -92,141 +91,6 @@ def test_expected_values(self, inline_query_result_document): ) assert inline_query_result_document.reply_markup.to_dict() == self.reply_markup.to_dict() - def test_thumb_url_property_deprecation_warning(self, recwarn): - inline_query_result_document = InlineQueryResultDocument( - TestInlineQueryResultDocumentBase.id_, - TestInlineQueryResultDocumentBase.document_url, - TestInlineQueryResultDocumentBase.title, - TestInlineQueryResultDocumentBase.mime_type, - caption=TestInlineQueryResultDocumentBase.caption, - parse_mode=TestInlineQueryResultDocumentBase.parse_mode, - caption_entities=TestInlineQueryResultDocumentBase.caption_entities, - description=TestInlineQueryResultDocumentBase.description, - input_message_content=TestInlineQueryResultDocumentBase.input_message_content, - reply_markup=TestInlineQueryResultDocumentBase.reply_markup, - thumb_url=TestInlineQueryResultDocumentBase.thumbnail_url, # deprecated arg - thumbnail_height=TestInlineQueryResultDocumentBase.thumbnail_height, - thumbnail_width=TestInlineQueryResultDocumentBase.thumbnail_width, - ) - assert inline_query_result_document.thumb_url == inline_query_result_document.thumbnail_url - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_url", new_name="thumbnail_url" - ) - - def test_thumb_height_property_deprecation_warning(self, recwarn): - inline_query_result_document = InlineQueryResultDocument( - TestInlineQueryResultDocumentBase.id_, - TestInlineQueryResultDocumentBase.document_url, - TestInlineQueryResultDocumentBase.title, - TestInlineQueryResultDocumentBase.mime_type, - caption=TestInlineQueryResultDocumentBase.caption, - parse_mode=TestInlineQueryResultDocumentBase.parse_mode, - caption_entities=TestInlineQueryResultDocumentBase.caption_entities, - description=TestInlineQueryResultDocumentBase.description, - input_message_content=TestInlineQueryResultDocumentBase.input_message_content, - reply_markup=TestInlineQueryResultDocumentBase.reply_markup, - thumbnail_url=TestInlineQueryResultDocumentBase.thumbnail_url, - thumb_height=TestInlineQueryResultDocumentBase.thumbnail_height, # deprecated arg - thumbnail_width=TestInlineQueryResultDocumentBase.thumbnail_width, - ) - assert ( - inline_query_result_document.thumb_height - == inline_query_result_document.thumbnail_height - ) - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_height", new_name="thumbnail_height" - ) - - def test_thumb_width_property_deprecation_warning(self, recwarn): - inline_query_result_document = InlineQueryResultDocument( - TestInlineQueryResultDocumentBase.id_, - TestInlineQueryResultDocumentBase.document_url, - TestInlineQueryResultDocumentBase.title, - TestInlineQueryResultDocumentBase.mime_type, - caption=TestInlineQueryResultDocumentBase.caption, - parse_mode=TestInlineQueryResultDocumentBase.parse_mode, - caption_entities=TestInlineQueryResultDocumentBase.caption_entities, - description=TestInlineQueryResultDocumentBase.description, - input_message_content=TestInlineQueryResultDocumentBase.input_message_content, - reply_markup=TestInlineQueryResultDocumentBase.reply_markup, - thumbnail_url=TestInlineQueryResultDocumentBase.thumbnail_url, - thumbnail_height=TestInlineQueryResultDocumentBase.thumbnail_height, - thumb_width=TestInlineQueryResultDocumentBase.thumbnail_width, # deprecated arg - ) - assert ( - inline_query_result_document.thumb_width - == inline_query_result_document.thumbnail_width - ) - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_width", new_name="thumbnail_width" - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_url(self): - with pytest.raises( - ValueError, - match="different entities as 'thumb_url' and 'thumbnail_url'", - ): - InlineQueryResultDocument( - TestInlineQueryResultDocumentBase.id_, - TestInlineQueryResultDocumentBase.document_url, - TestInlineQueryResultDocumentBase.title, - TestInlineQueryResultDocumentBase.mime_type, - caption=TestInlineQueryResultDocumentBase.caption, - parse_mode=TestInlineQueryResultDocumentBase.parse_mode, - caption_entities=TestInlineQueryResultDocumentBase.caption_entities, - description=TestInlineQueryResultDocumentBase.description, - input_message_content=TestInlineQueryResultDocumentBase.input_message_content, - reply_markup=TestInlineQueryResultDocumentBase.reply_markup, - thumbnail_url=TestInlineQueryResultDocumentBase.thumbnail_url, - thumb_url="some other url", - thumbnail_height=TestInlineQueryResultDocumentBase.thumbnail_height, - thumbnail_width=TestInlineQueryResultDocumentBase.thumbnail_width, - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_height(self): - with pytest.raises( - ValueError, - match="different entities as 'thumb_height' and 'thumbnail_height'", - ): - InlineQueryResultDocument( - TestInlineQueryResultDocumentBase.id_, - TestInlineQueryResultDocumentBase.document_url, - TestInlineQueryResultDocumentBase.title, - TestInlineQueryResultDocumentBase.mime_type, - caption=TestInlineQueryResultDocumentBase.caption, - parse_mode=TestInlineQueryResultDocumentBase.parse_mode, - caption_entities=TestInlineQueryResultDocumentBase.caption_entities, - description=TestInlineQueryResultDocumentBase.description, - input_message_content=TestInlineQueryResultDocumentBase.input_message_content, - reply_markup=TestInlineQueryResultDocumentBase.reply_markup, - thumbnail_url=TestInlineQueryResultDocumentBase.thumbnail_url, - thumbnail_height=TestInlineQueryResultDocumentBase.thumbnail_height, - thumb_height=TestInlineQueryResultDocumentBase.thumbnail_height + 1, - thumbnail_width=TestInlineQueryResultDocumentBase.thumbnail_width, - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_width(self): - with pytest.raises( - ValueError, - match="different entities as 'thumb_width' and 'thumbnail_width'", - ): - InlineQueryResultDocument( - TestInlineQueryResultDocumentBase.id_, - TestInlineQueryResultDocumentBase.document_url, - TestInlineQueryResultDocumentBase.title, - TestInlineQueryResultDocumentBase.mime_type, - caption=TestInlineQueryResultDocumentBase.caption, - parse_mode=TestInlineQueryResultDocumentBase.parse_mode, - caption_entities=TestInlineQueryResultDocumentBase.caption_entities, - description=TestInlineQueryResultDocumentBase.description, - input_message_content=TestInlineQueryResultDocumentBase.input_message_content, - reply_markup=TestInlineQueryResultDocumentBase.reply_markup, - thumbnail_url=TestInlineQueryResultDocumentBase.thumbnail_url, - thumbnail_height=TestInlineQueryResultDocumentBase.thumbnail_height, - thumbnail_width=TestInlineQueryResultDocumentBase.thumbnail_width, - thumb_width=TestInlineQueryResultDocumentBase.thumbnail_width + 1, - ) - def test_caption_entities_always_tuple(self): result = InlineQueryResultDocument(self.id_, self.document_url, self.title, self.mime_type) assert result.caption_entities == () diff --git a/tests/_inline/test_inlinequeryresultgame.py b/tests/_inline/test_inlinequeryresultgame.py index 0e5ca9269a9..15c783a3fed 100644 --- a/tests/_inline/test_inlinequeryresultgame.py +++ b/tests/_inline/test_inlinequeryresultgame.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -30,20 +30,20 @@ @pytest.fixture(scope="module") def inline_query_result_game(): return InlineQueryResultGame( - TestInlineQueryResultGameBase.id_, - TestInlineQueryResultGameBase.game_short_name, - reply_markup=TestInlineQueryResultGameBase.reply_markup, + InlineQueryResultGameTestBase.id_, + InlineQueryResultGameTestBase.game_short_name, + reply_markup=InlineQueryResultGameTestBase.reply_markup, ) -class TestInlineQueryResultGameBase: +class InlineQueryResultGameTestBase: id_ = "id" type_ = "game" game_short_name = "game short name" reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) -class TestInlineQueryResultGameWithoutRequest(TestInlineQueryResultGameBase): +class TestInlineQueryResultGameWithoutRequest(InlineQueryResultGameTestBase): def test_slot_behaviour(self, inline_query_result_game): inst = inline_query_result_game for attr in inst.__slots__: diff --git a/tests/_inline/test_inlinequeryresultgif.py b/tests/_inline/test_inlinequeryresultgif.py index 7d3545af705..9b25286eb0f 100644 --- a/tests/_inline/test_inlinequeryresultgif.py +++ b/tests/_inline/test_inlinequeryresultgif.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,6 +16,8 @@ # # 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 as dtm + import pytest from telegram import ( @@ -26,36 +28,37 @@ InputTextMessageContent, MessageEntity, ) -from tests.auxil.deprecations import check_thumb_deprecation_warnings_for_args_and_attrs +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def inline_query_result_gif(): return InlineQueryResultGif( - TestInlineQueryResultGifBase.id_, - TestInlineQueryResultGifBase.gif_url, - TestInlineQueryResultGifBase.thumbnail_url, - gif_width=TestInlineQueryResultGifBase.gif_width, - gif_height=TestInlineQueryResultGifBase.gif_height, - gif_duration=TestInlineQueryResultGifBase.gif_duration, - title=TestInlineQueryResultGifBase.title, - caption=TestInlineQueryResultGifBase.caption, - parse_mode=TestInlineQueryResultGifBase.parse_mode, - caption_entities=TestInlineQueryResultGifBase.caption_entities, - input_message_content=TestInlineQueryResultGifBase.input_message_content, - reply_markup=TestInlineQueryResultGifBase.reply_markup, - thumbnail_mime_type=TestInlineQueryResultGifBase.thumbnail_mime_type, + InlineQueryResultGifTestBase.id_, + InlineQueryResultGifTestBase.gif_url, + InlineQueryResultGifTestBase.thumbnail_url, + gif_width=InlineQueryResultGifTestBase.gif_width, + gif_height=InlineQueryResultGifTestBase.gif_height, + gif_duration=InlineQueryResultGifTestBase.gif_duration, + title=InlineQueryResultGifTestBase.title, + caption=InlineQueryResultGifTestBase.caption, + parse_mode=InlineQueryResultGifTestBase.parse_mode, + caption_entities=InlineQueryResultGifTestBase.caption_entities, + input_message_content=InlineQueryResultGifTestBase.input_message_content, + reply_markup=InlineQueryResultGifTestBase.reply_markup, + thumbnail_mime_type=InlineQueryResultGifTestBase.thumbnail_mime_type, + show_caption_above_media=InlineQueryResultGifTestBase.show_caption_above_media, ) -class TestInlineQueryResultGifBase: +class InlineQueryResultGifTestBase: id_ = "id" type_ = "gif" gif_url = "gif url" gif_width = 10 gif_height = 15 - gif_duration = 1 + gif_duration = dtm.timedelta(seconds=1) thumbnail_url = "thumb url" thumbnail_mime_type = "image/jpeg" title = "title" @@ -64,9 +67,10 @@ class TestInlineQueryResultGifBase: caption_entities = [MessageEntity(MessageEntity.ITALIC, 0, 7)] input_message_content = InputTextMessageContent("input_message_content") reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) + show_caption_above_media = True -class TestInlineQueryResultGifWithoutRequest(TestInlineQueryResultGifBase): +class TestInlineQueryResultGifWithoutRequest(InlineQueryResultGifTestBase): def test_slot_behaviour(self, inline_query_result_gif): inst = inline_query_result_gif for attr in inst.__slots__: @@ -83,7 +87,7 @@ def test_expected_values(self, inline_query_result_gif): assert inline_query_result_gif.gif_url == self.gif_url assert inline_query_result_gif.gif_width == self.gif_width assert inline_query_result_gif.gif_height == self.gif_height - assert inline_query_result_gif.gif_duration == self.gif_duration + assert inline_query_result_gif._gif_duration == self.gif_duration assert inline_query_result_gif.thumbnail_url == self.thumbnail_url assert inline_query_result_gif.thumbnail_mime_type == self.thumbnail_mime_type assert inline_query_result_gif.title == self.title @@ -95,141 +99,7 @@ def test_expected_values(self, inline_query_result_gif): == self.input_message_content.to_dict() ) assert inline_query_result_gif.reply_markup.to_dict() == self.reply_markup.to_dict() - - def test_thumb_url_property_deprecation_warning(self, recwarn): - inline_query_result_gif = InlineQueryResultGif( - TestInlineQueryResultGifBase.id_, - TestInlineQueryResultGifBase.gif_url, - TestInlineQueryResultGifBase.thumbnail_url, - gif_width=TestInlineQueryResultGifBase.gif_width, - gif_height=TestInlineQueryResultGifBase.gif_height, - gif_duration=TestInlineQueryResultGifBase.gif_duration, - title=TestInlineQueryResultGifBase.title, - caption=TestInlineQueryResultGifBase.caption, - parse_mode=TestInlineQueryResultGifBase.parse_mode, - caption_entities=TestInlineQueryResultGifBase.caption_entities, - input_message_content=TestInlineQueryResultGifBase.input_message_content, - reply_markup=TestInlineQueryResultGifBase.reply_markup, - thumbnail_mime_type=TestInlineQueryResultGifBase.thumbnail_mime_type, - thumb_url=TestInlineQueryResultGifBase.thumbnail_url, # deprecated arg - ) - assert inline_query_result_gif.thumb_url == inline_query_result_gif.thumbnail_url - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, - __file__, - deprecated_name="thumb_url", - new_name="thumbnail_url", - ) - - def test_thumb_mime_type_property_deprecation_warning(self, recwarn): - inline_query_result_gif = InlineQueryResultGif( - TestInlineQueryResultGifBase.id_, - TestInlineQueryResultGifBase.gif_url, - TestInlineQueryResultGifBase.thumbnail_url, - gif_width=TestInlineQueryResultGifBase.gif_width, - gif_height=TestInlineQueryResultGifBase.gif_height, - gif_duration=TestInlineQueryResultGifBase.gif_duration, - title=TestInlineQueryResultGifBase.title, - caption=TestInlineQueryResultGifBase.caption, - parse_mode=TestInlineQueryResultGifBase.parse_mode, - caption_entities=TestInlineQueryResultGifBase.caption_entities, - input_message_content=TestInlineQueryResultGifBase.input_message_content, - reply_markup=TestInlineQueryResultGifBase.reply_markup, - thumb_mime_type=TestInlineQueryResultGifBase.thumbnail_mime_type, # deprecated arg - ) - assert ( - inline_query_result_gif.thumb_mime_type == inline_query_result_gif.thumbnail_mime_type - ) - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_mime_type", new_name="thumbnail_mime_type" - ) - - def test_thumb_url_issues_warning_and_works_without_positional_arg(self, recwarn): - inline_query_result_gif = InlineQueryResultGif( - TestInlineQueryResultGifBase.id_, - TestInlineQueryResultGifBase.gif_url, - # positional argument thumbnail_url should be here, but it's not - gif_width=TestInlineQueryResultGifBase.gif_width, - gif_height=TestInlineQueryResultGifBase.gif_height, - gif_duration=TestInlineQueryResultGifBase.gif_duration, - title=TestInlineQueryResultGifBase.title, - caption=TestInlineQueryResultGifBase.caption, - parse_mode=TestInlineQueryResultGifBase.parse_mode, - caption_entities=TestInlineQueryResultGifBase.caption_entities, - input_message_content=TestInlineQueryResultGifBase.input_message_content, - reply_markup=TestInlineQueryResultGifBase.reply_markup, - thumbnail_mime_type=TestInlineQueryResultGifBase.thumbnail_mime_type, - thumb_url=TestInlineQueryResultGifBase.thumbnail_url, # deprecated arg - ) - - assert inline_query_result_gif.thumb_url == inline_query_result_gif.thumbnail_url - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, - __file__, - deprecated_name="thumb_url", - new_name="thumbnail_url", - ) - - def test_init_throws_error_without_thumbnail_url_and_thumb_url(self): - with pytest.raises(ValueError, match="You must pass either"): - InlineQueryResultGif( - TestInlineQueryResultGifBase.id_, - TestInlineQueryResultGifBase.gif_url, - # no thumbnail_url or thumb_url - gif_width=TestInlineQueryResultGifBase.gif_width, - gif_height=TestInlineQueryResultGifBase.gif_height, - gif_duration=TestInlineQueryResultGifBase.gif_duration, - title=TestInlineQueryResultGifBase.title, - caption=TestInlineQueryResultGifBase.caption, - parse_mode=TestInlineQueryResultGifBase.parse_mode, - caption_entities=TestInlineQueryResultGifBase.caption_entities, - input_message_content=TestInlineQueryResultGifBase.input_message_content, - reply_markup=TestInlineQueryResultGifBase.reply_markup, - thumbnail_mime_type=TestInlineQueryResultGifBase.thumbnail_mime_type, - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_url(self): - with pytest.raises( - ValueError, match="different entities as 'thumb_url' and 'thumbnail_url'" - ): - InlineQueryResultGif( - TestInlineQueryResultGifBase.id_, - TestInlineQueryResultGifBase.gif_url, - TestInlineQueryResultGifBase.thumbnail_url, - gif_width=TestInlineQueryResultGifBase.gif_width, - gif_height=TestInlineQueryResultGifBase.gif_height, - gif_duration=TestInlineQueryResultGifBase.gif_duration, - title=TestInlineQueryResultGifBase.title, - caption=TestInlineQueryResultGifBase.caption, - parse_mode=TestInlineQueryResultGifBase.parse_mode, - caption_entities=TestInlineQueryResultGifBase.caption_entities, - input_message_content=TestInlineQueryResultGifBase.input_message_content, - reply_markup=TestInlineQueryResultGifBase.reply_markup, - thumbnail_mime_type=TestInlineQueryResultGifBase.thumbnail_mime_type, - thumb_url="some other url", - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_mime_type(self): - with pytest.raises( - ValueError, - match="different entities as 'thumb_mime_type' and 'thumbnail_mime_type'", - ): - InlineQueryResultGif( - TestInlineQueryResultGifBase.id_, - TestInlineQueryResultGifBase.gif_url, - TestInlineQueryResultGifBase.thumbnail_url, - gif_width=TestInlineQueryResultGifBase.gif_width, - gif_height=TestInlineQueryResultGifBase.gif_height, - gif_duration=TestInlineQueryResultGifBase.gif_duration, - title=TestInlineQueryResultGifBase.title, - caption=TestInlineQueryResultGifBase.caption, - parse_mode=TestInlineQueryResultGifBase.parse_mode, - caption_entities=TestInlineQueryResultGifBase.caption_entities, - input_message_content=TestInlineQueryResultGifBase.input_message_content, - reply_markup=TestInlineQueryResultGifBase.reply_markup, - thumbnail_mime_type=TestInlineQueryResultGifBase.thumbnail_mime_type, - thumb_mime_type="video/mp4", - ) + assert inline_query_result_gif.show_caption_above_media == self.show_caption_above_media def test_to_dict(self, inline_query_result_gif): inline_query_result_gif_dict = inline_query_result_gif.to_dict() @@ -240,7 +110,10 @@ def test_to_dict(self, inline_query_result_gif): assert inline_query_result_gif_dict["gif_url"] == inline_query_result_gif.gif_url assert inline_query_result_gif_dict["gif_width"] == inline_query_result_gif.gif_width assert inline_query_result_gif_dict["gif_height"] == inline_query_result_gif.gif_height - assert inline_query_result_gif_dict["gif_duration"] == inline_query_result_gif.gif_duration + assert inline_query_result_gif_dict["gif_duration"] == int( + self.gif_duration.total_seconds() + ) + assert isinstance(inline_query_result_gif_dict["gif_duration"], int) assert ( inline_query_result_gif_dict["thumbnail_url"] == inline_query_result_gif.thumbnail_url ) @@ -262,6 +135,30 @@ def test_to_dict(self, inline_query_result_gif): inline_query_result_gif_dict["reply_markup"] == inline_query_result_gif.reply_markup.to_dict() ) + assert ( + inline_query_result_gif_dict["show_caption_above_media"] + == inline_query_result_gif.show_caption_above_media + ) + + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_gif): + gif_duration = inline_query_result_gif.gif_duration + + if PTB_TIMEDELTA: + assert gif_duration == self.gif_duration + assert isinstance(gif_duration, dtm.timedelta) + else: + assert gif_duration == int(self.gif_duration.total_seconds()) + assert isinstance(gif_duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, inline_query_result_gif): + inline_query_result_gif.gif_duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`gif_duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): a = InlineQueryResultGif(self.id_, self.gif_url, self.thumbnail_url) diff --git a/tests/_inline/test_inlinequeryresultlocation.py b/tests/_inline/test_inlinequeryresultlocation.py index 7a94f3ef5c2..183e1818268 100644 --- a/tests/_inline/test_inlinequeryresultlocation.py +++ b/tests/_inline/test_inlinequeryresultlocation.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,6 +16,8 @@ # # 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 as dtm + import pytest from telegram import ( @@ -25,37 +27,37 @@ InlineQueryResultVoice, InputTextMessageContent, ) -from tests.auxil.deprecations import check_thumb_deprecation_warnings_for_args_and_attrs +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def inline_query_result_location(): return InlineQueryResultLocation( - TestInlineQueryResultLocationBase.id_, - TestInlineQueryResultLocationBase.latitude, - TestInlineQueryResultLocationBase.longitude, - TestInlineQueryResultLocationBase.title, - live_period=TestInlineQueryResultLocationBase.live_period, - thumbnail_url=TestInlineQueryResultLocationBase.thumbnail_url, - thumbnail_width=TestInlineQueryResultLocationBase.thumbnail_width, - thumbnail_height=TestInlineQueryResultLocationBase.thumbnail_height, - input_message_content=TestInlineQueryResultLocationBase.input_message_content, - reply_markup=TestInlineQueryResultLocationBase.reply_markup, - horizontal_accuracy=TestInlineQueryResultLocationBase.horizontal_accuracy, - heading=TestInlineQueryResultLocationBase.heading, - proximity_alert_radius=TestInlineQueryResultLocationBase.proximity_alert_radius, + InlineQueryResultLocationTestBase.id_, + InlineQueryResultLocationTestBase.latitude, + InlineQueryResultLocationTestBase.longitude, + InlineQueryResultLocationTestBase.title, + live_period=InlineQueryResultLocationTestBase.live_period, + thumbnail_url=InlineQueryResultLocationTestBase.thumbnail_url, + thumbnail_width=InlineQueryResultLocationTestBase.thumbnail_width, + thumbnail_height=InlineQueryResultLocationTestBase.thumbnail_height, + input_message_content=InlineQueryResultLocationTestBase.input_message_content, + reply_markup=InlineQueryResultLocationTestBase.reply_markup, + horizontal_accuracy=InlineQueryResultLocationTestBase.horizontal_accuracy, + heading=InlineQueryResultLocationTestBase.heading, + proximity_alert_radius=InlineQueryResultLocationTestBase.proximity_alert_radius, ) -class TestInlineQueryResultLocationBase: +class InlineQueryResultLocationTestBase: id_ = "id" type_ = "location" latitude = 0.0 longitude = 1.0 title = "title" horizontal_accuracy = 999 - live_period = 70 + live_period = dtm.timedelta(seconds=70) heading = 90 proximity_alert_radius = 1000 thumbnail_url = "thumb url" @@ -65,7 +67,7 @@ class TestInlineQueryResultLocationBase: reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) -class TestInlineQueryResultLocationWithoutRequest(TestInlineQueryResultLocationBase): +class TestInlineQueryResultLocationWithoutRequest(InlineQueryResultLocationTestBase): def test_slot_behaviour(self, inline_query_result_location): inst = inline_query_result_location for attr in inst.__slots__: @@ -78,7 +80,7 @@ def test_expected_values(self, inline_query_result_location): assert inline_query_result_location.latitude == self.latitude assert inline_query_result_location.longitude == self.longitude assert inline_query_result_location.title == self.title - assert inline_query_result_location.live_period == self.live_period + assert inline_query_result_location._live_period == self.live_period assert inline_query_result_location.thumbnail_url == self.thumbnail_url assert inline_query_result_location.thumbnail_width == self.thumbnail_width assert inline_query_result_location.thumbnail_height == self.thumbnail_height @@ -91,138 +93,6 @@ def test_expected_values(self, inline_query_result_location): assert inline_query_result_location.horizontal_accuracy == self.horizontal_accuracy assert inline_query_result_location.proximity_alert_radius == self.proximity_alert_radius - def test_thumb_url_property_deprecation_warning(self, recwarn): - inline_query_result_location = InlineQueryResultLocation( - TestInlineQueryResultLocationBase.id_, - TestInlineQueryResultLocationBase.latitude, - TestInlineQueryResultLocationBase.longitude, - TestInlineQueryResultLocationBase.title, - live_period=TestInlineQueryResultLocationBase.live_period, - input_message_content=TestInlineQueryResultLocationBase.input_message_content, - reply_markup=TestInlineQueryResultLocationBase.reply_markup, - horizontal_accuracy=TestInlineQueryResultLocationBase.horizontal_accuracy, - heading=TestInlineQueryResultLocationBase.heading, - proximity_alert_radius=TestInlineQueryResultLocationBase.proximity_alert_radius, - thumb_url=TestInlineQueryResultLocationBase.thumbnail_url, # deprecated arg - thumbnail_height=TestInlineQueryResultLocationBase.thumbnail_height, - thumbnail_width=TestInlineQueryResultLocationBase.thumbnail_width, - ) - assert inline_query_result_location.thumb_url == inline_query_result_location.thumbnail_url - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_url", new_name="thumbnail_url" - ) - - def test_thumb_height_property_deprecation_warning(self, recwarn): - inline_query_result_location = InlineQueryResultLocation( - TestInlineQueryResultLocationBase.id_, - TestInlineQueryResultLocationBase.latitude, - TestInlineQueryResultLocationBase.longitude, - TestInlineQueryResultLocationBase.title, - live_period=TestInlineQueryResultLocationBase.live_period, - input_message_content=TestInlineQueryResultLocationBase.input_message_content, - reply_markup=TestInlineQueryResultLocationBase.reply_markup, - horizontal_accuracy=TestInlineQueryResultLocationBase.horizontal_accuracy, - heading=TestInlineQueryResultLocationBase.heading, - proximity_alert_radius=TestInlineQueryResultLocationBase.proximity_alert_radius, - thumbnail_url=TestInlineQueryResultLocationBase.thumbnail_url, - thumb_height=TestInlineQueryResultLocationBase.thumbnail_height, # deprecated arg - thumbnail_width=TestInlineQueryResultLocationBase.thumbnail_width, - ) - assert ( - inline_query_result_location.thumb_height - == inline_query_result_location.thumbnail_height - ) - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_height", new_name="thumbnail_height" - ) - - def test_thumb_width_property_deprecation_warning(self, recwarn): - inline_query_result_location = InlineQueryResultLocation( - TestInlineQueryResultLocationBase.id_, - TestInlineQueryResultLocationBase.latitude, - TestInlineQueryResultLocationBase.longitude, - TestInlineQueryResultLocationBase.title, - live_period=TestInlineQueryResultLocationBase.live_period, - input_message_content=TestInlineQueryResultLocationBase.input_message_content, - reply_markup=TestInlineQueryResultLocationBase.reply_markup, - horizontal_accuracy=TestInlineQueryResultLocationBase.horizontal_accuracy, - heading=TestInlineQueryResultLocationBase.heading, - proximity_alert_radius=TestInlineQueryResultLocationBase.proximity_alert_radius, - thumbnail_url=TestInlineQueryResultLocationBase.thumbnail_url, - thumbnail_height=TestInlineQueryResultLocationBase.thumbnail_height, - thumb_width=TestInlineQueryResultLocationBase.thumbnail_width, # deprecated arg - ) - assert ( - inline_query_result_location.thumb_width - == inline_query_result_location.thumbnail_width - ) - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_width", new_name="thumbnail_width" - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_url(self): - with pytest.raises( - ValueError, match="different entities as 'thumb_url' and 'thumbnail_url'" - ): - InlineQueryResultLocation( - TestInlineQueryResultLocationBase.id_, - TestInlineQueryResultLocationBase.latitude, - TestInlineQueryResultLocationBase.longitude, - TestInlineQueryResultLocationBase.title, - live_period=TestInlineQueryResultLocationBase.live_period, - input_message_content=TestInlineQueryResultLocationBase.input_message_content, - reply_markup=TestInlineQueryResultLocationBase.reply_markup, - horizontal_accuracy=TestInlineQueryResultLocationBase.horizontal_accuracy, - heading=TestInlineQueryResultLocationBase.heading, - proximity_alert_radius=TestInlineQueryResultLocationBase.proximity_alert_radius, - thumbnail_url=TestInlineQueryResultLocationBase.thumbnail_url, - thumb_url="some other url", - thumbnail_height=TestInlineQueryResultLocationBase.thumbnail_height, - thumbnail_width=TestInlineQueryResultLocationBase.thumbnail_width, - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_height(self): - with pytest.raises( - ValueError, match="different entities as 'thumb_height' and 'thumbnail_height'" - ): - InlineQueryResultLocation( - TestInlineQueryResultLocationBase.id_, - TestInlineQueryResultLocationBase.latitude, - TestInlineQueryResultLocationBase.longitude, - TestInlineQueryResultLocationBase.title, - live_period=TestInlineQueryResultLocationBase.live_period, - input_message_content=TestInlineQueryResultLocationBase.input_message_content, - reply_markup=TestInlineQueryResultLocationBase.reply_markup, - horizontal_accuracy=TestInlineQueryResultLocationBase.horizontal_accuracy, - heading=TestInlineQueryResultLocationBase.heading, - proximity_alert_radius=TestInlineQueryResultLocationBase.proximity_alert_radius, - thumbnail_url=TestInlineQueryResultLocationBase.thumbnail_url, - thumbnail_height=TestInlineQueryResultLocationBase.thumbnail_height, - thumb_height=TestInlineQueryResultLocationBase.thumbnail_height + 1, - thumbnail_width=TestInlineQueryResultLocationBase.thumbnail_width, - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_width(self): - with pytest.raises( - ValueError, match="different entities as 'thumb_width' and 'thumbnail_width'" - ): - InlineQueryResultLocation( - TestInlineQueryResultLocationBase.id_, - TestInlineQueryResultLocationBase.latitude, - TestInlineQueryResultLocationBase.longitude, - TestInlineQueryResultLocationBase.title, - live_period=TestInlineQueryResultLocationBase.live_period, - input_message_content=TestInlineQueryResultLocationBase.input_message_content, - reply_markup=TestInlineQueryResultLocationBase.reply_markup, - horizontal_accuracy=TestInlineQueryResultLocationBase.horizontal_accuracy, - heading=TestInlineQueryResultLocationBase.heading, - proximity_alert_radius=TestInlineQueryResultLocationBase.proximity_alert_radius, - thumbnail_url=TestInlineQueryResultLocationBase.thumbnail_url, - thumbnail_height=TestInlineQueryResultLocationBase.thumbnail_height, - thumbnail_width=TestInlineQueryResultLocationBase.thumbnail_width, - thumb_width=TestInlineQueryResultLocationBase.thumbnail_width + 1, - ) - def test_to_dict(self, inline_query_result_location): inline_query_result_location_dict = inline_query_result_location.to_dict() @@ -237,10 +107,10 @@ def test_to_dict(self, inline_query_result_location): == inline_query_result_location.longitude ) assert inline_query_result_location_dict["title"] == inline_query_result_location.title - assert ( - inline_query_result_location_dict["live_period"] - == inline_query_result_location.live_period + assert inline_query_result_location_dict["live_period"] == int( + self.live_period.total_seconds() ) + assert isinstance(inline_query_result_location_dict["live_period"], int) assert ( inline_query_result_location_dict["thumbnail_url"] == inline_query_result_location.thumbnail_url @@ -271,6 +141,28 @@ def test_to_dict(self, inline_query_result_location): == inline_query_result_location.proximity_alert_radius ) + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_location): + live_period = inline_query_result_location.live_period + + if PTB_TIMEDELTA: + assert live_period == self.live_period + assert isinstance(live_period, dtm.timedelta) + else: + assert live_period == int(self.live_period.total_seconds()) + assert isinstance(live_period, int) + + def test_time_period_int_deprecated( + self, recwarn, PTB_TIMEDELTA, inline_query_result_location + ): + inline_query_result_location.live_period + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`live_period` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = InlineQueryResultLocation(self.id_, self.longitude, self.latitude, self.title) b = InlineQueryResultLocation(self.id_, self.longitude, self.latitude, self.title) diff --git a/tests/_inline/test_inlinequeryresultmpeg4gif.py b/tests/_inline/test_inlinequeryresultmpeg4gif.py index 7a340a0464d..7b1fa84e5d1 100644 --- a/tests/_inline/test_inlinequeryresultmpeg4gif.py +++ b/tests/_inline/test_inlinequeryresultmpeg4gif.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,6 +16,8 @@ # # 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 as dtm + import pytest from telegram import ( @@ -26,36 +28,37 @@ InputTextMessageContent, MessageEntity, ) -from tests.auxil.deprecations import check_thumb_deprecation_warnings_for_args_and_attrs +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def inline_query_result_mpeg4_gif(): return InlineQueryResultMpeg4Gif( - TestInlineQueryResultMpeg4GifBase.id_, - TestInlineQueryResultMpeg4GifBase.mpeg4_url, - TestInlineQueryResultMpeg4GifBase.thumbnail_url, - mpeg4_width=TestInlineQueryResultMpeg4GifBase.mpeg4_width, - mpeg4_height=TestInlineQueryResultMpeg4GifBase.mpeg4_height, - mpeg4_duration=TestInlineQueryResultMpeg4GifBase.mpeg4_duration, - title=TestInlineQueryResultMpeg4GifBase.title, - caption=TestInlineQueryResultMpeg4GifBase.caption, - parse_mode=TestInlineQueryResultMpeg4GifBase.parse_mode, - caption_entities=TestInlineQueryResultMpeg4GifBase.caption_entities, - input_message_content=TestInlineQueryResultMpeg4GifBase.input_message_content, - reply_markup=TestInlineQueryResultMpeg4GifBase.reply_markup, - thumbnail_mime_type=TestInlineQueryResultMpeg4GifBase.thumbnail_mime_type, + InlineQueryResultMpeg4GifTestBase.id_, + InlineQueryResultMpeg4GifTestBase.mpeg4_url, + InlineQueryResultMpeg4GifTestBase.thumbnail_url, + mpeg4_width=InlineQueryResultMpeg4GifTestBase.mpeg4_width, + mpeg4_height=InlineQueryResultMpeg4GifTestBase.mpeg4_height, + mpeg4_duration=InlineQueryResultMpeg4GifTestBase.mpeg4_duration, + title=InlineQueryResultMpeg4GifTestBase.title, + caption=InlineQueryResultMpeg4GifTestBase.caption, + parse_mode=InlineQueryResultMpeg4GifTestBase.parse_mode, + caption_entities=InlineQueryResultMpeg4GifTestBase.caption_entities, + input_message_content=InlineQueryResultMpeg4GifTestBase.input_message_content, + reply_markup=InlineQueryResultMpeg4GifTestBase.reply_markup, + thumbnail_mime_type=InlineQueryResultMpeg4GifTestBase.thumbnail_mime_type, + show_caption_above_media=InlineQueryResultMpeg4GifTestBase.show_caption_above_media, ) -class TestInlineQueryResultMpeg4GifBase: +class InlineQueryResultMpeg4GifTestBase: id_ = "id" type_ = "mpeg4_gif" mpeg4_url = "mpeg4 url" mpeg4_width = 10 mpeg4_height = 15 - mpeg4_duration = 1 + mpeg4_duration = dtm.timedelta(seconds=1) thumbnail_url = "thumb url" thumbnail_mime_type = "image/jpeg" title = "title" @@ -64,9 +67,10 @@ class TestInlineQueryResultMpeg4GifBase: caption_entities = [MessageEntity(MessageEntity.ITALIC, 0, 7)] input_message_content = InputTextMessageContent("input_message_content") reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) + show_caption_above_media = True -class TestInlineQueryResultMpeg4GifWithoutRequest(TestInlineQueryResultMpeg4GifBase): +class TestInlineQueryResultMpeg4GifWithoutRequest(InlineQueryResultMpeg4GifTestBase): def test_slot_behaviour(self, inline_query_result_mpeg4_gif): inst = inline_query_result_mpeg4_gif for attr in inst.__slots__: @@ -79,7 +83,7 @@ def test_expected_values(self, inline_query_result_mpeg4_gif): assert inline_query_result_mpeg4_gif.mpeg4_url == self.mpeg4_url assert inline_query_result_mpeg4_gif.mpeg4_width == self.mpeg4_width assert inline_query_result_mpeg4_gif.mpeg4_height == self.mpeg4_height - assert inline_query_result_mpeg4_gif.mpeg4_duration == self.mpeg4_duration + assert inline_query_result_mpeg4_gif._mpeg4_duration == self.mpeg4_duration assert inline_query_result_mpeg4_gif.thumbnail_url == self.thumbnail_url assert inline_query_result_mpeg4_gif.thumbnail_mime_type == self.thumbnail_mime_type assert inline_query_result_mpeg4_gif.title == self.title @@ -91,151 +95,14 @@ def test_expected_values(self, inline_query_result_mpeg4_gif): == self.input_message_content.to_dict() ) assert inline_query_result_mpeg4_gif.reply_markup.to_dict() == self.reply_markup.to_dict() + assert ( + inline_query_result_mpeg4_gif.show_caption_above_media == self.show_caption_above_media + ) def test_caption_entities_always_tuple(self): result = InlineQueryResultMpeg4Gif(self.id_, self.mpeg4_url, self.thumbnail_url) assert result.caption_entities == () - def test_thumb_url_property_deprecation_warning(self, recwarn): - inline_query_result_mpeg4_gif = InlineQueryResultMpeg4Gif( - TestInlineQueryResultMpeg4GifBase.id_, - TestInlineQueryResultMpeg4GifBase.mpeg4_url, - TestInlineQueryResultMpeg4GifBase.thumbnail_url, - mpeg4_width=TestInlineQueryResultMpeg4GifBase.mpeg4_width, - mpeg4_height=TestInlineQueryResultMpeg4GifBase.mpeg4_height, - mpeg4_duration=TestInlineQueryResultMpeg4GifBase.mpeg4_duration, - title=TestInlineQueryResultMpeg4GifBase.title, - caption=TestInlineQueryResultMpeg4GifBase.caption, - parse_mode=TestInlineQueryResultMpeg4GifBase.parse_mode, - caption_entities=TestInlineQueryResultMpeg4GifBase.caption_entities, - input_message_content=TestInlineQueryResultMpeg4GifBase.input_message_content, - reply_markup=TestInlineQueryResultMpeg4GifBase.reply_markup, - thumbnail_mime_type=TestInlineQueryResultMpeg4GifBase.thumbnail_mime_type, - thumb_url=TestInlineQueryResultMpeg4GifBase.thumbnail_url, # deprecated arg - ) - assert ( - inline_query_result_mpeg4_gif.thumb_url == inline_query_result_mpeg4_gif.thumbnail_url - ) - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, - __file__, - deprecated_name="thumb_url", - new_name="thumbnail_url", - ) - - def test_thumb_mime_type_property_deprecation_warning(self, recwarn): - inline_query_result_mpeg4_gif = InlineQueryResultMpeg4Gif( - TestInlineQueryResultMpeg4GifBase.id_, - TestInlineQueryResultMpeg4GifBase.mpeg4_url, - TestInlineQueryResultMpeg4GifBase.thumbnail_url, - mpeg4_width=TestInlineQueryResultMpeg4GifBase.mpeg4_width, - mpeg4_height=TestInlineQueryResultMpeg4GifBase.mpeg4_height, - mpeg4_duration=TestInlineQueryResultMpeg4GifBase.mpeg4_duration, - title=TestInlineQueryResultMpeg4GifBase.title, - caption=TestInlineQueryResultMpeg4GifBase.caption, - parse_mode=TestInlineQueryResultMpeg4GifBase.parse_mode, - caption_entities=TestInlineQueryResultMpeg4GifBase.caption_entities, - input_message_content=TestInlineQueryResultMpeg4GifBase.input_message_content, - reply_markup=TestInlineQueryResultMpeg4GifBase.reply_markup, - thumb_mime_type=TestInlineQueryResultMpeg4GifBase.thumbnail_mime_type, # deprecated - ) - assert ( - inline_query_result_mpeg4_gif.thumb_mime_type - == inline_query_result_mpeg4_gif.thumbnail_mime_type - ) - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_mime_type", new_name="thumbnail_mime_type" - ) - - def test_thumb_url_issues_warning_and_works_without_positional_arg(self, recwarn): - inline_query_result_mpeg4_gif = InlineQueryResultMpeg4Gif( - TestInlineQueryResultMpeg4GifBase.id_, - TestInlineQueryResultMpeg4GifBase.mpeg4_url, - # positional argument thumbnail_url should be here, but it's not - mpeg4_width=TestInlineQueryResultMpeg4GifBase.mpeg4_width, - mpeg4_height=TestInlineQueryResultMpeg4GifBase.mpeg4_height, - mpeg4_duration=TestInlineQueryResultMpeg4GifBase.mpeg4_duration, - title=TestInlineQueryResultMpeg4GifBase.title, - caption=TestInlineQueryResultMpeg4GifBase.caption, - parse_mode=TestInlineQueryResultMpeg4GifBase.parse_mode, - caption_entities=TestInlineQueryResultMpeg4GifBase.caption_entities, - input_message_content=TestInlineQueryResultMpeg4GifBase.input_message_content, - reply_markup=TestInlineQueryResultMpeg4GifBase.reply_markup, - thumbnail_mime_type=TestInlineQueryResultMpeg4GifBase.thumbnail_mime_type, - thumb_url=TestInlineQueryResultMpeg4GifBase.thumbnail_url, # deprecated arg - ) - - assert ( - inline_query_result_mpeg4_gif.thumb_url == inline_query_result_mpeg4_gif.thumbnail_url - ) - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, - __file__, - deprecated_name="thumb_url", - new_name="thumbnail_url", - ) - - def test_init_throws_error_without_thumbnail_url_and_thumb_url(self): - with pytest.raises(ValueError, match="You must pass either"): - InlineQueryResultMpeg4Gif( - TestInlineQueryResultMpeg4GifBase.id_, - TestInlineQueryResultMpeg4GifBase.mpeg4_url, - # no thumbnail_url or thumb_url - mpeg4_width=TestInlineQueryResultMpeg4GifBase.mpeg4_width, - mpeg4_height=TestInlineQueryResultMpeg4GifBase.mpeg4_height, - mpeg4_duration=TestInlineQueryResultMpeg4GifBase.mpeg4_duration, - title=TestInlineQueryResultMpeg4GifBase.title, - caption=TestInlineQueryResultMpeg4GifBase.caption, - parse_mode=TestInlineQueryResultMpeg4GifBase.parse_mode, - caption_entities=TestInlineQueryResultMpeg4GifBase.caption_entities, - input_message_content=TestInlineQueryResultMpeg4GifBase.input_message_content, - reply_markup=TestInlineQueryResultMpeg4GifBase.reply_markup, - thumb_mime_type=TestInlineQueryResultMpeg4GifBase.thumbnail_mime_type, - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_url(self): - with pytest.raises( - ValueError, match="different entities as 'thumb_url' and 'thumbnail_url'" - ): - InlineQueryResultMpeg4Gif( - TestInlineQueryResultMpeg4GifBase.id_, - TestInlineQueryResultMpeg4GifBase.mpeg4_url, - TestInlineQueryResultMpeg4GifBase.thumbnail_url, - mpeg4_width=TestInlineQueryResultMpeg4GifBase.mpeg4_width, - mpeg4_height=TestInlineQueryResultMpeg4GifBase.mpeg4_height, - mpeg4_duration=TestInlineQueryResultMpeg4GifBase.mpeg4_duration, - title=TestInlineQueryResultMpeg4GifBase.title, - caption=TestInlineQueryResultMpeg4GifBase.caption, - parse_mode=TestInlineQueryResultMpeg4GifBase.parse_mode, - caption_entities=TestInlineQueryResultMpeg4GifBase.caption_entities, - input_message_content=TestInlineQueryResultMpeg4GifBase.input_message_content, - reply_markup=TestInlineQueryResultMpeg4GifBase.reply_markup, - thumbnail_mime_type=TestInlineQueryResultMpeg4GifBase.thumbnail_mime_type, - thumb_url="some other URL", - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_mime_type(self): - with pytest.raises( - ValueError, - match="different entities as 'thumb_mime_type' and 'thumbnail_mime_type'", - ): - InlineQueryResultMpeg4Gif( - TestInlineQueryResultMpeg4GifBase.id_, - TestInlineQueryResultMpeg4GifBase.mpeg4_url, - TestInlineQueryResultMpeg4GifBase.thumbnail_url, - mpeg4_width=TestInlineQueryResultMpeg4GifBase.mpeg4_width, - mpeg4_height=TestInlineQueryResultMpeg4GifBase.mpeg4_height, - mpeg4_duration=TestInlineQueryResultMpeg4GifBase.mpeg4_duration, - title=TestInlineQueryResultMpeg4GifBase.title, - caption=TestInlineQueryResultMpeg4GifBase.caption, - parse_mode=TestInlineQueryResultMpeg4GifBase.parse_mode, - caption_entities=TestInlineQueryResultMpeg4GifBase.caption_entities, - input_message_content=TestInlineQueryResultMpeg4GifBase.input_message_content, - reply_markup=TestInlineQueryResultMpeg4GifBase.reply_markup, - thumbnail_mime_type=TestInlineQueryResultMpeg4GifBase.thumbnail_mime_type, - thumb_mime_type="video/mp4", - ) - def test_to_dict(self, inline_query_result_mpeg4_gif): inline_query_result_mpeg4_gif_dict = inline_query_result_mpeg4_gif.to_dict() @@ -254,10 +121,10 @@ def test_to_dict(self, inline_query_result_mpeg4_gif): inline_query_result_mpeg4_gif_dict["mpeg4_height"] == inline_query_result_mpeg4_gif.mpeg4_height ) - assert ( - inline_query_result_mpeg4_gif_dict["mpeg4_duration"] - == inline_query_result_mpeg4_gif.mpeg4_duration + assert inline_query_result_mpeg4_gif_dict["mpeg4_duration"] == int( + self.mpeg4_duration.total_seconds() ) + assert isinstance(inline_query_result_mpeg4_gif_dict["mpeg4_duration"], int) assert ( inline_query_result_mpeg4_gif_dict["thumbnail_url"] == inline_query_result_mpeg4_gif.thumbnail_url @@ -285,6 +152,34 @@ def test_to_dict(self, inline_query_result_mpeg4_gif): inline_query_result_mpeg4_gif_dict["reply_markup"] == inline_query_result_mpeg4_gif.reply_markup.to_dict() ) + assert ( + inline_query_result_mpeg4_gif_dict["show_caption_above_media"] + == inline_query_result_mpeg4_gif.show_caption_above_media + ) + + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_mpeg4_gif): + mpeg4_duration = inline_query_result_mpeg4_gif.mpeg4_duration + + if PTB_TIMEDELTA: + assert mpeg4_duration == self.mpeg4_duration + assert isinstance(mpeg4_duration, dtm.timedelta) + else: + assert mpeg4_duration == int(self.mpeg4_duration.total_seconds()) + assert isinstance(mpeg4_duration, int) + + def test_time_period_int_deprecated( + self, recwarn, PTB_TIMEDELTA, inline_query_result_mpeg4_gif + ): + inline_query_result_mpeg4_gif.mpeg4_duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`mpeg4_duration` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) + assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): a = InlineQueryResultMpeg4Gif(self.id_, self.mpeg4_url, self.thumbnail_url) diff --git a/tests/_inline/test_inlinequeryresultphoto.py b/tests/_inline/test_inlinequeryresultphoto.py index d10b15fa374..3f4d0f12b56 100644 --- a/tests/_inline/test_inlinequeryresultphoto.py +++ b/tests/_inline/test_inlinequeryresultphoto.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -26,29 +26,29 @@ InputTextMessageContent, MessageEntity, ) -from tests.auxil.deprecations import check_thumb_deprecation_warnings_for_args_and_attrs from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def inline_query_result_photo(): return InlineQueryResultPhoto( - TestInlineQueryResultPhotoBase.id_, - TestInlineQueryResultPhotoBase.photo_url, - TestInlineQueryResultPhotoBase.thumbnail_url, - photo_width=TestInlineQueryResultPhotoBase.photo_width, - photo_height=TestInlineQueryResultPhotoBase.photo_height, - title=TestInlineQueryResultPhotoBase.title, - description=TestInlineQueryResultPhotoBase.description, - caption=TestInlineQueryResultPhotoBase.caption, - parse_mode=TestInlineQueryResultPhotoBase.parse_mode, - caption_entities=TestInlineQueryResultPhotoBase.caption_entities, - input_message_content=TestInlineQueryResultPhotoBase.input_message_content, - reply_markup=TestInlineQueryResultPhotoBase.reply_markup, + InlineQueryResultPhotoTestBase.id_, + InlineQueryResultPhotoTestBase.photo_url, + InlineQueryResultPhotoTestBase.thumbnail_url, + photo_width=InlineQueryResultPhotoTestBase.photo_width, + photo_height=InlineQueryResultPhotoTestBase.photo_height, + title=InlineQueryResultPhotoTestBase.title, + description=InlineQueryResultPhotoTestBase.description, + caption=InlineQueryResultPhotoTestBase.caption, + parse_mode=InlineQueryResultPhotoTestBase.parse_mode, + caption_entities=InlineQueryResultPhotoTestBase.caption_entities, + input_message_content=InlineQueryResultPhotoTestBase.input_message_content, + reply_markup=InlineQueryResultPhotoTestBase.reply_markup, + show_caption_above_media=InlineQueryResultPhotoTestBase.show_caption_above_media, ) -class TestInlineQueryResultPhotoBase: +class InlineQueryResultPhotoTestBase: id_ = "id" type_ = "photo" photo_url = "photo url" @@ -63,9 +63,10 @@ class TestInlineQueryResultPhotoBase: input_message_content = InputTextMessageContent("input_message_content") reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) + show_caption_above_media = True -class TestInlineQueryResultPhotoWithoutRequest(TestInlineQueryResultPhotoBase): +class TestInlineQueryResultPhotoWithoutRequest(InlineQueryResultPhotoTestBase): def test_slot_behaviour(self, inline_query_result_photo): inst = inline_query_result_photo for attr in inst.__slots__: @@ -89,97 +90,12 @@ def test_expected_values(self, inline_query_result_photo): == self.input_message_content.to_dict() ) assert inline_query_result_photo.reply_markup.to_dict() == self.reply_markup.to_dict() + assert inline_query_result_photo.show_caption_above_media == self.show_caption_above_media def test_caption_entities_always_tuple(self): result = InlineQueryResultPhoto(self.id_, self.photo_url, self.thumbnail_url) assert result.caption_entities == () - def test_thumb_url_property_deprecation_warning(self, recwarn): - inline_query_result_photo = InlineQueryResultPhoto( - TestInlineQueryResultPhotoBase.id_, - TestInlineQueryResultPhotoBase.photo_url, - TestInlineQueryResultPhotoBase.thumbnail_url, - photo_width=TestInlineQueryResultPhotoBase.photo_width, - photo_height=TestInlineQueryResultPhotoBase.photo_height, - title=TestInlineQueryResultPhotoBase.title, - description=TestInlineQueryResultPhotoBase.description, - caption=TestInlineQueryResultPhotoBase.caption, - parse_mode=TestInlineQueryResultPhotoBase.parse_mode, - caption_entities=TestInlineQueryResultPhotoBase.caption_entities, - input_message_content=TestInlineQueryResultPhotoBase.input_message_content, - reply_markup=TestInlineQueryResultPhotoBase.reply_markup, - thumb_url=TestInlineQueryResultPhotoBase.thumbnail_url, # deprecated arg - ) - assert inline_query_result_photo.thumb_url == inline_query_result_photo.thumbnail_url - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, - __file__, - deprecated_name="thumb_url", - new_name="thumbnail_url", - ) - - def test_thumb_url_issues_warning_and_works_without_positional_arg(self, recwarn): - inline_query_result_photo = InlineQueryResultPhoto( - TestInlineQueryResultPhotoBase.id_, - TestInlineQueryResultPhotoBase.photo_url, - # positional argument thumbnail_url should be here, but it's not - photo_width=TestInlineQueryResultPhotoBase.photo_width, - photo_height=TestInlineQueryResultPhotoBase.photo_height, - title=TestInlineQueryResultPhotoBase.title, - description=TestInlineQueryResultPhotoBase.description, - caption=TestInlineQueryResultPhotoBase.caption, - parse_mode=TestInlineQueryResultPhotoBase.parse_mode, - caption_entities=TestInlineQueryResultPhotoBase.caption_entities, - input_message_content=TestInlineQueryResultPhotoBase.input_message_content, - reply_markup=TestInlineQueryResultPhotoBase.reply_markup, - thumb_url=TestInlineQueryResultPhotoBase.thumbnail_url, # deprecated arg - ) - assert inline_query_result_photo.thumb_url == inline_query_result_photo.thumbnail_url - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, - __file__, - deprecated_name="thumb_url", - new_name="thumbnail_url", - ) - - def test_init_throws_error_without_thumbnail_url_and_thumb_url(self): - with pytest.raises(ValueError, match="You must pass either"): - InlineQueryResultPhoto( - TestInlineQueryResultPhotoBase.id_, - TestInlineQueryResultPhotoBase.photo_url, - # no thumbnail_url or thumb_url - photo_width=TestInlineQueryResultPhotoBase.photo_width, - photo_height=TestInlineQueryResultPhotoBase.photo_height, - title=TestInlineQueryResultPhotoBase.title, - description=TestInlineQueryResultPhotoBase.description, - caption=TestInlineQueryResultPhotoBase.caption, - parse_mode=TestInlineQueryResultPhotoBase.parse_mode, - caption_entities=TestInlineQueryResultPhotoBase.caption_entities, - input_message_content=TestInlineQueryResultPhotoBase.input_message_content, - reply_markup=TestInlineQueryResultPhotoBase.reply_markup, - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_url(self): - with pytest.raises( - ValueError, - match="different entities as 'thumb_url' and 'thumbnail_url'", - ): - InlineQueryResultPhoto( - TestInlineQueryResultPhotoBase.id_, - TestInlineQueryResultPhotoBase.photo_url, - TestInlineQueryResultPhotoBase.thumbnail_url, - photo_width=TestInlineQueryResultPhotoBase.photo_width, - photo_height=TestInlineQueryResultPhotoBase.photo_height, - title=TestInlineQueryResultPhotoBase.title, - description=TestInlineQueryResultPhotoBase.description, - caption=TestInlineQueryResultPhotoBase.caption, - parse_mode=TestInlineQueryResultPhotoBase.parse_mode, - caption_entities=TestInlineQueryResultPhotoBase.caption_entities, - input_message_content=TestInlineQueryResultPhotoBase.input_message_content, - reply_markup=TestInlineQueryResultPhotoBase.reply_markup, - thumb_url="some other url", - ) - def test_to_dict(self, inline_query_result_photo): inline_query_result_photo_dict = inline_query_result_photo.to_dict() @@ -215,6 +131,10 @@ def test_to_dict(self, inline_query_result_photo): inline_query_result_photo_dict["reply_markup"] == inline_query_result_photo.reply_markup.to_dict() ) + assert ( + inline_query_result_photo_dict["show_caption_above_media"] + == inline_query_result_photo.show_caption_above_media + ) def test_equality(self): a = InlineQueryResultPhoto(self.id_, self.photo_url, self.thumbnail_url) diff --git a/tests/_inline/test_inlinequeryresultvenue.py b/tests/_inline/test_inlinequeryresultvenue.py index ee456cf10dc..f6bc75c0108 100644 --- a/tests/_inline/test_inlinequeryresultvenue.py +++ b/tests/_inline/test_inlinequeryresultvenue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,31 +25,30 @@ InlineQueryResultVoice, InputTextMessageContent, ) -from tests.auxil.deprecations import check_thumb_deprecation_warnings_for_args_and_attrs from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def inline_query_result_venue(): return InlineQueryResultVenue( - TestInlineQueryResultVenueBase.id_, - TestInlineQueryResultVenueBase.latitude, - TestInlineQueryResultVenueBase.longitude, - TestInlineQueryResultVenueBase.title, - TestInlineQueryResultVenueBase.address, - foursquare_id=TestInlineQueryResultVenueBase.foursquare_id, - foursquare_type=TestInlineQueryResultVenueBase.foursquare_type, - thumbnail_url=TestInlineQueryResultVenueBase.thumbnail_url, - thumbnail_width=TestInlineQueryResultVenueBase.thumbnail_width, - thumbnail_height=TestInlineQueryResultVenueBase.thumbnail_height, - input_message_content=TestInlineQueryResultVenueBase.input_message_content, - reply_markup=TestInlineQueryResultVenueBase.reply_markup, - google_place_id=TestInlineQueryResultVenueBase.google_place_id, - google_place_type=TestInlineQueryResultVenueBase.google_place_type, + InlineQueryResultVenueTestBase.id_, + InlineQueryResultVenueTestBase.latitude, + InlineQueryResultVenueTestBase.longitude, + InlineQueryResultVenueTestBase.title, + InlineQueryResultVenueTestBase.address, + foursquare_id=InlineQueryResultVenueTestBase.foursquare_id, + foursquare_type=InlineQueryResultVenueTestBase.foursquare_type, + thumbnail_url=InlineQueryResultVenueTestBase.thumbnail_url, + thumbnail_width=InlineQueryResultVenueTestBase.thumbnail_width, + thumbnail_height=InlineQueryResultVenueTestBase.thumbnail_height, + input_message_content=InlineQueryResultVenueTestBase.input_message_content, + reply_markup=InlineQueryResultVenueTestBase.reply_markup, + google_place_id=InlineQueryResultVenueTestBase.google_place_id, + google_place_type=InlineQueryResultVenueTestBase.google_place_type, ) -class TestInlineQueryResultVenueBase: +class InlineQueryResultVenueTestBase: id_ = "id" type_ = "venue" latitude = "latitude" @@ -67,7 +66,7 @@ class TestInlineQueryResultVenueBase: reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) -class TestInlineQueryResultVenueWithoutRequest(TestInlineQueryResultVenueBase): +class TestInlineQueryResultVenueWithoutRequest(InlineQueryResultVenueTestBase): def test_slot_behaviour(self, inline_query_result_venue): inst = inline_query_result_venue for attr in inst.__slots__: @@ -94,138 +93,6 @@ def test_expected_values(self, inline_query_result_venue): ) assert inline_query_result_venue.reply_markup.to_dict() == self.reply_markup.to_dict() - def test_thumb_url_property_deprecation_warning(self, recwarn): - inline_query_result_venue = InlineQueryResultVenue( - TestInlineQueryResultVenueBase.id_, - TestInlineQueryResultVenueBase.latitude, - TestInlineQueryResultVenueBase.longitude, - TestInlineQueryResultVenueBase.title, - TestInlineQueryResultVenueBase.address, - foursquare_id=TestInlineQueryResultVenueBase.foursquare_id, - foursquare_type=TestInlineQueryResultVenueBase.foursquare_type, - input_message_content=TestInlineQueryResultVenueBase.input_message_content, - reply_markup=TestInlineQueryResultVenueBase.reply_markup, - google_place_id=TestInlineQueryResultVenueBase.google_place_id, - google_place_type=TestInlineQueryResultVenueBase.google_place_type, - thumb_url=TestInlineQueryResultVenueBase.thumbnail_url, # deprecated arg - thumbnail_height=TestInlineQueryResultVenueBase.thumbnail_height, - thumbnail_width=TestInlineQueryResultVenueBase.thumbnail_width, - ) - assert inline_query_result_venue.thumb_url == inline_query_result_venue.thumbnail_url - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_url", new_name="thumbnail_url" - ) - - def test_thumb_height_property_deprecation_warning(self, recwarn): - inline_query_result_venue = InlineQueryResultVenue( - TestInlineQueryResultVenueBase.id_, - TestInlineQueryResultVenueBase.latitude, - TestInlineQueryResultVenueBase.longitude, - TestInlineQueryResultVenueBase.title, - TestInlineQueryResultVenueBase.address, - foursquare_id=TestInlineQueryResultVenueBase.foursquare_id, - foursquare_type=TestInlineQueryResultVenueBase.foursquare_type, - input_message_content=TestInlineQueryResultVenueBase.input_message_content, - reply_markup=TestInlineQueryResultVenueBase.reply_markup, - google_place_id=TestInlineQueryResultVenueBase.google_place_id, - google_place_type=TestInlineQueryResultVenueBase.google_place_type, - thumbnail_url=TestInlineQueryResultVenueBase.thumbnail_url, - thumb_height=TestInlineQueryResultVenueBase.thumbnail_height, # deprecated arg - thumbnail_width=TestInlineQueryResultVenueBase.thumbnail_width, - ) - assert inline_query_result_venue.thumb_height == inline_query_result_venue.thumbnail_height - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_height", new_name="thumbnail_height" - ) - - def test_thumb_width_property_deprecation_warning(self, recwarn): - inline_query_result_venue = InlineQueryResultVenue( - TestInlineQueryResultVenueBase.id_, - TestInlineQueryResultVenueBase.latitude, - TestInlineQueryResultVenueBase.longitude, - TestInlineQueryResultVenueBase.title, - TestInlineQueryResultVenueBase.address, - foursquare_id=TestInlineQueryResultVenueBase.foursquare_id, - foursquare_type=TestInlineQueryResultVenueBase.foursquare_type, - input_message_content=TestInlineQueryResultVenueBase.input_message_content, - reply_markup=TestInlineQueryResultVenueBase.reply_markup, - google_place_id=TestInlineQueryResultVenueBase.google_place_id, - google_place_type=TestInlineQueryResultVenueBase.google_place_type, - thumbnail_url=TestInlineQueryResultVenueBase.thumbnail_url, - thumbnail_height=TestInlineQueryResultVenueBase.thumbnail_height, - thumb_width=TestInlineQueryResultVenueBase.thumbnail_width, # deprecated arg - ) - assert inline_query_result_venue.thumb_width == inline_query_result_venue.thumbnail_width - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, __file__, deprecated_name="thumb_width", new_name="thumbnail_width" - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_url(self): - with pytest.raises( - ValueError, match="different entities as 'thumb_url' and 'thumbnail_url'" - ): - InlineQueryResultVenue( - TestInlineQueryResultVenueBase.id_, - TestInlineQueryResultVenueBase.latitude, - TestInlineQueryResultVenueBase.longitude, - TestInlineQueryResultVenueBase.title, - TestInlineQueryResultVenueBase.address, - foursquare_id=TestInlineQueryResultVenueBase.foursquare_id, - foursquare_type=TestInlineQueryResultVenueBase.foursquare_type, - input_message_content=TestInlineQueryResultVenueBase.input_message_content, - reply_markup=TestInlineQueryResultVenueBase.reply_markup, - google_place_id=TestInlineQueryResultVenueBase.google_place_id, - google_place_type=TestInlineQueryResultVenueBase.google_place_type, - thumbnail_url=TestInlineQueryResultVenueBase.thumbnail_url, - thumb_url="some other url", - thumbnail_height=TestInlineQueryResultVenueBase.thumbnail_height, - thumbnail_width=TestInlineQueryResultVenueBase.thumbnail_width, - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_height(self): - with pytest.raises( - ValueError, match="different entities as 'thumb_height' and 'thumbnail_height'" - ): - InlineQueryResultVenue( - TestInlineQueryResultVenueBase.id_, - TestInlineQueryResultVenueBase.latitude, - TestInlineQueryResultVenueBase.longitude, - TestInlineQueryResultVenueBase.title, - TestInlineQueryResultVenueBase.address, - foursquare_id=TestInlineQueryResultVenueBase.foursquare_id, - foursquare_type=TestInlineQueryResultVenueBase.foursquare_type, - input_message_content=TestInlineQueryResultVenueBase.input_message_content, - reply_markup=TestInlineQueryResultVenueBase.reply_markup, - google_place_id=TestInlineQueryResultVenueBase.google_place_id, - google_place_type=TestInlineQueryResultVenueBase.google_place_type, - thumbnail_url=TestInlineQueryResultVenueBase.thumbnail_url, - thumbnail_height=TestInlineQueryResultVenueBase.thumbnail_height, - thumb_height=TestInlineQueryResultVenueBase.thumbnail_height + 1, - thumbnail_width=TestInlineQueryResultVenueBase.thumbnail_width, - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_width(self): - with pytest.raises( - ValueError, match="different entities as 'thumb_width' and 'thumbnail_width'" - ): - InlineQueryResultVenue( - TestInlineQueryResultVenueBase.id_, - TestInlineQueryResultVenueBase.latitude, - TestInlineQueryResultVenueBase.longitude, - TestInlineQueryResultVenueBase.title, - TestInlineQueryResultVenueBase.address, - foursquare_id=TestInlineQueryResultVenueBase.foursquare_id, - foursquare_type=TestInlineQueryResultVenueBase.foursquare_type, - input_message_content=TestInlineQueryResultVenueBase.input_message_content, - reply_markup=TestInlineQueryResultVenueBase.reply_markup, - google_place_id=TestInlineQueryResultVenueBase.google_place_id, - google_place_type=TestInlineQueryResultVenueBase.google_place_type, - thumbnail_url=TestInlineQueryResultVenueBase.thumbnail_url, - thumbnail_height=TestInlineQueryResultVenueBase.thumbnail_height, - thumbnail_width=TestInlineQueryResultVenueBase.thumbnail_width, - thumb_width=TestInlineQueryResultVenueBase.thumbnail_width + 1, - ) - def test_to_dict(self, inline_query_result_venue): inline_query_result_venue_dict = inline_query_result_venue.to_dict() diff --git a/tests/_inline/test_inlinequeryresultvideo.py b/tests/_inline/test_inlinequeryresultvideo.py index e34eb348c46..9d918f91626 100644 --- a/tests/_inline/test_inlinequeryresultvideo.py +++ b/tests/_inline/test_inlinequeryresultvideo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,6 +16,8 @@ # # 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 as dtm + import pytest from telegram import ( @@ -26,38 +28,39 @@ InputTextMessageContent, MessageEntity, ) -from tests.auxil.deprecations import check_thumb_deprecation_warnings_for_args_and_attrs +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def inline_query_result_video(): return InlineQueryResultVideo( - TestInlineQueryResultVideoBase.id_, - TestInlineQueryResultVideoBase.video_url, - TestInlineQueryResultVideoBase.mime_type, - TestInlineQueryResultVideoBase.thumbnail_url, - TestInlineQueryResultVideoBase.title, - video_width=TestInlineQueryResultVideoBase.video_width, - video_height=TestInlineQueryResultVideoBase.video_height, - video_duration=TestInlineQueryResultVideoBase.video_duration, - caption=TestInlineQueryResultVideoBase.caption, - parse_mode=TestInlineQueryResultVideoBase.parse_mode, - caption_entities=TestInlineQueryResultVideoBase.caption_entities, - description=TestInlineQueryResultVideoBase.description, - input_message_content=TestInlineQueryResultVideoBase.input_message_content, - reply_markup=TestInlineQueryResultVideoBase.reply_markup, + InlineQueryResultVideoTestBase.id_, + InlineQueryResultVideoTestBase.video_url, + InlineQueryResultVideoTestBase.mime_type, + InlineQueryResultVideoTestBase.thumbnail_url, + InlineQueryResultVideoTestBase.title, + video_width=InlineQueryResultVideoTestBase.video_width, + video_height=InlineQueryResultVideoTestBase.video_height, + video_duration=InlineQueryResultVideoTestBase.video_duration, + caption=InlineQueryResultVideoTestBase.caption, + parse_mode=InlineQueryResultVideoTestBase.parse_mode, + caption_entities=InlineQueryResultVideoTestBase.caption_entities, + description=InlineQueryResultVideoTestBase.description, + input_message_content=InlineQueryResultVideoTestBase.input_message_content, + reply_markup=InlineQueryResultVideoTestBase.reply_markup, + show_caption_above_media=InlineQueryResultVideoTestBase.show_caption_above_media, ) -class TestInlineQueryResultVideoBase: +class InlineQueryResultVideoTestBase: id_ = "id" type_ = "video" video_url = "video url" mime_type = "mime type" video_width = 10 video_height = 15 - video_duration = 15 + video_duration = dtm.timedelta(seconds=15) thumbnail_url = "thumbnail url" title = "title" caption = "caption" @@ -66,9 +69,10 @@ class TestInlineQueryResultVideoBase: description = "description" input_message_content = InputTextMessageContent("input_message_content") reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) + show_caption_above_media = True -class TestInlineQueryResultVideoWithoutRequest(TestInlineQueryResultVideoBase): +class TestInlineQueryResultVideoWithoutRequest(InlineQueryResultVideoTestBase): def test_slot_behaviour(self, inline_query_result_video): inst = inline_query_result_video for attr in inst.__slots__: @@ -82,7 +86,7 @@ def test_expected_values(self, inline_query_result_video): assert inline_query_result_video.mime_type == self.mime_type assert inline_query_result_video.video_width == self.video_width assert inline_query_result_video.video_height == self.video_height - assert inline_query_result_video.video_duration == self.video_duration + assert inline_query_result_video._video_duration == self.video_duration assert inline_query_result_video.thumbnail_url == self.thumbnail_url assert inline_query_result_video.title == self.title assert inline_query_result_video.description == self.description @@ -94,6 +98,7 @@ def test_expected_values(self, inline_query_result_video): == self.input_message_content.to_dict() ) assert inline_query_result_video.reply_markup.to_dict() == self.reply_markup.to_dict() + assert inline_query_result_video.show_caption_above_media == self.show_caption_above_media def test_caption_entities_always_tuple(self): video = InlineQueryResultVideo( @@ -101,145 +106,6 @@ def test_caption_entities_always_tuple(self): ) assert video.caption_entities == () - def test_thumb_url_property_deprecation_warning(self, recwarn): - inline_query_result_video = InlineQueryResultVideo( - TestInlineQueryResultVideoBase.id_, - TestInlineQueryResultVideoBase.video_url, - TestInlineQueryResultVideoBase.mime_type, - TestInlineQueryResultVideoBase.thumbnail_url, - TestInlineQueryResultVideoBase.title, - video_width=TestInlineQueryResultVideoBase.video_width, - video_height=TestInlineQueryResultVideoBase.video_height, - video_duration=TestInlineQueryResultVideoBase.video_duration, - caption=TestInlineQueryResultVideoBase.caption, - parse_mode=TestInlineQueryResultVideoBase.parse_mode, - caption_entities=TestInlineQueryResultVideoBase.caption_entities, - description=TestInlineQueryResultVideoBase.description, - input_message_content=TestInlineQueryResultVideoBase.input_message_content, - reply_markup=TestInlineQueryResultVideoBase.reply_markup, - thumb_url=TestInlineQueryResultVideoBase.thumbnail_url, # deprecated arg - ) - assert inline_query_result_video.thumb_url == inline_query_result_video.thumbnail_url - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, - __file__, - deprecated_name="thumb_url", - new_name="thumbnail_url", - ) - - def test_thumb_url_issues_warning_and_works_without_positional_arg(self, recwarn): - inline_query_result_video = InlineQueryResultVideo( - TestInlineQueryResultVideoBase.id_, - TestInlineQueryResultVideoBase.video_url, - TestInlineQueryResultVideoBase.mime_type, - # Positional argument thumbnail_url should be here, but it's not. Code works fine. - # If user deletes thumb_url from positional arguments and replaces it with a keyword - # argument while keeping `title` as a positional argument, the code will break. - # But it should break, given the fact that the user now passes fewer positional - # arguments than they are expected to. - title=TestInlineQueryResultVideoBase.title, - video_width=TestInlineQueryResultVideoBase.video_width, - video_height=TestInlineQueryResultVideoBase.video_height, - video_duration=TestInlineQueryResultVideoBase.video_duration, - caption=TestInlineQueryResultVideoBase.caption, - parse_mode=TestInlineQueryResultVideoBase.parse_mode, - caption_entities=TestInlineQueryResultVideoBase.caption_entities, - description=TestInlineQueryResultVideoBase.description, - input_message_content=TestInlineQueryResultVideoBase.input_message_content, - reply_markup=TestInlineQueryResultVideoBase.reply_markup, - thumb_url=TestInlineQueryResultVideoBase.thumbnail_url, # deprecated arg - ) - assert inline_query_result_video.thumb_url == inline_query_result_video.thumbnail_url - check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn, - __file__, - deprecated_name="thumb_url", - new_name="thumbnail_url", - ) - - def test_init_throws_error_without_thumbnail_url_and_thumb_url(self): - with pytest.raises(ValueError, match="You must pass either"): - InlineQueryResultVideo( - TestInlineQueryResultVideoBase.id_, - TestInlineQueryResultVideoBase.video_url, - TestInlineQueryResultVideoBase.mime_type, - # no thumbnail_url, no thumb_url - # see note in previous test on `title` being keyword argument here - title=TestInlineQueryResultVideoBase.title, - video_width=TestInlineQueryResultVideoBase.video_width, - video_height=TestInlineQueryResultVideoBase.video_height, - video_duration=TestInlineQueryResultVideoBase.video_duration, - caption=TestInlineQueryResultVideoBase.caption, - parse_mode=TestInlineQueryResultVideoBase.parse_mode, - caption_entities=TestInlineQueryResultVideoBase.caption_entities, - description=TestInlineQueryResultVideoBase.description, - input_message_content=TestInlineQueryResultVideoBase.input_message_content, - reply_markup=TestInlineQueryResultVideoBase.reply_markup, - ) - - def test_throws_type_error_with_title_not_passed_or_is_none(self): - # this test is needed because we had to make argument title optional in declaration of - # __init__() while it is not optional. This had to be done to deal with renaming of - # thumb_url. Hence, we have to enforce `title` being required by checking it. - with pytest.raises(TypeError, match="missing a required argument"): - InlineQueryResultVideo( - TestInlineQueryResultVideoBase.id_, - TestInlineQueryResultVideoBase.video_url, - TestInlineQueryResultVideoBase.mime_type, - TestInlineQueryResultVideoBase.thumbnail_url, - # title is missing - video_width=TestInlineQueryResultVideoBase.video_width, - video_height=TestInlineQueryResultVideoBase.video_height, - video_duration=TestInlineQueryResultVideoBase.video_duration, - caption=TestInlineQueryResultVideoBase.caption, - parse_mode=TestInlineQueryResultVideoBase.parse_mode, - caption_entities=TestInlineQueryResultVideoBase.caption_entities, - description=TestInlineQueryResultVideoBase.description, - input_message_content=TestInlineQueryResultVideoBase.input_message_content, - reply_markup=TestInlineQueryResultVideoBase.reply_markup, - ) - - with pytest.raises(TypeError, match="missing a required argument"): - InlineQueryResultVideo( - TestInlineQueryResultVideoBase.id_, - TestInlineQueryResultVideoBase.video_url, - TestInlineQueryResultVideoBase.mime_type, - TestInlineQueryResultVideoBase.thumbnail_url, - title=None, # the declaration of __init__ allows it, but we don't. - video_width=TestInlineQueryResultVideoBase.video_width, - video_height=TestInlineQueryResultVideoBase.video_height, - video_duration=TestInlineQueryResultVideoBase.video_duration, - caption=TestInlineQueryResultVideoBase.caption, - parse_mode=TestInlineQueryResultVideoBase.parse_mode, - caption_entities=TestInlineQueryResultVideoBase.caption_entities, - description=TestInlineQueryResultVideoBase.description, - input_message_content=TestInlineQueryResultVideoBase.input_message_content, - reply_markup=TestInlineQueryResultVideoBase.reply_markup, - ) - - def test_throws_value_error_with_different_deprecated_and_new_arg_thumb_url(self): - with pytest.raises( - ValueError, - match="different entities as 'thumb_url' and 'thumbnail_url'", - ): - InlineQueryResultVideo( - TestInlineQueryResultVideoBase.id_, - TestInlineQueryResultVideoBase.video_url, - TestInlineQueryResultVideoBase.mime_type, - TestInlineQueryResultVideoBase.thumbnail_url, - TestInlineQueryResultVideoBase.title, - video_width=TestInlineQueryResultVideoBase.video_width, - video_height=TestInlineQueryResultVideoBase.video_height, - video_duration=TestInlineQueryResultVideoBase.video_duration, - caption=TestInlineQueryResultVideoBase.caption, - parse_mode=TestInlineQueryResultVideoBase.parse_mode, - caption_entities=TestInlineQueryResultVideoBase.caption_entities, - description=TestInlineQueryResultVideoBase.description, - input_message_content=TestInlineQueryResultVideoBase.input_message_content, - reply_markup=TestInlineQueryResultVideoBase.reply_markup, - thumb_url="some other url", - ) - def test_to_dict(self, inline_query_result_video): inline_query_result_video_dict = inline_query_result_video.to_dict() @@ -255,10 +121,10 @@ def test_to_dict(self, inline_query_result_video): inline_query_result_video_dict["video_height"] == inline_query_result_video.video_height ) - assert ( - inline_query_result_video_dict["video_duration"] - == inline_query_result_video.video_duration + assert inline_query_result_video_dict["video_duration"] == int( + self.video_duration.total_seconds() ) + assert isinstance(inline_query_result_video_dict["video_duration"], int) assert ( inline_query_result_video_dict["thumbnail_url"] == inline_query_result_video.thumbnail_url @@ -280,6 +146,33 @@ def test_to_dict(self, inline_query_result_video): inline_query_result_video_dict["reply_markup"] == inline_query_result_video.reply_markup.to_dict() ) + assert ( + inline_query_result_video_dict["show_caption_above_media"] + == inline_query_result_video.show_caption_above_media + ) + + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_video): + iqrv = inline_query_result_video + if PTB_TIMEDELTA: + assert iqrv.video_duration == self.video_duration + assert isinstance(iqrv.video_duration, dtm.timedelta) + else: + assert iqrv.video_duration == int(self.video_duration.total_seconds()) + assert isinstance(iqrv.video_duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, inline_query_result_video): + value = inline_query_result_video.video_duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + assert isinstance(value, dtm.timedelta) + else: + assert len(recwarn) == 1 + assert "`video_duration` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) + assert recwarn[0].category is PTBDeprecationWarning + assert isinstance(value, int) def test_equality(self): a = InlineQueryResultVideo( diff --git a/tests/_inline/test_inlinequeryresultvoice.py b/tests/_inline/test_inlinequeryresultvoice.py index cbfb7e9f01d..ccc9db85494 100644 --- a/tests/_inline/test_inlinequeryresultvoice.py +++ b/tests/_inline/test_inlinequeryresultvoice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,6 +16,8 @@ # # 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 as dtm + import pytest from telegram import ( @@ -26,30 +28,31 @@ InputTextMessageContent, MessageEntity, ) +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def inline_query_result_voice(): return InlineQueryResultVoice( - id=TestInlineQueryResultVoiceBase.id_, - voice_url=TestInlineQueryResultVoiceBase.voice_url, - title=TestInlineQueryResultVoiceBase.title, - voice_duration=TestInlineQueryResultVoiceBase.voice_duration, - caption=TestInlineQueryResultVoiceBase.caption, - parse_mode=TestInlineQueryResultVoiceBase.parse_mode, - caption_entities=TestInlineQueryResultVoiceBase.caption_entities, - input_message_content=TestInlineQueryResultVoiceBase.input_message_content, - reply_markup=TestInlineQueryResultVoiceBase.reply_markup, + id=InlineQueryResultVoiceTestBase.id_, + voice_url=InlineQueryResultVoiceTestBase.voice_url, + title=InlineQueryResultVoiceTestBase.title, + voice_duration=InlineQueryResultVoiceTestBase.voice_duration, + caption=InlineQueryResultVoiceTestBase.caption, + parse_mode=InlineQueryResultVoiceTestBase.parse_mode, + caption_entities=InlineQueryResultVoiceTestBase.caption_entities, + input_message_content=InlineQueryResultVoiceTestBase.input_message_content, + reply_markup=InlineQueryResultVoiceTestBase.reply_markup, ) -class TestInlineQueryResultVoiceBase: +class InlineQueryResultVoiceTestBase: id_ = "id" type_ = "voice" voice_url = "voice url" title = "title" - voice_duration = "voice_duration" + voice_duration = dtm.timedelta(seconds=10) caption = "caption" parse_mode = "HTML" caption_entities = [MessageEntity(MessageEntity.ITALIC, 0, 7)] @@ -57,7 +60,7 @@ class TestInlineQueryResultVoiceBase: reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("reply_markup")]]) -class TestInlineQueryResultVoiceWithoutRequest(TestInlineQueryResultVoiceBase): +class TestInlineQueryResultVoiceWithoutRequest(InlineQueryResultVoiceTestBase): def test_slot_behaviour(self, inline_query_result_voice): inst = inline_query_result_voice for attr in inst.__slots__: @@ -69,7 +72,7 @@ def test_expected_values(self, inline_query_result_voice): assert inline_query_result_voice.id == self.id_ assert inline_query_result_voice.voice_url == self.voice_url assert inline_query_result_voice.title == self.title - assert inline_query_result_voice.voice_duration == self.voice_duration + assert inline_query_result_voice._voice_duration == self.voice_duration assert inline_query_result_voice.caption == self.caption assert inline_query_result_voice.parse_mode == self.parse_mode assert inline_query_result_voice.caption_entities == tuple(self.caption_entities) @@ -96,10 +99,10 @@ def test_to_dict(self, inline_query_result_voice): assert inline_query_result_voice_dict["id"] == inline_query_result_voice.id assert inline_query_result_voice_dict["voice_url"] == inline_query_result_voice.voice_url assert inline_query_result_voice_dict["title"] == inline_query_result_voice.title - assert ( - inline_query_result_voice_dict["voice_duration"] - == inline_query_result_voice.voice_duration + assert inline_query_result_voice_dict["voice_duration"] == int( + self.voice_duration.total_seconds() ) + assert isinstance(inline_query_result_voice_dict["voice_duration"], int) assert inline_query_result_voice_dict["caption"] == inline_query_result_voice.caption assert inline_query_result_voice_dict["parse_mode"] == inline_query_result_voice.parse_mode assert inline_query_result_voice_dict["caption_entities"] == [ @@ -114,6 +117,28 @@ def test_to_dict(self, inline_query_result_voice): == inline_query_result_voice.reply_markup.to_dict() ) + def test_time_period_properties(self, PTB_TIMEDELTA, inline_query_result_voice): + voice_duration = inline_query_result_voice.voice_duration + + if PTB_TIMEDELTA: + assert voice_duration == self.voice_duration + assert isinstance(voice_duration, dtm.timedelta) + else: + assert voice_duration == int(self.voice_duration.total_seconds()) + assert isinstance(voice_duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, inline_query_result_voice): + inline_query_result_voice.voice_duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`voice_duration` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = InlineQueryResultVoice(self.id_, self.voice_url, self.title) b = InlineQueryResultVoice(self.id_, self.voice_url, self.title) diff --git a/tests/_inline/test_inputcontactmessagecontent.py b/tests/_inline/test_inputcontactmessagecontent.py index 0f5d8d11d61..e70b87c4d07 100644 --- a/tests/_inline/test_inputcontactmessagecontent.py +++ b/tests/_inline/test_inputcontactmessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,19 +25,19 @@ @pytest.fixture(scope="module") def input_contact_message_content(): return InputContactMessageContent( - TestInputContactMessageContentBase.phone_number, - TestInputContactMessageContentBase.first_name, - TestInputContactMessageContentBase.last_name, + InputContactMessageContentTestBase.phone_number, + InputContactMessageContentTestBase.first_name, + InputContactMessageContentTestBase.last_name, ) -class TestInputContactMessageContentBase: +class InputContactMessageContentTestBase: phone_number = "phone number" first_name = "first name" last_name = "last name" -class TestInputContactMessageContentWithoutRequest(TestInputContactMessageContentBase): +class TestInputContactMessageContentWithoutRequest(InputContactMessageContentTestBase): def test_slot_behaviour(self, input_contact_message_content): inst = input_contact_message_content for attr in inst.__slots__: diff --git a/tests/_inline/test_inputinvoicemessagecontent.py b/tests/_inline/test_inputinvoicemessagecontent.py index c399c49aa77..4e0dc9acae3 100644 --- a/tests/_inline/test_inputinvoicemessagecontent.py +++ b/tests/_inline/test_inputinvoicemessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -26,30 +26,32 @@ @pytest.fixture(scope="module") def input_invoice_message_content(): return InputInvoiceMessageContent( - title=TestInputInvoiceMessageContentBase.title, - description=TestInputInvoiceMessageContentBase.description, - payload=TestInputInvoiceMessageContentBase.payload, - provider_token=TestInputInvoiceMessageContentBase.provider_token, - currency=TestInputInvoiceMessageContentBase.currency, - prices=TestInputInvoiceMessageContentBase.prices, - max_tip_amount=TestInputInvoiceMessageContentBase.max_tip_amount, - suggested_tip_amounts=TestInputInvoiceMessageContentBase.suggested_tip_amounts, - provider_data=TestInputInvoiceMessageContentBase.provider_data, - photo_url=TestInputInvoiceMessageContentBase.photo_url, - photo_size=TestInputInvoiceMessageContentBase.photo_size, - photo_width=TestInputInvoiceMessageContentBase.photo_width, - photo_height=TestInputInvoiceMessageContentBase.photo_height, - need_name=TestInputInvoiceMessageContentBase.need_name, - need_phone_number=TestInputInvoiceMessageContentBase.need_phone_number, - need_email=TestInputInvoiceMessageContentBase.need_email, - need_shipping_address=TestInputInvoiceMessageContentBase.need_shipping_address, - send_phone_number_to_provider=TestInputInvoiceMessageContentBase.send_phone_number_to_provider, # noqa: E501 - send_email_to_provider=TestInputInvoiceMessageContentBase.send_email_to_provider, - is_flexible=TestInputInvoiceMessageContentBase.is_flexible, + title=InputInvoiceMessageContentTestBase.title, + description=InputInvoiceMessageContentTestBase.description, + payload=InputInvoiceMessageContentTestBase.payload, + provider_token=InputInvoiceMessageContentTestBase.provider_token, + currency=InputInvoiceMessageContentTestBase.currency, + prices=InputInvoiceMessageContentTestBase.prices, + max_tip_amount=InputInvoiceMessageContentTestBase.max_tip_amount, + suggested_tip_amounts=InputInvoiceMessageContentTestBase.suggested_tip_amounts, + provider_data=InputInvoiceMessageContentTestBase.provider_data, + photo_url=InputInvoiceMessageContentTestBase.photo_url, + photo_size=InputInvoiceMessageContentTestBase.photo_size, + photo_width=InputInvoiceMessageContentTestBase.photo_width, + photo_height=InputInvoiceMessageContentTestBase.photo_height, + need_name=InputInvoiceMessageContentTestBase.need_name, + need_phone_number=InputInvoiceMessageContentTestBase.need_phone_number, + need_email=InputInvoiceMessageContentTestBase.need_email, + need_shipping_address=InputInvoiceMessageContentTestBase.need_shipping_address, + send_phone_number_to_provider=( + InputInvoiceMessageContentTestBase.send_phone_number_to_provider + ), + send_email_to_provider=InputInvoiceMessageContentTestBase.send_email_to_provider, + is_flexible=InputInvoiceMessageContentTestBase.is_flexible, ) -class TestInputInvoiceMessageContentBase: +class InputInvoiceMessageContentTestBase: title = "invoice title" description = "invoice description" payload = "invoice payload" @@ -72,7 +74,7 @@ class TestInputInvoiceMessageContentBase: is_flexible = True -class TestInputInvoiceMessageContentWithoutRequest(TestInputInvoiceMessageContentBase): +class TestInputInvoiceMessageContentWithoutRequest(InputInvoiceMessageContentTestBase): def test_slot_behaviour(self, input_invoice_message_content): inst = input_invoice_message_content for attr in inst.__slots__: @@ -201,9 +203,7 @@ def test_to_dict(self, input_invoice_message_content): == input_invoice_message_content.is_flexible ) - def test_de_json(self, bot): - assert InputInvoiceMessageContent.de_json({}, bot=bot) is None - + def test_de_json(self, offline_bot): json_dict = { "title": self.title, "description": self.description, @@ -227,7 +227,9 @@ def test_de_json(self, bot): "is_flexible": self.is_flexible, } - input_invoice_message_content = InputInvoiceMessageContent.de_json(json_dict, bot=bot) + input_invoice_message_content = InputInvoiceMessageContent.de_json( + json_dict, bot=offline_bot + ) assert input_invoice_message_content.api_kwargs == {} assert input_invoice_message_content.title == self.title @@ -279,10 +281,10 @@ def test_equality(self): self.title, self.description, self.payload, - self.provider_token, self.currency, # the first prices amount & the second lebal changed [LabeledPrice("label1", 24), LabeledPrice("label22", 314)], + self.provider_token, ) d = InputInvoiceMessageContent( self.title, diff --git a/tests/_inline/test_inputlocationmessagecontent.py b/tests/_inline/test_inputlocationmessagecontent.py index 0bfb1f4cffb..f5cc16c1386 100644 --- a/tests/_inline/test_inputlocationmessagecontent.py +++ b/tests/_inline/test_inputlocationmessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,34 +16,37 @@ # # 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 as dtm + import pytest from telegram import InputLocationMessageContent, Location +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def input_location_message_content(): return InputLocationMessageContent( - TestInputLocationMessageContentBase.latitude, - TestInputLocationMessageContentBase.longitude, - live_period=TestInputLocationMessageContentBase.live_period, - horizontal_accuracy=TestInputLocationMessageContentBase.horizontal_accuracy, - heading=TestInputLocationMessageContentBase.heading, - proximity_alert_radius=TestInputLocationMessageContentBase.proximity_alert_radius, + InputLocationMessageContentTestBase.latitude, + InputLocationMessageContentTestBase.longitude, + live_period=InputLocationMessageContentTestBase.live_period, + horizontal_accuracy=InputLocationMessageContentTestBase.horizontal_accuracy, + heading=InputLocationMessageContentTestBase.heading, + proximity_alert_radius=InputLocationMessageContentTestBase.proximity_alert_radius, ) -class TestInputLocationMessageContentBase: +class InputLocationMessageContentTestBase: latitude = -23.691288 longitude = -46.788279 - live_period = 80 + live_period = dtm.timedelta(seconds=80) horizontal_accuracy = 50.5 heading = 90 proximity_alert_radius = 999 -class TestInputLocationMessageContentWithoutRequest(TestInputLocationMessageContentBase): +class TestInputLocationMessageContentWithoutRequest(InputLocationMessageContentTestBase): def test_slot_behaviour(self, input_location_message_content): inst = input_location_message_content for attr in inst.__slots__: @@ -53,7 +56,7 @@ def test_slot_behaviour(self, input_location_message_content): def test_expected_values(self, input_location_message_content): assert input_location_message_content.longitude == self.longitude assert input_location_message_content.latitude == self.latitude - assert input_location_message_content.live_period == self.live_period + assert input_location_message_content._live_period == self.live_period assert input_location_message_content.horizontal_accuracy == self.horizontal_accuracy assert input_location_message_content.heading == self.heading assert input_location_message_content.proximity_alert_radius == self.proximity_alert_radius @@ -70,10 +73,10 @@ def test_to_dict(self, input_location_message_content): input_location_message_content_dict["longitude"] == input_location_message_content.longitude ) - assert ( - input_location_message_content_dict["live_period"] - == input_location_message_content.live_period + assert input_location_message_content_dict["live_period"] == int( + self.live_period.total_seconds() ) + assert isinstance(input_location_message_content_dict["live_period"], int) assert ( input_location_message_content_dict["horizontal_accuracy"] == input_location_message_content.horizontal_accuracy @@ -87,6 +90,28 @@ def test_to_dict(self, input_location_message_content): == input_location_message_content.proximity_alert_radius ) + def test_time_period_properties(self, PTB_TIMEDELTA, input_location_message_content): + live_period = input_location_message_content.live_period + + if PTB_TIMEDELTA: + assert live_period == self.live_period + assert isinstance(live_period, dtm.timedelta) + else: + assert live_period == int(self.live_period.total_seconds()) + assert isinstance(live_period, int) + + def test_time_period_int_deprecated( + self, recwarn, PTB_TIMEDELTA, input_location_message_content + ): + input_location_message_content.live_period + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`live_period` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + def test_equality(self): a = InputLocationMessageContent(123, 456, 70) b = InputLocationMessageContent(123, 456, 90) diff --git a/tests/_inline/test_inputtextmessagecontent.py b/tests/_inline/test_inputtextmessagecontent.py index e842461bb0e..252fbda7446 100644 --- a/tests/_inline/test_inputtextmessagecontent.py +++ b/tests/_inline/test_inputtextmessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,7 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. import pytest -from telegram import InputTextMessageContent, MessageEntity +from telegram import InputTextMessageContent, LinkPreviewOptions, MessageEntity from telegram.constants import ParseMode from tests.auxil.slots import mro_slots @@ -26,21 +26,22 @@ @pytest.fixture(scope="module") def input_text_message_content(): return InputTextMessageContent( - TestInputTextMessageContentBase.message_text, - parse_mode=TestInputTextMessageContentBase.parse_mode, - entities=TestInputTextMessageContentBase.entities, - disable_web_page_preview=TestInputTextMessageContentBase.disable_web_page_preview, + InputTextMessageContentTestBase.message_text, + parse_mode=InputTextMessageContentTestBase.parse_mode, + entities=InputTextMessageContentTestBase.entities, + link_preview_options=InputTextMessageContentTestBase.link_preview_options, ) -class TestInputTextMessageContentBase: +class InputTextMessageContentTestBase: message_text = "*message text*" parse_mode = ParseMode.MARKDOWN entities = [MessageEntity(MessageEntity.ITALIC, 0, 7)] - disable_web_page_preview = True + disable_web_page_preview = False + link_preview_options = LinkPreviewOptions(False, url="https://python-telegram-bot.org") -class TestInputTextMessageContentWithoutRequest(TestInputTextMessageContentBase): +class TestInputTextMessageContentWithoutRequest(InputTextMessageContentTestBase): def test_slot_behaviour(self, input_text_message_content): inst = input_text_message_content for attr in inst.__slots__: @@ -50,8 +51,8 @@ def test_slot_behaviour(self, input_text_message_content): def test_expected_values(self, input_text_message_content): assert input_text_message_content.parse_mode == self.parse_mode assert input_text_message_content.message_text == self.message_text - assert input_text_message_content.disable_web_page_preview == self.disable_web_page_preview assert input_text_message_content.entities == tuple(self.entities) + assert input_text_message_content.link_preview_options == self.link_preview_options def test_entities_always_tuple(self): input_text_message_content = InputTextMessageContent("text") @@ -72,8 +73,8 @@ def test_to_dict(self, input_text_message_content): ce.to_dict() for ce in input_text_message_content.entities ] assert ( - input_text_message_content_dict["disable_web_page_preview"] - == input_text_message_content.disable_web_page_preview + input_text_message_content_dict["link_preview_options"] + == input_text_message_content.link_preview_options.to_dict() ) def test_equality(self): @@ -90,3 +91,13 @@ def test_equality(self): assert a != d assert hash(a) != hash(d) + + def test_mutually_exclusive(self): + with pytest.raises(ValueError, match="`link_preview_options` are mutually exclusive"): + InputTextMessageContent( + "text", disable_web_page_preview=True, link_preview_options=LinkPreviewOptions() + ) + + def test_disable_web_page_preview_deprecation(self): + itmc = InputTextMessageContent("text", disable_web_page_preview=True) + assert itmc.link_preview_options.is_disabled is True diff --git a/tests/_inline/test_inputvenuemessagecontent.py b/tests/_inline/test_inputvenuemessagecontent.py index f54ac4cb0d2..b55eb23e8af 100644 --- a/tests/_inline/test_inputvenuemessagecontent.py +++ b/tests/_inline/test_inputvenuemessagecontent.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,18 +25,18 @@ @pytest.fixture(scope="module") def input_venue_message_content(): return InputVenueMessageContent( - TestInputVenueMessageContentBase.latitude, - TestInputVenueMessageContentBase.longitude, - TestInputVenueMessageContentBase.title, - TestInputVenueMessageContentBase.address, - foursquare_id=TestInputVenueMessageContentBase.foursquare_id, - foursquare_type=TestInputVenueMessageContentBase.foursquare_type, - google_place_id=TestInputVenueMessageContentBase.google_place_id, - google_place_type=TestInputVenueMessageContentBase.google_place_type, + InputVenueMessageContentTestBase.latitude, + InputVenueMessageContentTestBase.longitude, + InputVenueMessageContentTestBase.title, + InputVenueMessageContentTestBase.address, + foursquare_id=InputVenueMessageContentTestBase.foursquare_id, + foursquare_type=InputVenueMessageContentTestBase.foursquare_type, + google_place_id=InputVenueMessageContentTestBase.google_place_id, + google_place_type=InputVenueMessageContentTestBase.google_place_type, ) -class TestInputVenueMessageContentBase: +class InputVenueMessageContentTestBase: latitude = 1.0 longitude = 2.0 title = "title" @@ -47,7 +47,7 @@ class TestInputVenueMessageContentBase: google_place_type = "google place type" -class TestInputVenueMessageContentWithoutRequest(TestInputVenueMessageContentBase): +class TestInputVenueMessageContentWithoutRequest(InputVenueMessageContentTestBase): def test_slot_behaviour(self, input_venue_message_content): inst = input_venue_message_content for attr in inst.__slots__: diff --git a/tests/_inline/test_preparedinlinemessage.py b/tests/_inline/test_preparedinlinemessage.py new file mode 100644 index 00000000000..c9f3deeb320 --- /dev/null +++ b/tests/_inline/test_preparedinlinemessage.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm + +import pytest + +from telegram import Location, PreparedInlineMessage +from telegram._utils.datetime import UTC, to_timestamp +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def prepared_inline_message(): + return PreparedInlineMessage( + PreparedInlineMessageTestBase.id, + PreparedInlineMessageTestBase.expiration_date, + ) + + +class PreparedInlineMessageTestBase: + id = "some_uid" + expiration_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + + +class TestPreparedInlineMessageWithoutRequest(PreparedInlineMessageTestBase): + def test_slot_behaviour(self, prepared_inline_message): + inst = prepared_inline_message + 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, prepared_inline_message): + assert prepared_inline_message.id == self.id + assert prepared_inline_message.expiration_date == self.expiration_date + + def test_de_json(self, prepared_inline_message): + json_dict = { + "id": self.id, + "expiration_date": to_timestamp(self.expiration_date), + } + new_prepared_inline_message = PreparedInlineMessage.de_json(json_dict, None) + + assert isinstance(new_prepared_inline_message, PreparedInlineMessage) + assert new_prepared_inline_message.id == prepared_inline_message.id + assert ( + new_prepared_inline_message.expiration_date == prepared_inline_message.expiration_date + ) + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): + json_dict = { + "id": "some_uid", + "expiration_date": to_timestamp(self.expiration_date), + } + pim = PreparedInlineMessage.de_json(json_dict, offline_bot) + pim_raw = PreparedInlineMessage.de_json(json_dict, raw_bot) + pim_tz = PreparedInlineMessage.de_json(json_dict, tz_bot) + + # comparing utcoffset because comparing tzinfo objects is not reliable + offset = pim_tz.expiration_date.utcoffset() + offset_tz = tz_bot.defaults.tzinfo.utcoffset(pim_tz.expiration_date.replace(tzinfo=None)) + + assert pim.expiration_date.tzinfo == UTC + assert pim_raw.expiration_date.tzinfo == UTC + assert offset_tz == offset + + def test_to_dict(self, prepared_inline_message): + prepared_inline_message_dict = prepared_inline_message.to_dict() + + assert isinstance(prepared_inline_message_dict, dict) + assert prepared_inline_message_dict["id"] == prepared_inline_message.id + assert prepared_inline_message_dict["expiration_date"] == to_timestamp( + self.expiration_date + ) + + def test_equality(self, prepared_inline_message): + a = prepared_inline_message + b = PreparedInlineMessage(self.id, self.expiration_date) + c = PreparedInlineMessage(self.id, self.expiration_date + dtm.timedelta(seconds=1)) + d = PreparedInlineMessage("other_uid", self.expiration_date) + e = Location(123, 456) + + 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) diff --git a/tests/_passport/__init__.py b/tests/_passport/__init__.py index 1eaba12c869..c95cb3c9741 100644 --- a/tests/_passport/__init__.py +++ b/tests/_passport/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_passport/test_encryptedcredentials.py b/tests/_passport/test_encryptedcredentials.py index e80424396a2..154f8b35fea 100644 --- a/tests/_passport/test_encryptedcredentials.py +++ b/tests/_passport/test_encryptedcredentials.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -26,19 +26,19 @@ @pytest.fixture(scope="module") def encrypted_credentials(): return EncryptedCredentials( - TestEncryptedCredentialsBase.data, - TestEncryptedCredentialsBase.hash, - TestEncryptedCredentialsBase.secret, + EncryptedCredentialsTestBase.data, + EncryptedCredentialsTestBase.hash, + EncryptedCredentialsTestBase.secret, ) -class TestEncryptedCredentialsBase: +class EncryptedCredentialsTestBase: data = "data" hash = "hash" secret = "secret" -class TestEncryptedCredentialsWithoutRequest(TestEncryptedCredentialsBase): +class TestEncryptedCredentialsWithoutRequest(EncryptedCredentialsTestBase): def test_slot_behaviour(self, encrypted_credentials): inst = encrypted_credentials for attr in inst.__slots__: diff --git a/tests/_passport/test_encryptedpassportelement.py b/tests/_passport/test_encryptedpassportelement.py index e2b05a74669..f1bab591b36 100644 --- a/tests/_passport/test_encryptedpassportelement.py +++ b/tests/_passport/test_encryptedpassportelement.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -26,19 +26,19 @@ @pytest.fixture(scope="module") def encrypted_passport_element(): return EncryptedPassportElement( - TestEncryptedPassportElementBase.type_, + EncryptedPassportElementTestBase.type_, "this is a hash", - data=TestEncryptedPassportElementBase.data, - phone_number=TestEncryptedPassportElementBase.phone_number, - email=TestEncryptedPassportElementBase.email, - files=TestEncryptedPassportElementBase.files, - front_side=TestEncryptedPassportElementBase.front_side, - reverse_side=TestEncryptedPassportElementBase.reverse_side, - selfie=TestEncryptedPassportElementBase.selfie, + data=EncryptedPassportElementTestBase.data, + phone_number=EncryptedPassportElementTestBase.phone_number, + email=EncryptedPassportElementTestBase.email, + files=EncryptedPassportElementTestBase.files, + front_side=EncryptedPassportElementTestBase.front_side, + reverse_side=EncryptedPassportElementTestBase.reverse_side, + selfie=EncryptedPassportElementTestBase.selfie, ) -class TestEncryptedPassportElementBase: +class EncryptedPassportElementTestBase: type_ = "type" hash = "this is a hash" data = "data" @@ -50,7 +50,7 @@ class TestEncryptedPassportElementBase: selfie = PassportFile("file_id", 50, 0, 25) -class TestEncryptedPassportElementWithoutRequest(TestEncryptedPassportElementBase): +class TestEncryptedPassportElementWithoutRequest(EncryptedPassportElementTestBase): def test_slot_behaviour(self, encrypted_passport_element): inst = encrypted_passport_element for attr in inst.__slots__: diff --git a/tests/_passport/test_no_passport.py b/tests/_passport/test_no_passport.py index 8fa4b092534..b8f48d27bcb 100644 --- a/tests/_passport/test_no_passport.py +++ b/tests/_passport/test_no_passport.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -26,9 +26,10 @@ with the TEST_WITH_OPT_DEPS environment variable set to False in addition to the regular test suite """ + import pytest -from telegram import _bot as bot +import telegram from telegram._passport import credentials from tests.auxil.envvars import TEST_WITH_OPT_DEPS @@ -39,7 +40,7 @@ class TestNoPassportWithoutRequest: def test_bot_init(self, bot_info): with pytest.raises(RuntimeError, match="passport"): - bot.Bot(bot_info["token"], private_key=1, private_key_password=2) + telegram.Bot(bot_info["token"], private_key=1, private_key_password=2) def test_credentials_decrypt(self): with pytest.raises(RuntimeError, match="passport"): diff --git a/tests/_passport/test_passport.py b/tests/_passport/test_passport.py index e980f0d5b0f..5b87074105c 100644 --- a/tests/_passport/test_passport.py +++ b/tests/_passport/test_passport.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # flake8: noqa: E501 # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -220,7 +220,7 @@ def passport_data(bot): return PassportData.de_json(RAW_PASSPORT_DATA, bot=bot) -class TestPassportBase: +class PassportTestBase: driver_license_selfie_file_id = "DgADBAADEQQAAkopgFNr6oi-wISRtAI" driver_license_selfie_file_unique_id = "d4e390cca57b4da5a65322b304762a12" driver_license_front_side_file_id = "DgADBAADxwMAApnQgVPK2-ckL2eXVAI" @@ -243,7 +243,7 @@ class TestPassportBase: driver_license_selfie_credentials_secret = "tivdId6RNYNsvXYPppdzrbxOBuBOr9wXRPDcCvnXU7E=" -class TestPassportWithoutRequest(TestPassportBase): +class TestPassportWithoutRequest(PassportTestBase): def test_slot_behaviour(self, passport_data): inst = passport_data for attr in inst.__slots__: @@ -390,8 +390,8 @@ def test_expected_decrypted_values(self, passport_data): assert email.type == "email" assert email.email == "fb3e3i47zt@dispostable.com" - def test_de_json_and_to_dict(self, bot): - passport_data = PassportData.de_json(RAW_PASSPORT_DATA, bot) + def test_de_json_and_to_dict(self, offline_bot): + passport_data = PassportData.de_json(RAW_PASSPORT_DATA, offline_bot) assert passport_data.api_kwargs == {} assert passport_data.to_dict() == RAW_PASSPORT_DATA @@ -414,14 +414,17 @@ def test_equality(self, passport_data): assert a != c assert hash(a) != hash(c) - def test_bot_init_invalid_key(self, bot): + def test_bot_init_invalid_key(self, offline_bot): with pytest.raises(TypeError): - Bot(bot.token, private_key="Invalid key!") + Bot(offline_bot.token, private_key="Invalid key!") - with pytest.raises(ValueError): - Bot(bot.token, private_key=b"Invalid key!") + # Different error messages for different cryptography versions + with pytest.raises( + ValueError, match=r"(Could not deserialize key data)|(Unable to load PEM file)" + ): + Bot(offline_bot.token, private_key=b"Invalid key!") - def test_all_types(self, passport_data, bot, all_passport_data): + def test_all_types(self, passport_data, offline_bot, all_passport_data): credentials = passport_data.decrypted_credentials.to_dict() # Copy credentials from other types to all types so we can decrypt everything @@ -446,43 +449,46 @@ def test_all_types(self, passport_data, bot, all_passport_data): # Replaced below "credentials": {"data": "data", "hash": "hash", "secret": "secret"}, }, - bot=bot, + bot=offline_bot, ) assert new.api_kwargs == {} - new.credentials._decrypted_data = Credentials.de_json(credentials, bot) + new.credentials._decrypted_data = Credentials.de_json(credentials, offline_bot) assert new.credentials.api_kwargs == {} assert isinstance(new, PassportData) assert new.decrypted_data - async def test_passport_data_okay_with_non_crypto_bot(self, bot): - async with make_bot(token=bot.token) as b: + async def test_passport_data_okay_with_non_crypto_bot(self, offline_bot): + async with make_bot(token=offline_bot.token) as b: assert PassportData.de_json(RAW_PASSPORT_DATA, bot=b) - def test_wrong_hash(self, bot): + def test_wrong_hash(self, offline_bot): data = deepcopy(RAW_PASSPORT_DATA) data["credentials"]["hash"] = "bm90Y29ycmVjdGhhc2g=" # Not correct hash - passport_data = PassportData.de_json(data, bot=bot) + passport_data = PassportData.de_json(data, bot=offline_bot) with pytest.raises(PassportDecryptionError): assert passport_data.decrypted_data - async def test_wrong_key(self, bot): - short_key = b"-----BEGIN RSA PRIVATE KEY-----\r\nMIIBOQIBAAJBAKU+OZ2jJm7sCA/ec4gngNZhXYPu+DZ/TAwSMl0W7vAPXAsLplBk\r\nO8l6IBHx8N0ZC4Bc65mO3b2G8YAzqndyqH8CAwEAAQJAWOx3jQFzeVXDsOaBPdAk\r\nYTncXVeIc6tlfUl9mOLyinSbRNCy1XicOiOZFgH1rRKOGIC1235QmqxFvdecySoY\r\nwQIhAOFeGgeX9CrEPuSsd9+kqUcA2avCwqdQgSdy2qggRFyJAiEAu7QHT8JQSkHU\r\nDELfzrzc24AhjyG0z1DpGZArM8COascCIDK42SboXj3Z2UXiQ0CEcMzYNiVgOisq\r\nBUd5pBi+2mPxAiAM5Z7G/Sv1HjbKrOGh29o0/sXPhtpckEuj5QMC6E0gywIgFY6S\r\nNjwrAA+cMmsgY0O2fAzEKkDc5YiFsiXaGaSS4eA=\r\n-----END RSA PRIVATE KEY-----" - async with make_bot(token=bot.token, private_key=short_key) as b: + async def test_wrong_key(self, offline_bot): + short_key = ( + b"-----BEGIN RSA PRIVATE" + b" KEY-----\r\nMIIBOQIBAAJBAKU+OZ2jJm7sCA/ec4gngNZhXYPu+DZ/TAwSMl0W7vAPXAsLplBk\r\nO8l6IBHx8N0ZC4Bc65mO3b2G8YAzqndyqH8CAwEAAQJAWOx3jQFzeVXDsOaBPdAk\r\nYTncXVeIc6tlfUl9mOLyinSbRNCy1XicOiOZFgH1rRKOGIC1235QmqxFvdecySoY\r\nwQIhAOFeGgeX9CrEPuSsd9+kqUcA2avCwqdQgSdy2qggRFyJAiEAu7QHT8JQSkHU\r\nDELfzrzc24AhjyG0z1DpGZArM8COascCIDK42SboXj3Z2UXiQ0CEcMzYNiVgOisq\r\nBUd5pBi+2mPxAiAM5Z7G/Sv1HjbKrOGh29o0/sXPhtpckEuj5QMC6E0gywIgFY6S\r\nNjwrAA+cMmsgY0O2fAzEKkDc5YiFsiXaGaSS4eA=\r\n-----END" + b" RSA PRIVATE KEY-----" + ) + async with make_bot(token=offline_bot.token, private_key=short_key) as b: passport_data = PassportData.de_json(RAW_PASSPORT_DATA, bot=b) with pytest.raises(PassportDecryptionError): assert passport_data.decrypted_data - wrong_key = b"-----BEGIN RSA PRIVATE KEY-----\r\nMIIEogIBAAKCAQB4qCFltuvHakZze86TUweU7E/SB3VLGEHAe7GJlBmrou9SSWsL\r\nH7E++157X6UqWFl54LOE9MeHZnoW7rZ+DxLKhk6NwAHTxXPnvw4CZlvUPC3OFxg3\r\nhEmNen6ojSM4sl4kYUIa7F+Q5uMEYaboxoBen9mbj4zzMGsG4aY/xBOb2ewrXQyL\r\nRh//tk1Px4ago+lUPisAvQVecz7/6KU4Xj4Lpv2z20f3cHlZX6bb7HlE1vixCMOf\r\nxvfC5SkWEGZMR/ZoWQUsoDkrDSITF/S3GtLfg083TgtCKaOF3mCT27sJ1og77npP\r\n0cH/qdlbdoFtdrRj3PvBpaj/TtXRhmdGcJBxAgMBAAECggEAYSq1Sp6XHo8dkV8B\r\nK2/QSURNu8y5zvIH8aUrgqo8Shb7OH9bryekrB3vJtgNwR5JYHdu2wHttcL3S4SO\r\nftJQxbyHgmxAjHUVNGqOM6yPA0o7cR70J7FnMoKVgdO3q68pVY7ll50IET9/T0X9\r\nDrTdKFb+/eILFsXFS1NpeSzExdsKq3zM0sP/vlJHHYVTmZDGaGEvny/eLAS+KAfG\r\nrKP96DeO4C/peXEJzALZ/mG1ReBB05Qp9Dx1xEC20yreRk5MnnBA5oiHVG5ZLOl9\r\nEEHINidqN+TMNSkxv67xMfQ6utNu5IpbklKv/4wqQOJOO50HZ+qBtSurTN573dky\r\nzslbCQKBgQDHDUBYyKN/v69VLmvNVcxTgrOcrdbqAfefJXb9C3dVXhS8/oRkCRU/\r\ndzxYWNT7hmQyWUKor/izh68rZ/M+bsTnlaa7IdAgyChzTfcZL/2pxG9pq05GF1Q4\r\nBSJ896ZEe3jEhbpJXRlWYvz7455svlxR0H8FooCTddTmkU3nsQSx0wKBgQCbLSa4\r\nyZs2QVstQQerNjxAtLi0IvV8cJkuvFoNC2Q21oqQc7BYU7NJL7uwriprZr5nwkCQ\r\nOFQXi4N3uqimNxuSng31ETfjFZPp+pjb8jf7Sce7cqU66xxR+anUzVZqBG1CJShx\r\nVxN7cWN33UZvIH34gA2Ax6AXNnJG42B5Gn1GKwKBgQCZ/oh/p4nGNXfiAK3qB6yy\r\nFvX6CwuvsqHt/8AUeKBz7PtCU+38roI/vXF0MBVmGky+HwxREQLpcdl1TVCERpIT\r\nUFXThI9OLUwOGI1IcTZf9tby+1LtKvM++8n4wGdjp9qAv6ylQV9u09pAzZItMwCd\r\nUx5SL6wlaQ2y60tIKk0lfQKBgBJS+56YmA6JGzY11qz+I5FUhfcnpauDNGOTdGLT\r\n9IqRPR2fu7RCdgpva4+KkZHLOTLReoRNUojRPb4WubGfEk93AJju5pWXR7c6k3Bt\r\novS2mrJk8GQLvXVksQxjDxBH44sLDkKMEM3j7uYJqDaZNKbyoCWT7TCwikAau5qx\r\naRevAoGAAKZV705dvrpJuyoHFZ66luANlrAwG/vNf6Q4mBEXB7guqMkokCsSkjqR\r\nhsD79E6q06zA0QzkLCavbCn5kMmDS/AbA80+B7El92iIN6d3jRdiNZiewkhlWhEG\r\nm4N0gQRfIu+rUjsS/4xk8UuQUT/Ossjn/hExi7ejpKdCc7N++bc=\r\n-----END RSA PRIVATE KEY-----" - async with make_bot(token=bot.token, private_key=short_key) as b: + async with make_bot(token=offline_bot.token, private_key=short_key) as b: passport_data = PassportData.de_json(RAW_PASSPORT_DATA, bot=b) with pytest.raises(PassportDecryptionError): assert passport_data.decrypted_data async def test_mocked_download_passport_file(self, passport_data, monkeypatch): - # The files are not coming from our test bot, therefore the file id is invalid/wrong - # when coming from this bot, so we monkeypatch the call, to make sure that Bot.get_file + # The files are not coming from our test offline_bot, therefore the file id is invalid/wrong + # when coming from this offline_bot, so we monkeypatch the call, to make sure that Bot.get_file # at least gets called # TODO: Actually download a passport file in a test selfie = passport_data.decrypted_data[1].selfie @@ -498,7 +504,9 @@ async def get_file(*_, **kwargs): assert file._credentials.file_hash == self.driver_license_selfie_credentials_file_hash assert file._credentials.secret == self.driver_license_selfie_credentials_secret - async def test_mocked_set_passport_data_errors(self, monkeypatch, bot, chat_id, passport_data): + async def test_mocked_set_passport_data_errors( + self, monkeypatch, offline_bot, chat_id, passport_data + ): async def make_assertion(url, request_data: RequestData, *args, **kwargs): data = request_data.parameters return ( @@ -511,8 +519,8 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): == passport_data.decrypted_credentials.secure_data.driver_license.data.data_hash ) - monkeypatch.setattr(bot.request, "post", make_assertion) - message = await bot.set_passport_data_errors( + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + message = await offline_bot.set_passport_data_errors( chat_id, [ PassportElementErrorSelfie( diff --git a/tests/_passport/test_passportelementerrordatafield.py b/tests/_passport/test_passportelementerrordatafield.py index 40db20fc2f0..d0e53bbb162 100644 --- a/tests/_passport/test_passportelementerrordatafield.py +++ b/tests/_passport/test_passportelementerrordatafield.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,14 +25,14 @@ @pytest.fixture(scope="module") def passport_element_error_data_field(): return PassportElementErrorDataField( - TestPassportElementErrorDataFieldBase.type_, - TestPassportElementErrorDataFieldBase.field_name, - TestPassportElementErrorDataFieldBase.data_hash, - TestPassportElementErrorDataFieldBase.message, + PassportElementErrorDataFieldTestBase.type_, + PassportElementErrorDataFieldTestBase.field_name, + PassportElementErrorDataFieldTestBase.data_hash, + PassportElementErrorDataFieldTestBase.message, ) -class TestPassportElementErrorDataFieldBase: +class PassportElementErrorDataFieldTestBase: source = "data" type_ = "test_type" field_name = "test_field" @@ -40,7 +40,7 @@ class TestPassportElementErrorDataFieldBase: message = "Error message" -class TestPassportElementErrorDataFieldWithoutRequest(TestPassportElementErrorDataFieldBase): +class TestPassportElementErrorDataFieldWithoutRequest(PassportElementErrorDataFieldTestBase): def test_slot_behaviour(self, passport_element_error_data_field): inst = passport_element_error_data_field for attr in inst.__slots__: diff --git a/tests/_passport/test_passportelementerrorfile.py b/tests/_passport/test_passportelementerrorfile.py index be96a7a20e0..95309431c80 100644 --- a/tests/_passport/test_passportelementerrorfile.py +++ b/tests/_passport/test_passportelementerrorfile.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,20 +25,20 @@ @pytest.fixture(scope="module") def passport_element_error_file(): return PassportElementErrorFile( - TestPassportElementErrorFileBase.type_, - TestPassportElementErrorFileBase.file_hash, - TestPassportElementErrorFileBase.message, + PassportElementErrorFileTestBase.type_, + PassportElementErrorFileTestBase.file_hash, + PassportElementErrorFileTestBase.message, ) -class TestPassportElementErrorFileBase: +class PassportElementErrorFileTestBase: source = "file" type_ = "test_type" file_hash = "file_hash" message = "Error message" -class TestPassportElementErrorFileWithoutRequest(TestPassportElementErrorFileBase): +class TestPassportElementErrorFileWithoutRequest(PassportElementErrorFileTestBase): def test_slot_behaviour(self, passport_element_error_file): inst = passport_element_error_file for attr in inst.__slots__: diff --git a/tests/_passport/test_passportelementerrorfiles.py b/tests/_passport/test_passportelementerrorfiles.py index 507c222c4e3..ea55875fbf7 100644 --- a/tests/_passport/test_passportelementerrorfiles.py +++ b/tests/_passport/test_passportelementerrorfiles.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,20 +25,20 @@ @pytest.fixture(scope="module") def passport_element_error_files(): return PassportElementErrorFiles( - TestPassportElementErrorFilesBase.type_, - TestPassportElementErrorFilesBase.file_hashes, - TestPassportElementErrorFilesBase.message, + PassportElementErrorFilesTestBase.type_, + PassportElementErrorFilesTestBase.file_hashes, + PassportElementErrorFilesTestBase.message, ) -class TestPassportElementErrorFilesBase: +class PassportElementErrorFilesTestBase: source = "files" type_ = "test_type" file_hashes = ["hash1", "hash2"] message = "Error message" -class TestPassportElementErrorFilesWithoutRequest(TestPassportElementErrorFilesBase): +class TestPassportElementErrorFilesWithoutRequest(PassportElementErrorFilesTestBase): def test_slot_behaviour(self, passport_element_error_files): inst = passport_element_error_files for attr in inst.__slots__: @@ -48,8 +48,8 @@ def test_slot_behaviour(self, passport_element_error_files): def test_expected_values(self, passport_element_error_files): assert passport_element_error_files.source == self.source assert passport_element_error_files.type == self.type_ - assert isinstance(passport_element_error_files.file_hashes, list) - assert passport_element_error_files.file_hashes == self.file_hashes + assert isinstance(passport_element_error_files.file_hashes, tuple) + assert passport_element_error_files.file_hashes == tuple(self.file_hashes) assert passport_element_error_files.message == self.message def test_to_dict(self, passport_element_error_files): @@ -58,11 +58,10 @@ def test_to_dict(self, passport_element_error_files): assert isinstance(passport_element_error_files_dict, dict) assert passport_element_error_files_dict["source"] == passport_element_error_files.source assert passport_element_error_files_dict["type"] == passport_element_error_files.type - assert ( - passport_element_error_files_dict["file_hashes"] - == passport_element_error_files.file_hashes - ) assert passport_element_error_files_dict["message"] == passport_element_error_files.message + assert passport_element_error_files_dict["file_hashes"] == list( + passport_element_error_files.file_hashes + ) def test_equality(self): a = PassportElementErrorFiles(self.type_, self.file_hashes, self.message) diff --git a/tests/_passport/test_passportelementerrorfrontside.py b/tests/_passport/test_passportelementerrorfrontside.py index f7cb3942dc2..a4f7e56aa3f 100644 --- a/tests/_passport/test_passportelementerrorfrontside.py +++ b/tests/_passport/test_passportelementerrorfrontside.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,20 +25,20 @@ @pytest.fixture(scope="module") def passport_element_error_front_side(): return PassportElementErrorFrontSide( - TestPassportElementErrorFrontSideBase.type_, - TestPassportElementErrorFrontSideBase.file_hash, - TestPassportElementErrorFrontSideBase.message, + PassportElementErrorFrontSideTestBase.type_, + PassportElementErrorFrontSideTestBase.file_hash, + PassportElementErrorFrontSideTestBase.message, ) -class TestPassportElementErrorFrontSideBase: +class PassportElementErrorFrontSideTestBase: source = "front_side" type_ = "test_type" file_hash = "file_hash" message = "Error message" -class TestPassportElementErrorFrontSideWithoutRequest(TestPassportElementErrorFrontSideBase): +class TestPassportElementErrorFrontSideWithoutRequest(PassportElementErrorFrontSideTestBase): def test_slot_behaviour(self, passport_element_error_front_side): inst = passport_element_error_front_side for attr in inst.__slots__: diff --git a/tests/_passport/test_passportelementerrorreverseside.py b/tests/_passport/test_passportelementerrorreverseside.py index 9e29dc4a353..97ce2b71efe 100644 --- a/tests/_passport/test_passportelementerrorreverseside.py +++ b/tests/_passport/test_passportelementerrorreverseside.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,20 +25,20 @@ @pytest.fixture(scope="module") def passport_element_error_reverse_side(): return PassportElementErrorReverseSide( - TestPassportElementErrorReverseSideBase.type_, - TestPassportElementErrorReverseSideBase.file_hash, - TestPassportElementErrorReverseSideBase.message, + PassportElementErrorReverseSideTestBase.type_, + PassportElementErrorReverseSideTestBase.file_hash, + PassportElementErrorReverseSideTestBase.message, ) -class TestPassportElementErrorReverseSideBase: +class PassportElementErrorReverseSideTestBase: source = "reverse_side" type_ = "test_type" file_hash = "file_hash" message = "Error message" -class TestPassportElementErrorReverseSideWithoutRequest(TestPassportElementErrorReverseSideBase): +class TestPassportElementErrorReverseSideWithoutRequest(PassportElementErrorReverseSideTestBase): def test_slot_behaviour(self, passport_element_error_reverse_side): inst = passport_element_error_reverse_side for attr in inst.__slots__: diff --git a/tests/_passport/test_passportelementerrorselfie.py b/tests/_passport/test_passportelementerrorselfie.py index 1e22b1b0b05..5bc87b6a579 100644 --- a/tests/_passport/test_passportelementerrorselfie.py +++ b/tests/_passport/test_passportelementerrorselfie.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,20 +25,20 @@ @pytest.fixture(scope="module") def passport_element_error_selfie(): return PassportElementErrorSelfie( - TestPassportElementErrorSelfieBase.type_, - TestPassportElementErrorSelfieBase.file_hash, - TestPassportElementErrorSelfieBase.message, + PassportElementErrorSelfieTestBase.type_, + PassportElementErrorSelfieTestBase.file_hash, + PassportElementErrorSelfieTestBase.message, ) -class TestPassportElementErrorSelfieBase: +class PassportElementErrorSelfieTestBase: source = "selfie" type_ = "test_type" file_hash = "file_hash" message = "Error message" -class TestPassportElementErrorSelfieWithoutRequest(TestPassportElementErrorSelfieBase): +class TestPassportElementErrorSelfieWithoutRequest(PassportElementErrorSelfieTestBase): def test_slot_behaviour(self, passport_element_error_selfie): inst = passport_element_error_selfie for attr in inst.__slots__: diff --git a/tests/_passport/test_passportelementerrortranslationfile.py b/tests/_passport/test_passportelementerrortranslationfile.py index 189ba6159a0..f1ccdfb9fb8 100644 --- a/tests/_passport/test_passportelementerrortranslationfile.py +++ b/tests/_passport/test_passportelementerrortranslationfile.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,13 +25,13 @@ @pytest.fixture(scope="module") def passport_element_error_translation_file(): return PassportElementErrorTranslationFile( - TestPassportElementErrorTranslationFileBase.type_, - TestPassportElementErrorTranslationFileBase.file_hash, - TestPassportElementErrorTranslationFileBase.message, + PassportElementErrorTranslationFileTestBase.type_, + PassportElementErrorTranslationFileTestBase.file_hash, + PassportElementErrorTranslationFileTestBase.message, ) -class TestPassportElementErrorTranslationFileBase: +class PassportElementErrorTranslationFileTestBase: source = "translation_file" type_ = "test_type" file_hash = "file_hash" @@ -39,7 +39,7 @@ class TestPassportElementErrorTranslationFileBase: class TestPassportElementErrorTranslationFileWithoutRequest( - TestPassportElementErrorTranslationFileBase + PassportElementErrorTranslationFileTestBase ): def test_slot_behaviour(self, passport_element_error_translation_file): inst = passport_element_error_translation_file diff --git a/tests/_passport/test_passportelementerrortranslationfiles.py b/tests/_passport/test_passportelementerrortranslationfiles.py index 3ae5307f6e8..e2be406fb7e 100644 --- a/tests/_passport/test_passportelementerrortranslationfiles.py +++ b/tests/_passport/test_passportelementerrortranslationfiles.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,13 +25,13 @@ @pytest.fixture(scope="module") def passport_element_error_translation_files(): return PassportElementErrorTranslationFiles( - TestPassportElementErrorTranslationFilesBase.type_, - TestPassportElementErrorTranslationFilesBase.file_hashes, - TestPassportElementErrorTranslationFilesBase.message, + PassportElementErrorTranslationFilesTestBase.type_, + PassportElementErrorTranslationFilesTestBase.file_hashes, + PassportElementErrorTranslationFilesTestBase.message, ) -class TestPassportElementErrorTranslationFilesBase: +class PassportElementErrorTranslationFilesTestBase: source = "translation_files" type_ = "test_type" file_hashes = ["hash1", "hash2"] @@ -39,7 +39,7 @@ class TestPassportElementErrorTranslationFilesBase: class TestPassportElementErrorTranslationFilesWithoutRequest( - TestPassportElementErrorTranslationFilesBase + PassportElementErrorTranslationFilesTestBase ): def test_slot_behaviour(self, passport_element_error_translation_files): inst = passport_element_error_translation_files @@ -50,8 +50,8 @@ def test_slot_behaviour(self, passport_element_error_translation_files): def test_expected_values(self, passport_element_error_translation_files): assert passport_element_error_translation_files.source == self.source assert passport_element_error_translation_files.type == self.type_ - assert isinstance(passport_element_error_translation_files.file_hashes, list) - assert passport_element_error_translation_files.file_hashes == self.file_hashes + assert isinstance(passport_element_error_translation_files.file_hashes, tuple) + assert passport_element_error_translation_files.file_hashes == tuple(self.file_hashes) assert passport_element_error_translation_files.message == self.message def test_to_dict(self, passport_element_error_translation_files): @@ -68,14 +68,13 @@ def test_to_dict(self, passport_element_error_translation_files): passport_element_error_translation_files_dict["type"] == passport_element_error_translation_files.type ) - assert ( - passport_element_error_translation_files_dict["file_hashes"] - == passport_element_error_translation_files.file_hashes - ) assert ( passport_element_error_translation_files_dict["message"] == passport_element_error_translation_files.message ) + assert passport_element_error_translation_files_dict["file_hashes"] == list( + passport_element_error_translation_files.file_hashes + ) def test_equality(self): a = PassportElementErrorTranslationFiles(self.type_, self.file_hashes, self.message) diff --git a/tests/_passport/test_passportelementerrorunspecified.py b/tests/_passport/test_passportelementerrorunspecified.py index 97612f600c5..f367df4d3da 100644 --- a/tests/_passport/test_passportelementerrorunspecified.py +++ b/tests/_passport/test_passportelementerrorunspecified.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,20 +25,20 @@ @pytest.fixture(scope="module") def passport_element_error_unspecified(): return PassportElementErrorUnspecified( - TestPassportElementErrorUnspecifiedBase.type_, - TestPassportElementErrorUnspecifiedBase.element_hash, - TestPassportElementErrorUnspecifiedBase.message, + PassportElementErrorUnspecifiedTestBase.type_, + PassportElementErrorUnspecifiedTestBase.element_hash, + PassportElementErrorUnspecifiedTestBase.message, ) -class TestPassportElementErrorUnspecifiedBase: +class PassportElementErrorUnspecifiedTestBase: source = "unspecified" type_ = "test_type" element_hash = "element_hash" message = "Error message" -class TestPassportElementErrorUnspecifiedWithoutRequest(TestPassportElementErrorUnspecifiedBase): +class TestPassportElementErrorUnspecifiedWithoutRequest(PassportElementErrorUnspecifiedTestBase): def test_slot_behaviour(self, passport_element_error_unspecified): inst = passport_element_error_unspecified for attr in inst.__slots__: diff --git a/tests/_passport/test_passportfile.py b/tests/_passport/test_passportfile.py index 2492ba66afd..6eb5ad24cf5 100644 --- a/tests/_passport/test_passportfile.py +++ b/tests/_passport/test_passportfile.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,9 +16,12 @@ # # 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 as dtm + import pytest from telegram import Bot, File, PassportElementError, PassportFile +from telegram._utils.datetime import UTC, to_timestamp from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -30,23 +33,23 @@ @pytest.fixture(scope="class") def passport_file(bot): pf = PassportFile( - file_id=TestPassportFileBase.file_id, - file_unique_id=TestPassportFileBase.file_unique_id, - file_size=TestPassportFileBase.file_size, - file_date=TestPassportFileBase.file_date, + file_id=PassportFileTestBase.file_id, + file_unique_id=PassportFileTestBase.file_unique_id, + file_size=PassportFileTestBase.file_size, + file_date=PassportFileTestBase.file_date, ) pf.set_bot(bot) return pf -class TestPassportFileBase: +class PassportFileTestBase: file_id = "data" file_unique_id = "adc3145fd2e84d95b64d68eaa22aa33e" file_size = 50 - file_date = 1532879128 + file_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) -class TestPassportFileWithoutRequest(TestPassportFileBase): +class TestPassportFileWithoutRequest(PassportFileTestBase): def test_slot_behaviour(self, passport_file): inst = passport_file for attr in inst.__slots__: @@ -66,7 +69,27 @@ def test_to_dict(self, passport_file): assert passport_file_dict["file_id"] == passport_file.file_id assert passport_file_dict["file_unique_id"] == passport_file.file_unique_id assert passport_file_dict["file_size"] == passport_file.file_size - assert passport_file_dict["file_date"] == passport_file.file_date + assert passport_file_dict["file_date"] == to_timestamp(passport_file.file_date) + + def test_de_json_localization(self, passport_file, tz_bot, offline_bot, raw_bot): + json_dict = { + "file_id": self.file_id, + "file_unique_id": self.file_unique_id, + "file_size": self.file_size, + "file_date": to_timestamp(self.file_date), + } + + pf = PassportFile.de_json(json_dict, offline_bot) + pf_raw = PassportFile.de_json(json_dict, raw_bot) + pf_tz = PassportFile.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + date_offset = pf_tz.file_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(pf_tz.file_date.replace(tzinfo=None)) + + assert pf_raw.file_date.tzinfo == UTC + assert pf.file_date.tzinfo == UTC + assert date_offset == tz_bot_offset def test_equality(self): a = PassportFile(self.file_id, self.file_unique_id, self.file_size, self.file_date) diff --git a/tests/_payment/__init__.py b/tests/_payment/__init__.py index 1eaba12c869..c95cb3c9741 100644 --- a/tests/_payment/__init__.py +++ b/tests/_payment/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_payment/stars/__init__.py b/tests/_payment/stars/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/_payment/stars/test_affiliateinfo.py b/tests/_payment/stars/test_affiliateinfo.py new file mode 100644 index 00000000000..b6e72cc724f --- /dev/null +++ b/tests/_payment/stars/test_affiliateinfo.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 AffiliateInfo, Chat, Dice, User +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def affiliate_info(): + return AffiliateInfo( + affiliate_user=AffiliateInfoTestBase.affiliate_user, + affiliate_chat=AffiliateInfoTestBase.affiliate_chat, + commission_per_mille=AffiliateInfoTestBase.commission_per_mille, + amount=AffiliateInfoTestBase.amount, + nanostar_amount=AffiliateInfoTestBase.nanostar_amount, + ) + + +class AffiliateInfoTestBase: + affiliate_user = User(id=1, is_bot=True, first_name="affiliate_user", username="username") + affiliate_chat = Chat(id=2, type="private", title="affiliate_chat") + commission_per_mille = 13 + amount = 14 + nanostar_amount = -42 + + +class TestAffiliateInfoWithoutRequest(AffiliateInfoTestBase): + def test_slot_behaviour(self, affiliate_info): + inst = affiliate_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, offline_bot): + json_dict = { + "affiliate_user": self.affiliate_user.to_dict(), + "affiliate_chat": self.affiliate_chat.to_dict(), + "commission_per_mille": self.commission_per_mille, + "amount": self.amount, + "nanostar_amount": self.nanostar_amount, + } + ai = AffiliateInfo.de_json(json_dict, offline_bot) + assert ai.api_kwargs == {} + assert ai.affiliate_user == self.affiliate_user + assert ai.affiliate_chat == self.affiliate_chat + assert ai.commission_per_mille == self.commission_per_mille + assert ai.amount == self.amount + assert ai.nanostar_amount == self.nanostar_amount + + def test_to_dict(self, affiliate_info): + ai_dict = affiliate_info.to_dict() + + assert isinstance(ai_dict, dict) + assert ai_dict["affiliate_user"] == affiliate_info.affiliate_user.to_dict() + assert ai_dict["affiliate_chat"] == affiliate_info.affiliate_chat.to_dict() + assert ai_dict["commission_per_mille"] == affiliate_info.commission_per_mille + assert ai_dict["amount"] == affiliate_info.amount + assert ai_dict["nanostar_amount"] == affiliate_info.nanostar_amount + + def test_equality(self, affiliate_info, offline_bot): + a = AffiliateInfo( + affiliate_user=self.affiliate_user, + affiliate_chat=self.affiliate_chat, + commission_per_mille=self.commission_per_mille, + amount=self.amount, + nanostar_amount=self.nanostar_amount, + ) + b = AffiliateInfo( + affiliate_user=self.affiliate_user, + affiliate_chat=self.affiliate_chat, + commission_per_mille=self.commission_per_mille, + amount=self.amount, + nanostar_amount=self.nanostar_amount, + ) + c = AffiliateInfo( + affiliate_user=User(id=3, is_bot=True, first_name="first_name", username="username"), + affiliate_chat=self.affiliate_chat, + commission_per_mille=self.commission_per_mille, + amount=self.amount, + nanostar_amount=self.nanostar_amount, + ) + d = AffiliateInfo( + affiliate_user=self.affiliate_user, + affiliate_chat=Chat(id=3, type="private", title="title"), + commission_per_mille=self.commission_per_mille, + amount=self.amount, + nanostar_amount=self.nanostar_amount, + ) + e = AffiliateInfo( + affiliate_user=self.affiliate_user, + affiliate_chat=self.affiliate_chat, + commission_per_mille=1, + amount=self.amount, + nanostar_amount=self.nanostar_amount, + ) + f = AffiliateInfo( + affiliate_user=self.affiliate_user, + affiliate_chat=self.affiliate_chat, + commission_per_mille=self.commission_per_mille, + amount=1, + nanostar_amount=self.nanostar_amount, + ) + g = AffiliateInfo( + affiliate_user=self.affiliate_user, + affiliate_chat=self.affiliate_chat, + commission_per_mille=self.commission_per_mille, + amount=self.amount, + nanostar_amount=1, + ) + h = 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 a != f + assert hash(a) != hash(f) + + assert a != g + assert hash(a) != hash(g) + + assert a != h + assert hash(a) != hash(h) diff --git a/tests/_payment/stars/test_revenuewithdrawelstate.py b/tests/_payment/stars/test_revenuewithdrawelstate.py new file mode 100644 index 00000000000..da24129b68e --- /dev/null +++ b/tests/_payment/stars/test_revenuewithdrawelstate.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm + +import pytest + +from telegram import ( + Dice, + RevenueWithdrawalState, + RevenueWithdrawalStateFailed, + RevenueWithdrawalStatePending, + RevenueWithdrawalStateSucceeded, +) +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import RevenueWithdrawalStateType +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def revenue_withdrawal_state(): + return RevenueWithdrawalState(RevenueWithdrawalStateTestBase.state) + + +@pytest.fixture +def revenue_withdrawal_state_pending(): + return RevenueWithdrawalStatePending() + + +@pytest.fixture +def revenue_withdrawal_state_succeeded(): + return RevenueWithdrawalStateSucceeded( + date=RevenueWithdrawalStateTestBase.date, url=RevenueWithdrawalStateTestBase.url + ) + + +@pytest.fixture +def revenue_withdrawal_state_failed(): + return RevenueWithdrawalStateFailed() + + +class RevenueWithdrawalStateTestBase: + state = "failed" + date = dtm.datetime(2024, 1, 1, 0, 0, 0, 0, tzinfo=UTC) + url = "url" + + +class TestRevenueWithdrawalStateWithoutRequest(RevenueWithdrawalStateTestBase): + 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_type_enum_conversion(self): + assert type(RevenueWithdrawalState("failed").type) is RevenueWithdrawalStateType + assert RevenueWithdrawalState("unknown").type == "unknown" + + def test_de_json(self, offline_bot): + json_dict = {"type": "unknown"} + rws = RevenueWithdrawalState.de_json(json_dict, offline_bot) + assert rws.api_kwargs == {} + assert rws.type == "unknown" + + @pytest.mark.parametrize( + ("state", "subclass"), + [ + ("pending", RevenueWithdrawalStatePending), + ("succeeded", RevenueWithdrawalStateSucceeded), + ("failed", RevenueWithdrawalStateFailed), + ], + ) + def test_de_json_subclass(self, offline_bot, state, subclass): + json_dict = {"type": state, "date": to_timestamp(self.date), "url": self.url} + rws = RevenueWithdrawalState.de_json(json_dict, offline_bot) + + assert type(rws) is subclass + assert set(rws.api_kwargs.keys()) == {"date", "url"} - set(subclass.__slots__) + assert rws.type == state + + def test_to_dict(self, revenue_withdrawal_state): + json_dict = revenue_withdrawal_state.to_dict() + assert json_dict == {"type": self.state} + + def test_equality(self, revenue_withdrawal_state): + a = revenue_withdrawal_state + b = RevenueWithdrawalState(self.state) + c = RevenueWithdrawalState("pending") + d = Dice(value=1, 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) + + +class TestRevenueWithdrawalStatePendingWithoutRequest(RevenueWithdrawalStateTestBase): + def test_slot_behaviour(self, revenue_withdrawal_state_pending): + inst = revenue_withdrawal_state_pending + 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, offline_bot): + json_dict = {} + rws = RevenueWithdrawalStatePending.de_json(json_dict, offline_bot) + assert rws.api_kwargs == {} + assert rws.type == "pending" + + def test_to_dict(self, revenue_withdrawal_state_pending): + json_dict = revenue_withdrawal_state_pending.to_dict() + assert json_dict == {"type": "pending"} + + def test_equality(self, revenue_withdrawal_state_pending): + a = revenue_withdrawal_state_pending + b = RevenueWithdrawalStatePending() + c = RevenueWithdrawalState("unknown") + d = Dice(value=1, 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) + + +class TestRevenueWithdrawalStateSucceededWithoutRequest(RevenueWithdrawalStateTestBase): + state = RevenueWithdrawalStateType.SUCCEEDED + + def test_slot_behaviour(self, revenue_withdrawal_state_succeeded): + inst = revenue_withdrawal_state_succeeded + 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, offline_bot): + json_dict = {"date": to_timestamp(self.date), "url": self.url} + rws = RevenueWithdrawalStateSucceeded.de_json(json_dict, offline_bot) + assert rws.api_kwargs == {} + assert rws.type == "succeeded" + assert rws.date == self.date + assert rws.url == self.url + + def test_to_dict(self, revenue_withdrawal_state_succeeded): + json_dict = revenue_withdrawal_state_succeeded.to_dict() + assert json_dict["type"] == "succeeded" + assert json_dict["date"] == to_timestamp(self.date) + assert json_dict["url"] == self.url + + def test_equality(self, revenue_withdrawal_state_succeeded): + a = revenue_withdrawal_state_succeeded + b = RevenueWithdrawalStateSucceeded(date=self.date, url=self.url) + c = RevenueWithdrawalStateSucceeded(date=self.date, url="other_url") + d = RevenueWithdrawalStateSucceeded( + date=dtm.datetime(1900, 1, 1, 0, 0, 0, 0, tzinfo=UTC), url=self.url + ) + e = Dice(value=1, 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) + + +class TestRevenueWithdrawalStateFailedWithoutRequest(RevenueWithdrawalStateTestBase): + state = RevenueWithdrawalStateType.FAILED + + def test_slot_behaviour(self, revenue_withdrawal_state_failed): + inst = revenue_withdrawal_state_failed + 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, offline_bot): + json_dict = {} + rws = RevenueWithdrawalStateFailed.de_json(json_dict, offline_bot) + assert rws.api_kwargs == {} + assert rws.type == "failed" + + def test_to_dict(self, revenue_withdrawal_state_failed): + json_dict = revenue_withdrawal_state_failed.to_dict() + assert json_dict == {"type": "failed"} + + def test_equality(self, revenue_withdrawal_state_failed): + a = revenue_withdrawal_state_failed + b = RevenueWithdrawalStateFailed() + c = RevenueWithdrawalState("unknown") + d = Dice(value=1, 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) diff --git a/tests/_payment/stars/test_staramount.py b/tests/_payment/stars/test_staramount.py new file mode 100644 index 00000000000..6f9ea649450 --- /dev/null +++ b/tests/_payment/stars/test_staramount.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 StarAmount +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def star_amount(): + return StarAmount( + amount=StarTransactionTestBase.amount, + nanostar_amount=StarTransactionTestBase.nanostar_amount, + ) + + +class StarTransactionTestBase: + amount = 100 + nanostar_amount = 356 + + +class TestStarAmountWithoutRequest(StarTransactionTestBase): + def test_slot_behaviour(self, star_amount): + inst = star_amount + 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, offline_bot): + json_dict = { + "amount": self.amount, + "nanostar_amount": self.nanostar_amount, + } + st = StarAmount.de_json(json_dict, offline_bot) + assert st.api_kwargs == {} + assert st.amount == self.amount + assert st.nanostar_amount == self.nanostar_amount + + def test_to_dict(self, star_amount): + expected_dict = { + "amount": self.amount, + "nanostar_amount": self.nanostar_amount, + } + assert star_amount.to_dict() == expected_dict + + def test_equality(self, star_amount): + a = star_amount + b = StarAmount(amount=self.amount, nanostar_amount=self.nanostar_amount) + c = StarAmount(amount=99, nanostar_amount=99) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) diff --git a/tests/_payment/stars/test_startransactions.py b/tests/_payment/stars/test_startransactions.py new file mode 100644 index 00000000000..aa3ef7fe592 --- /dev/null +++ b/tests/_payment/stars/test_startransactions.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm + +import pytest + +from telegram import ( + StarTransaction, + StarTransactions, + TransactionPartnerOther, + TransactionPartnerUser, + User, +) +from telegram._utils.datetime import UTC, from_timestamp, to_timestamp +from tests.auxil.slots import mro_slots + + +def star_transaction_factory(): + return StarTransaction( + id=StarTransactionTestBase.id, + amount=StarTransactionTestBase.amount, + nanostar_amount=StarTransactionTestBase.nanostar_amount, + date=from_timestamp(StarTransactionTestBase.date), + source=StarTransactionTestBase.source, + receiver=StarTransactionTestBase.receiver, + ) + + +@pytest.fixture +def star_transaction(): + return star_transaction_factory() + + +@pytest.fixture +def star_transactions(): + return StarTransactions( + transactions=[ + star_transaction_factory(), + star_transaction_factory(), + ] + ) + + +class StarTransactionTestBase: + id = "2" + amount = 2 + nanostar_amount = 365 + date = to_timestamp(dtm.datetime(2024, 1, 1, 0, 0, 0, 0, tzinfo=UTC)) + source = TransactionPartnerUser( + transaction_type="premium_purchase", + user=User( + id=2, + is_bot=False, + first_name="first_name", + ), + ) + receiver = TransactionPartnerOther() + + +class TestStarTransactionWithoutRequest(StarTransactionTestBase): + def test_slot_behaviour(self, star_transaction): + 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, offline_bot): + json_dict = { + "id": self.id, + "amount": self.amount, + "nanostar_amount": self.nanostar_amount, + "date": self.date, + "source": self.source.to_dict(), + "receiver": self.receiver.to_dict(), + } + st = StarTransaction.de_json(json_dict, offline_bot) + assert st.api_kwargs == {} + assert st.id == self.id + assert st.amount == self.amount + assert st.nanostar_amount == self.nanostar_amount + assert st.date == from_timestamp(self.date) + assert st.source == self.source + assert st.receiver == self.receiver + + def test_de_json_star_transaction_localization( + self, tz_bot, offline_bot, raw_bot, star_transaction + ): + json_dict = star_transaction.to_dict() + st_raw = StarTransaction.de_json(json_dict, raw_bot) + st_bot = StarTransaction.de_json(json_dict, offline_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, star_transaction): + expected_dict = { + "id": self.id, + "amount": self.amount, + "nanostar_amount": self.nanostar_amount, + "date": self.date, + "source": self.source.to_dict(), + "receiver": self.receiver.to_dict(), + } + assert star_transaction.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(dtm.datetime.utcnow()), + source=TransactionPartnerUser( + transaction_type="other_type", + 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 StarTransactionsTestBase: + transactions = [star_transaction_factory(), star_transaction_factory()] + + +class TestStarTransactionsWithoutRequest(StarTransactionsTestBase): + 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, offline_bot): + json_dict = { + "transactions": [t.to_dict() for t in self.transactions], + } + st = StarTransactions.de_json(json_dict, offline_bot) + assert st.api_kwargs == {} + assert st.transactions == tuple(self.transactions) + + 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, star_transaction): + 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) diff --git a/tests/_payment/stars/test_transactionpartner.py b/tests/_payment/stars/test_transactionpartner.py new file mode 100644 index 00000000000..e17e8480b62 --- /dev/null +++ b/tests/_payment/stars/test_transactionpartner.py @@ -0,0 +1,527 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm + +import pytest + +from telegram import ( + AffiliateInfo, + Chat, + Gift, + PaidMediaVideo, + RevenueWithdrawalStatePending, + Sticker, + TransactionPartner, + TransactionPartnerAffiliateProgram, + TransactionPartnerChat, + TransactionPartnerFragment, + TransactionPartnerOther, + TransactionPartnerTelegramAds, + TransactionPartnerTelegramApi, + TransactionPartnerUser, + User, + Video, +) +from telegram.constants import TransactionPartnerType +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def transaction_partner(): + return TransactionPartner(TransactionPartnerTestBase.type) + + +class TransactionPartnerTestBase: + type = TransactionPartnerType.AFFILIATE_PROGRAM + commission_per_mille = 42 + sponsor_user = User( + id=1, + is_bot=False, + first_name="sponsor", + last_name="user", + ) + withdrawal_state = RevenueWithdrawalStatePending() + user = User( + id=2, + is_bot=False, + first_name="user", + last_name="user", + ) + transaction_type = "premium_purchase" + invoice_payload = "invoice_payload" + paid_media = ( + PaidMediaVideo( + video=Video( + file_id="file_id", + file_unique_id="file_unique_id", + width=10, + height=10, + duration=10, + ) + ), + ) + paid_media_payload = "paid_media_payload" + subscription_period = dtm.timedelta(days=1) + gift = Gift( + id="gift_id", + sticker=Sticker( + file_id="file_id", + file_unique_id="file_unique_id", + width=10, + height=10, + is_animated=False, + is_video=False, + type="type", + ), + total_count=42, + remaining_count=42, + star_count=42, + ) + affiliate = AffiliateInfo( + commission_per_mille=42, + amount=42, + ) + request_count = 42 + chat = Chat( + id=3, + type=Chat.CHANNEL, + ) + premium_subscription_duration = 3 + + +class TestTransactionPartnerWithoutRequest(TransactionPartnerTestBase): + 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_type_enum_conversion(self, transaction_partner): + assert type(TransactionPartner("affiliate_program").type) is TransactionPartnerType + assert TransactionPartner("unknown").type == "unknown" + + def test_de_json(self, offline_bot): + data = {"type": "unknown"} + transaction_partner = TransactionPartner.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "unknown" + + @pytest.mark.parametrize( + ("tp_type", "subclass"), + [ + ("affiliate_program", TransactionPartnerAffiliateProgram), + ("fragment", TransactionPartnerFragment), + ("user", TransactionPartnerUser), + ("telegram_ads", TransactionPartnerTelegramAds), + ("telegram_api", TransactionPartnerTelegramApi), + ("other", TransactionPartnerOther), + ("chat", TransactionPartnerChat), + ], + ) + def test_subclass(self, offline_bot, tp_type, subclass): + json_dict = { + "type": tp_type, + "commission_per_mille": self.commission_per_mille, + "user": self.user.to_dict(), + "transaction_type": self.transaction_type, + "request_count": self.request_count, + } + tp = TransactionPartner.de_json(json_dict, offline_bot) + + assert type(tp) is subclass + assert set(tp.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { + "type" + } + assert tp.type == tp_type + + def test_to_dict(self, transaction_partner): + data = transaction_partner.to_dict() + assert data == {"type": self.type} + + def test_equality(self, transaction_partner): + a = transaction_partner + b = TransactionPartner(self.type) + c = TransactionPartner("unknown") + d = User(id=1, is_bot=False, first_name="user", last_name="user") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def transaction_partner_affiliate_program(): + return TransactionPartnerAffiliateProgram( + commission_per_mille=TransactionPartnerTestBase.commission_per_mille, + sponsor_user=TransactionPartnerTestBase.sponsor_user, + ) + + +class TestTransactionPartnerAffiliateProgramWithoutRequest(TransactionPartnerTestBase): + type = TransactionPartnerType.AFFILIATE_PROGRAM + + def test_slot_behaviour(self, transaction_partner_affiliate_program): + inst = transaction_partner_affiliate_program + 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, offline_bot): + json_dict = { + "commission_per_mille": self.commission_per_mille, + "sponsor_user": self.sponsor_user.to_dict(), + } + tp = TransactionPartnerAffiliateProgram.de_json(json_dict, offline_bot) + assert tp.api_kwargs == {} + assert tp.type == "affiliate_program" + assert tp.commission_per_mille == self.commission_per_mille + assert tp.sponsor_user == self.sponsor_user + + def test_to_dict(self, transaction_partner_affiliate_program): + json_dict = transaction_partner_affiliate_program.to_dict() + assert json_dict["type"] == self.type + assert json_dict["commission_per_mille"] == self.commission_per_mille + assert json_dict["sponsor_user"] == self.sponsor_user.to_dict() + + def test_equality(self, transaction_partner_affiliate_program): + a = transaction_partner_affiliate_program + b = TransactionPartnerAffiliateProgram( + commission_per_mille=self.commission_per_mille, + ) + c = TransactionPartnerAffiliateProgram( + commission_per_mille=0, + ) + d = User(id=1, is_bot=False, first_name="user", last_name="user") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def transaction_partner_fragment(): + return TransactionPartnerFragment( + withdrawal_state=TransactionPartnerTestBase.withdrawal_state, + ) + + +class TestTransactionPartnerFragmentWithoutRequest(TransactionPartnerTestBase): + type = TransactionPartnerType.FRAGMENT + + def test_slot_behaviour(self, transaction_partner_fragment): + inst = transaction_partner_fragment + 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, offline_bot): + json_dict = {"withdrawal_state": self.withdrawal_state.to_dict()} + tp = TransactionPartnerFragment.de_json(json_dict, offline_bot) + assert tp.api_kwargs == {} + assert tp.type == "fragment" + assert tp.withdrawal_state == self.withdrawal_state + + def test_to_dict(self, transaction_partner_fragment): + json_dict = transaction_partner_fragment.to_dict() + assert json_dict["type"] == self.type + assert json_dict["withdrawal_state"] == self.withdrawal_state.to_dict() + + def test_equality(self, transaction_partner_fragment): + a = transaction_partner_fragment + b = TransactionPartnerFragment(withdrawal_state=self.withdrawal_state) + c = TransactionPartnerFragment(withdrawal_state=None) + d = User(id=1, is_bot=False, first_name="user", last_name="user") + + assert a == b + assert hash(a) == hash(b) + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def transaction_partner_user(): + return TransactionPartnerUser( + transaction_type=TransactionPartnerTestBase.transaction_type, + user=TransactionPartnerTestBase.user, + invoice_payload=TransactionPartnerTestBase.invoice_payload, + paid_media=TransactionPartnerTestBase.paid_media, + paid_media_payload=TransactionPartnerTestBase.paid_media_payload, + subscription_period=TransactionPartnerTestBase.subscription_period, + premium_subscription_duration=TransactionPartnerTestBase.premium_subscription_duration, + ) + + +class TestTransactionPartnerUserWithoutRequest(TransactionPartnerTestBase): + type = TransactionPartnerType.USER + + def test_slot_behaviour(self, transaction_partner_user): + inst = transaction_partner_user + 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, offline_bot): + json_dict = { + "user": self.user.to_dict(), + "transaction_type": self.transaction_type, + "invoice_payload": self.invoice_payload, + "paid_media": [pm.to_dict() for pm in self.paid_media], + "paid_media_payload": self.paid_media_payload, + "subscription_period": self.subscription_period.total_seconds(), + "premium_subscription_duration": self.premium_subscription_duration, + } + tp = TransactionPartnerUser.de_json(json_dict, offline_bot) + assert tp.api_kwargs == {} + assert tp.type == "user" + assert tp.user == self.user + assert tp.transaction_type == self.transaction_type + assert tp.invoice_payload == self.invoice_payload + assert tp.paid_media == self.paid_media + assert tp.paid_media_payload == self.paid_media_payload + assert tp.subscription_period == self.subscription_period + assert tp.premium_subscription_duration == self.premium_subscription_duration + + def test_to_dict(self, transaction_partner_user): + json_dict = transaction_partner_user.to_dict() + assert json_dict["type"] == self.type + assert json_dict["transaction_type"] == self.transaction_type + assert json_dict["user"] == self.user.to_dict() + assert json_dict["invoice_payload"] == self.invoice_payload + assert json_dict["paid_media"] == [pm.to_dict() for pm in self.paid_media] + assert json_dict["paid_media_payload"] == self.paid_media_payload + assert json_dict["subscription_period"] == self.subscription_period.total_seconds() + assert json_dict["premium_subscription_duration"] == self.premium_subscription_duration + + def test_equality(self, transaction_partner_user): + a = transaction_partner_user + b = TransactionPartnerUser( + transaction_type=self.transaction_type, + user=self.user, + ) + c = TransactionPartnerUser( + transaction_type=self.transaction_type, + user=User(id=1, is_bot=False, first_name="user", last_name="user"), + ) + d = User(id=1, is_bot=False, first_name="user", last_name="user") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def transaction_partner_other(): + return TransactionPartnerOther() + + +class TestTransactionPartnerOtherWithoutRequest(TransactionPartnerTestBase): + type = TransactionPartnerType.OTHER + + def test_slot_behaviour(self, transaction_partner_other): + inst = transaction_partner_other + 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, offline_bot): + json_dict = {} + tp = TransactionPartnerOther.de_json(json_dict, offline_bot) + assert tp.api_kwargs == {} + assert tp.type == "other" + + def test_to_dict(self, transaction_partner_other): + json_dict = transaction_partner_other.to_dict() + assert json_dict == {"type": self.type} + + def test_equality(self, transaction_partner_other): + a = transaction_partner_other + b = TransactionPartnerOther() + c = TransactionPartnerOther() + d = User(id=1, is_bot=False, first_name="user", last_name="user") + + assert a == b + assert hash(a) == hash(b) + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def transaction_partner_telegram_ads(): + return TransactionPartnerTelegramAds() + + +class TestTransactionPartnerTelegramAdsWithoutRequest(TransactionPartnerTestBase): + type = TransactionPartnerType.TELEGRAM_ADS + + def test_slot_behaviour(self, transaction_partner_telegram_ads): + inst = transaction_partner_telegram_ads + 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, offline_bot): + json_dict = {} + tp = TransactionPartnerTelegramAds.de_json(json_dict, offline_bot) + assert tp.api_kwargs == {} + assert tp.type == "telegram_ads" + + def test_to_dict(self, transaction_partner_telegram_ads): + json_dict = transaction_partner_telegram_ads.to_dict() + assert json_dict == {"type": self.type} + + def test_equality(self, transaction_partner_telegram_ads): + a = transaction_partner_telegram_ads + b = TransactionPartnerTelegramAds() + c = TransactionPartnerTelegramAds() + d = User(id=1, is_bot=False, first_name="user", last_name="user") + + assert a == b + assert hash(a) == hash(b) + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def transaction_partner_telegram_api(): + return TransactionPartnerTelegramApi( + request_count=TransactionPartnerTestBase.request_count, + ) + + +class TestTransactionPartnerTelegramApiWithoutRequest(TransactionPartnerTestBase): + type = TransactionPartnerType.TELEGRAM_API + + def test_slot_behaviour(self, transaction_partner_telegram_api): + inst = transaction_partner_telegram_api + 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, offline_bot): + json_dict = {"request_count": self.request_count} + tp = TransactionPartnerTelegramApi.de_json(json_dict, offline_bot) + assert tp.api_kwargs == {} + assert tp.type == "telegram_api" + assert tp.request_count == self.request_count + + def test_to_dict(self, transaction_partner_telegram_api): + json_dict = transaction_partner_telegram_api.to_dict() + assert json_dict["type"] == self.type + assert json_dict["request_count"] == self.request_count + + def test_equality(self, transaction_partner_telegram_api): + a = transaction_partner_telegram_api + b = TransactionPartnerTelegramApi( + request_count=self.request_count, + ) + c = TransactionPartnerTelegramApi( + request_count=0, + ) + d = User(id=1, is_bot=False, first_name="user", last_name="user") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def transaction_partner_chat(): + return TransactionPartnerChat( + chat=TransactionPartnerTestBase.chat, + gift=TransactionPartnerTestBase.gift, + ) + + +class TestTransactionPartnerChatWithoutRequest(TransactionPartnerTestBase): + type = TransactionPartnerType.CHAT + + def test_slot_behaviour(self, transaction_partner_chat): + inst = transaction_partner_chat + 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, offline_bot): + json_dict = { + "chat": self.chat.to_dict(), + "gift": self.gift.to_dict(), + } + tp = TransactionPartnerChat.de_json(json_dict, offline_bot) + assert tp.api_kwargs == {} + assert tp.type == "chat" + assert tp.chat == self.chat + assert tp.gift == self.gift + + def test_to_dict(self, transaction_partner_chat): + json_dict = transaction_partner_chat.to_dict() + assert json_dict["type"] == self.type + assert json_dict["chat"] == self.chat.to_dict() + assert json_dict["gift"] == self.gift.to_dict() + + def test_equality(self, transaction_partner_chat): + a = transaction_partner_chat + b = TransactionPartnerChat( + chat=self.chat, + gift=self.gift, + ) + c = TransactionPartnerChat( + chat=Chat(id=1, type=Chat.CHANNEL), + ) + d = Chat(id=1, type=Chat.CHANNEL) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/_payment/test_invoice.py b/tests/_payment/test_invoice.py index 68a2e447b7b..06985f552aa 100644 --- a/tests/_payment/test_invoice.py +++ b/tests/_payment/test_invoice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,27 +17,30 @@ # 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 asyncio +import datetime as dtm import pytest -from telegram import Invoice, LabeledPrice +from telegram import Invoice, LabeledPrice, ReplyParameters +from telegram.constants import ParseMode from telegram.error import BadRequest from telegram.request import RequestData +from tests.auxil.build_messages import make_message from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def invoice(): return Invoice( - TestInvoiceBase.title, - TestInvoiceBase.description, - TestInvoiceBase.start_parameter, - TestInvoiceBase.currency, - TestInvoiceBase.total_amount, + InvoiceTestBase.title, + InvoiceTestBase.description, + InvoiceTestBase.start_parameter, + InvoiceTestBase.currency, + InvoiceTestBase.total_amount, ) -class TestInvoiceBase: +class InvoiceTestBase: payload = "payload" prices = [LabeledPrice("Fish", 100), LabeledPrice("Fish Tax", 1000)] provider_data = """{"test":"test"}""" @@ -50,13 +53,13 @@ class TestInvoiceBase: suggested_tip_amounts = [13, 42] -class TestInvoiceWithoutRequest(TestInvoiceBase): +class TestInvoiceWithoutRequest(InvoiceTestBase): def test_slot_behaviour(self, invoice): for attr in invoice.__slots__: assert getattr(invoice, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(invoice)) == len(set(mro_slots(invoice))), "duplicate slot" - def test_de_json(self, bot): + def test_de_json(self, offline_bot): invoice_json = Invoice.de_json( { "title": self.title, @@ -65,7 +68,7 @@ def test_de_json(self, bot): "currency": self.currency, "total_amount": self.total_amount, }, - bot, + offline_bot, ) assert invoice_json.api_kwargs == {} @@ -85,15 +88,15 @@ def test_to_dict(self, invoice): assert invoice_dict["currency"] == invoice.currency assert invoice_dict["total_amount"] == invoice.total_amount - async def test_send_invoice_all_args_mock(self, bot, monkeypatch): + async def test_send_invoice_all_args_mock(self, offline_bot, monkeypatch): # We do this one as safety guard to make sure that we pass all of the optional # parameters correctly because #2526 went unnoticed for 3 years … async def make_assertion(*args, **_): kwargs = args[1] return all(kwargs[key] == key for key in kwargs) - monkeypatch.setattr(bot, "_send_message", make_assertion) - assert await bot.send_invoice( + monkeypatch.setattr(offline_bot, "_send_message", make_assertion) + assert await offline_bot.send_invoice( chat_id="chat_id", title="title", description="description", @@ -120,13 +123,17 @@ async def make_assertion(*args, **_): protect_content=True, ) - async def test_send_all_args_create_invoice_link(self, bot, monkeypatch): - async def make_assertion(*args, **_): - kwargs = args[1] - return all(kwargs[i] == i for i in kwargs) + @pytest.mark.parametrize("subscription_period", [42, dtm.timedelta(seconds=42)]) + async def test_send_all_args_create_invoice_link( + self, offline_bot, monkeypatch, subscription_period + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + kwargs = request_data.parameters + sp = kwargs.pop("subscription_period") == 42 + return all(kwargs[i] == i for i in kwargs) and sp - monkeypatch.setattr(bot, "_post", make_assertion) - assert await bot.create_invoice_link( + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.create_invoice_link( title="title", description="description", payload="payload", @@ -147,15 +154,19 @@ async def make_assertion(*args, **_): send_phone_number_to_provider="send_phone_number_to_provider", send_email_to_provider="send_email_to_provider", is_flexible="is_flexible", + business_connection_id="business_connection_id", + subscription_period=subscription_period, ) - async def test_send_object_as_provider_data(self, monkeypatch, bot, chat_id, provider_token): + async def test_send_object_as_provider_data( + self, monkeypatch, offline_bot, chat_id, provider_token + ): async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.json_parameters["provider_data"] == '{"test_data": 123456789}' - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - assert await bot.send_invoice( + assert await offline_bot.send_invoice( chat_id, self.title, self.description, @@ -167,6 +178,40 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): start_parameter=self.start_parameter, ) + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_invoice_default_quote_parse_mode( + self, default_bot, chat_id, invoice, custom, monkeypatch, provider_token + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() + + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_invoice( + chat_id, + self.title, + self.description, + self.payload, + provider_token, + self.currency, + self.prices, + reply_parameters=ReplyParameters(**kwargs), + ) + def test_equality(self): a = Invoice("invoice", "desc", "start", "EUR", 7) b = Invoice("invoice", "desc", "start", "EUR", 7) @@ -183,7 +228,7 @@ def test_equality(self): assert hash(a) != hash(d) -class TestInvoiceWithRequest(TestInvoiceBase): +class TestInvoiceWithRequest(InvoiceTestBase): async def test_send_required_args_only(self, bot, chat_id, provider_token): message = await bot.send_invoice( chat_id=chat_id, @@ -224,9 +269,9 @@ async def test_send_invoice_default_protect_content( self.title, self.description, self.payload, - provider_token, self.currency, self.prices, + provider_token, **kwargs, ) for kwargs in ({}, {"protect_content": False}) @@ -256,35 +301,35 @@ async def test_send_invoice_default_allow_sending_without_reply( self.title, self.description, self.payload, - provider_token, - self.currency, - self.prices, + "XTR", + [self.prices[0]], allow_sending_without_reply=custom, reply_to_message_id=reply_to_message.message_id, ) assert message.reply_to_message is None + assert message.invoice.currency == "XTR" elif default_bot.defaults.allow_sending_without_reply: message = await default_bot.send_invoice( chat_id, self.title, self.description, self.payload, - provider_token, self.currency, self.prices, + provider_token, reply_to_message_id=reply_to_message.message_id, ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="message not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_invoice( chat_id, self.title, self.description, self.payload, - provider_token, self.currency, self.prices, + provider_token, reply_to_message_id=reply_to_message.message_id, ) @@ -294,15 +339,17 @@ async def test_send_all_args_send_invoice(self, bot, chat_id, provider_token): self.title, self.description, self.payload, - provider_token, self.currency, self.prices, + provider_token=provider_token, max_tip_amount=self.max_tip_amount, suggested_tip_amounts=self.suggested_tip_amounts, start_parameter=self.start_parameter, provider_data=self.provider_data, - photo_url="https://raw.githubusercontent.com/" - "python-telegram-bot/logos/master/logo/png/ptb-logo_240.png", + photo_url=( + "https://raw.githubusercontent.com/" + "python-telegram-bot/logos/master/logo/png/ptb-logo_240.png" + ), photo_size=240, photo_width=240, photo_height=240, diff --git a/tests/_payment/test_labeledprice.py b/tests/_payment/test_labeledprice.py index a7c14d4fbe8..babea2aae62 100644 --- a/tests/_payment/test_labeledprice.py +++ b/tests/_payment/test_labeledprice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -24,15 +24,15 @@ @pytest.fixture(scope="module") def labeled_price(): - return LabeledPrice(TestLabeledPriceBase.label, TestLabeledPriceBase.amount) + return LabeledPrice(LabeledPriceTestBase.label, LabeledPriceTestBase.amount) -class TestLabeledPriceBase: +class LabeledPriceTestBase: label = "label" amount = 100 -class TestLabeledPriceWithoutRequest(TestLabeledPriceBase): +class TestLabeledPriceWithoutRequest(LabeledPriceTestBase): def test_slot_behaviour(self, labeled_price): inst = labeled_price for attr in inst.__slots__: diff --git a/tests/_payment/test_orderinfo.py b/tests/_payment/test_orderinfo.py index bcfc9dd2495..3f12a4b582e 100644 --- a/tests/_payment/test_orderinfo.py +++ b/tests/_payment/test_orderinfo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,34 +25,34 @@ @pytest.fixture(scope="module") def order_info(): return OrderInfo( - TestOrderInfoBase.name, - TestOrderInfoBase.phone_number, - TestOrderInfoBase.email, - TestOrderInfoBase.shipping_address, + OrderInfoTestBase.name, + OrderInfoTestBase.phone_number, + OrderInfoTestBase.email, + OrderInfoTestBase.shipping_address, ) -class TestOrderInfoBase: +class OrderInfoTestBase: name = "name" phone_number = "phone_number" email = "email" shipping_address = ShippingAddress("GB", "", "London", "12 Grimmauld Place", "", "WC1") -class TestOrderInfoWithoutRequest(TestOrderInfoBase): +class TestOrderInfoWithoutRequest(OrderInfoTestBase): def test_slot_behaviour(self, order_info): for attr in order_info.__slots__: assert getattr(order_info, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(order_info)) == len(set(mro_slots(order_info))), "duplicate slot" - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "name": self.name, "phone_number": self.phone_number, "email": self.email, "shipping_address": self.shipping_address.to_dict(), } - order_info = OrderInfo.de_json(json_dict, bot) + order_info = OrderInfo.de_json(json_dict, offline_bot) assert order_info.api_kwargs == {} assert order_info.name == self.name diff --git a/tests/_payment/test_precheckoutquery.py b/tests/_payment/test_precheckoutquery.py index 0fd0e103ebf..e8352d52be7 100644 --- a/tests/_payment/test_precheckoutquery.py +++ b/tests/_payment/test_precheckoutquery.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -31,19 +31,19 @@ @pytest.fixture(scope="module") def pre_checkout_query(bot): pcq = PreCheckoutQuery( - TestPreCheckoutQueryBase.id_, - TestPreCheckoutQueryBase.from_user, - TestPreCheckoutQueryBase.currency, - TestPreCheckoutQueryBase.total_amount, - TestPreCheckoutQueryBase.invoice_payload, - shipping_option_id=TestPreCheckoutQueryBase.shipping_option_id, - order_info=TestPreCheckoutQueryBase.order_info, + PreCheckoutQueryTestBase.id_, + PreCheckoutQueryTestBase.from_user, + PreCheckoutQueryTestBase.currency, + PreCheckoutQueryTestBase.total_amount, + PreCheckoutQueryTestBase.invoice_payload, + shipping_option_id=PreCheckoutQueryTestBase.shipping_option_id, + order_info=PreCheckoutQueryTestBase.order_info, ) pcq.set_bot(bot) return pcq -class TestPreCheckoutQueryBase: +class PreCheckoutQueryTestBase: id_ = 5 invoice_payload = "invoice_payload" shipping_option_id = "shipping_option_id" @@ -53,14 +53,14 @@ class TestPreCheckoutQueryBase: order_info = OrderInfo() -class TestPreCheckoutQueryWithoutRequest(TestPreCheckoutQueryBase): +class TestPreCheckoutQueryWithoutRequest(PreCheckoutQueryTestBase): def test_slot_behaviour(self, pre_checkout_query): inst = pre_checkout_query 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): + def test_de_json(self, offline_bot): json_dict = { "id": self.id_, "invoice_payload": self.invoice_payload, @@ -70,10 +70,10 @@ def test_de_json(self, bot): "from": self.from_user.to_dict(), "order_info": self.order_info.to_dict(), } - pre_checkout_query = PreCheckoutQuery.de_json(json_dict, bot) + pre_checkout_query = PreCheckoutQuery.de_json(json_dict, offline_bot) assert pre_checkout_query.api_kwargs == {} - assert pre_checkout_query.get_bot() is bot + assert pre_checkout_query.get_bot() is offline_bot assert pre_checkout_query.id == self.id_ assert pre_checkout_query.invoice_payload == self.invoice_payload assert pre_checkout_query.shipping_option_id == self.shipping_option_id diff --git a/tests/_payment/test_refundedpayment.py b/tests/_payment/test_refundedpayment.py new file mode 100644 index 00000000000..cff668fad3b --- /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-2026 +# 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( + RefundedPaymentTestBase.currency, + RefundedPaymentTestBase.total_amount, + RefundedPaymentTestBase.invoice_payload, + RefundedPaymentTestBase.telegram_payment_charge_id, + RefundedPaymentTestBase.provider_payment_charge_id, + ) + + +class RefundedPaymentTestBase: + 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(RefundedPaymentTestBase): + 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, offline_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, offline_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_shippingaddress.py b/tests/_payment/test_shippingaddress.py index 271539b3130..8e7348afea7 100644 --- a/tests/_payment/test_shippingaddress.py +++ b/tests/_payment/test_shippingaddress.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,16 +25,16 @@ @pytest.fixture(scope="module") def shipping_address(): return ShippingAddress( - TestShippingAddressBase.country_code, - TestShippingAddressBase.state, - TestShippingAddressBase.city, - TestShippingAddressBase.street_line1, - TestShippingAddressBase.street_line2, - TestShippingAddressBase.post_code, + ShippingAddressTestBase.country_code, + ShippingAddressTestBase.state, + ShippingAddressTestBase.city, + ShippingAddressTestBase.street_line1, + ShippingAddressTestBase.street_line2, + ShippingAddressTestBase.post_code, ) -class TestShippingAddressBase: +class ShippingAddressTestBase: country_code = "GB" state = "state" city = "London" @@ -43,14 +43,14 @@ class TestShippingAddressBase: post_code = "WC1" -class TestShippingAddressWithoutRequest(TestShippingAddressBase): +class TestShippingAddressWithoutRequest(ShippingAddressTestBase): def test_slot_behaviour(self, shipping_address): inst = shipping_address 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): + def test_de_json(self, offline_bot): json_dict = { "country_code": self.country_code, "state": self.state, @@ -59,7 +59,7 @@ def test_de_json(self, bot): "street_line2": self.street_line2, "post_code": self.post_code, } - shipping_address = ShippingAddress.de_json(json_dict, bot) + shipping_address = ShippingAddress.de_json(json_dict, offline_bot) assert shipping_address.api_kwargs == {} assert shipping_address.country_code == self.country_code diff --git a/tests/_payment/test_shippingoption.py b/tests/_payment/test_shippingoption.py index be09d9ce204..953e2cf25d6 100644 --- a/tests/_payment/test_shippingoption.py +++ b/tests/_payment/test_shippingoption.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,17 +25,17 @@ @pytest.fixture(scope="module") def shipping_option(): return ShippingOption( - TestShippingOptionBase.id_, TestShippingOptionBase.title, TestShippingOptionBase.prices + ShippingOptionTestBase.id_, ShippingOptionTestBase.title, ShippingOptionTestBase.prices ) -class TestShippingOptionBase: +class ShippingOptionTestBase: id_ = "id" title = "title" prices = [LabeledPrice("Fish Container", 100), LabeledPrice("Premium Fish Container", 1000)] -class TestShippingOptionWithoutRequest(TestShippingOptionBase): +class TestShippingOptionWithoutRequest(ShippingOptionTestBase): def test_slot_behaviour(self, shipping_option): inst = shipping_option for attr in inst.__slots__: diff --git a/tests/_payment/test_shippingquery.py b/tests/_payment/test_shippingquery.py index ba405eb084a..23606faedce 100644 --- a/tests/_payment/test_shippingquery.py +++ b/tests/_payment/test_shippingquery.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -31,44 +31,44 @@ @pytest.fixture(scope="module") def shipping_query(bot): sq = ShippingQuery( - TestShippingQueryBase.id_, - TestShippingQueryBase.from_user, - TestShippingQueryBase.invoice_payload, - TestShippingQueryBase.shipping_address, + ShippingQueryTestBase.id_, + ShippingQueryTestBase.from_user, + ShippingQueryTestBase.invoice_payload, + ShippingQueryTestBase.shipping_address, ) sq.set_bot(bot) return sq -class TestShippingQueryBase: +class ShippingQueryTestBase: id_ = "5" invoice_payload = "invoice_payload" from_user = User(0, "", False) shipping_address = ShippingAddress("GB", "", "London", "12 Grimmauld Place", "", "WC1") -class TestShippingQueryWithoutRequest(TestShippingQueryBase): +class TestShippingQueryWithoutRequest(ShippingQueryTestBase): def test_slot_behaviour(self, shipping_query): inst = shipping_query 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): + def test_de_json(self, offline_bot): json_dict = { "id": self.id_, "invoice_payload": self.invoice_payload, "from": self.from_user.to_dict(), "shipping_address": self.shipping_address.to_dict(), } - shipping_query = ShippingQuery.de_json(json_dict, bot) + shipping_query = ShippingQuery.de_json(json_dict, offline_bot) assert shipping_query.api_kwargs == {} assert shipping_query.id == self.id_ assert shipping_query.invoice_payload == self.invoice_payload assert shipping_query.from_user == self.from_user assert shipping_query.shipping_address == self.shipping_address - assert shipping_query.get_bot() is bot + assert shipping_query.get_bot() is offline_bot def test_to_dict(self, shipping_query): shipping_query_dict = shipping_query.to_dict() diff --git a/tests/_payment/test_successfulpayment.py b/tests/_payment/test_successfulpayment.py index a2bc81f169a..51d485052d6 100644 --- a/tests/_payment/test_successfulpayment.py +++ b/tests/_payment/test_successfulpayment.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,26 +16,32 @@ # # 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 as dtm + import pytest from telegram import OrderInfo, SuccessfulPayment +from telegram._utils.datetime import UTC, to_timestamp from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def successful_payment(): return SuccessfulPayment( - TestSuccessfulPaymentBase.currency, - TestSuccessfulPaymentBase.total_amount, - TestSuccessfulPaymentBase.invoice_payload, - TestSuccessfulPaymentBase.telegram_payment_charge_id, - TestSuccessfulPaymentBase.provider_payment_charge_id, - shipping_option_id=TestSuccessfulPaymentBase.shipping_option_id, - order_info=TestSuccessfulPaymentBase.order_info, + SuccessfulPaymentTestBase.currency, + SuccessfulPaymentTestBase.total_amount, + SuccessfulPaymentTestBase.invoice_payload, + SuccessfulPaymentTestBase.telegram_payment_charge_id, + SuccessfulPaymentTestBase.provider_payment_charge_id, + shipping_option_id=SuccessfulPaymentTestBase.shipping_option_id, + order_info=SuccessfulPaymentTestBase.order_info, + subscription_expiration_date=SuccessfulPaymentTestBase.subscription_expiration_date, + is_recurring=SuccessfulPaymentTestBase.is_recurring, + is_first_recurring=SuccessfulPaymentTestBase.is_first_recurring, ) -class TestSuccessfulPaymentBase: +class SuccessfulPaymentTestBase: invoice_payload = "invoice_payload" shipping_option_id = "shipping_option_id" currency = "EUR" @@ -43,16 +49,19 @@ class TestSuccessfulPaymentBase: order_info = OrderInfo() telegram_payment_charge_id = "telegram_payment_charge_id" provider_payment_charge_id = "provider_payment_charge_id" + subscription_expiration_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + is_recurring = True + is_first_recurring = True -class TestSuccessfulPaymentWithoutRequest(TestSuccessfulPaymentBase): +class TestSuccessfulPaymentWithoutRequest(SuccessfulPaymentTestBase): def test_slot_behaviour(self, successful_payment): inst = successful_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): + def test_de_json(self, offline_bot): json_dict = { "invoice_payload": self.invoice_payload, "shipping_option_id": self.shipping_option_id, @@ -61,16 +70,46 @@ def test_de_json(self, bot): "order_info": self.order_info.to_dict(), "telegram_payment_charge_id": self.telegram_payment_charge_id, "provider_payment_charge_id": self.provider_payment_charge_id, + "subscription_expiration_date": to_timestamp(self.subscription_expiration_date), + "is_recurring": self.is_recurring, + "is_first_recurring": self.is_first_recurring, } - successful_payment = SuccessfulPayment.de_json(json_dict, bot) + successful_payment = SuccessfulPayment.de_json(json_dict, offline_bot) assert successful_payment.api_kwargs == {} 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 + assert successful_payment.subscription_expiration_date == self.subscription_expiration_date + assert successful_payment.is_recurring == self.is_recurring + assert successful_payment.is_first_recurring == self.is_first_recurring + + def test_de_json_localization(self, offline_bot, raw_bot, tz_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, + "subscription_expiration_date": to_timestamp(self.subscription_expiration_date), + } + successful_payment = SuccessfulPayment.de_json(json_dict, offline_bot) + successful_payment_raw = SuccessfulPayment.de_json(json_dict, raw_bot) + successful_payment_tz = SuccessfulPayment.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + date_offset = successful_payment_tz.subscription_expiration_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + successful_payment_tz.subscription_expiration_date.replace(tzinfo=None) + ) + + assert successful_payment_raw.subscription_expiration_date.tzinfo == UTC + assert successful_payment.subscription_expiration_date.tzinfo == UTC + assert date_offset == tz_bot_offset def test_to_dict(self, successful_payment): successful_payment_dict = successful_payment.to_dict() @@ -81,6 +120,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"] @@ -90,6 +130,13 @@ def test_to_dict(self, successful_payment): successful_payment_dict["provider_payment_charge_id"] == successful_payment.provider_payment_charge_id ) + assert successful_payment_dict["subscription_expiration_date"] == to_timestamp( + successful_payment.subscription_expiration_date + ) + assert successful_payment_dict["is_recurring"] == successful_payment.is_recurring + assert ( + successful_payment_dict["is_first_recurring"] == successful_payment.is_first_recurring + ) def test_equality(self): a = SuccessfulPayment( diff --git a/tests/_utils/__init__.py b/tests/_utils/__init__.py index 1eaba12c869..c95cb3c9741 100644 --- a/tests/_utils/__init__.py +++ b/tests/_utils/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_utils/test_datetime.py b/tests/_utils/test_datetime.py index ca89390bcf3..7267449d2a2 100644 --- a/tests/_utils/test_datetime.py +++ b/tests/_utils/test_datetime.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,10 +18,12 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. import datetime as dtm import time +import zoneinfo import pytest from telegram._utils import datetime as tg_dtm +from telegram._utils.datetime import get_zone_info from telegram.ext import Defaults # sample time specification values categorised into absolute / delta / time-of-day @@ -55,18 +57,38 @@ class TestDatetime: - @staticmethod - def localize(dt, tzinfo): - if TEST_WITH_OPT_DEPS: - return tzinfo.localize(dt) - return dt.replace(tzinfo=tzinfo) - - def test_helpers_utc(self): - # Here we just test, that we got the correct UTC variant - if not TEST_WITH_OPT_DEPS: - assert tg_dtm.UTC is tg_dtm.DTM_UTC - else: - assert tg_dtm.UTC is not tg_dtm.DTM_UTC + def test_localize_utc(self): + dt = dtm.datetime(2023, 1, 1, 12, 0, 0) + localized_dt = tg_dtm.localize(dt, tg_dtm.UTC) + assert localized_dt.tzinfo == tg_dtm.UTC + assert localized_dt == dt.replace(tzinfo=tg_dtm.UTC) + + @pytest.mark.skipif(not TEST_WITH_OPT_DEPS, reason="pytz not installed") + def test_localize_pytz(self): + dt = dtm.datetime(2023, 1, 1, 12, 0, 0) + import pytz # noqa: PLC0415 + + tzinfo = pytz.timezone("Europe/Berlin") + localized_dt = tg_dtm.localize(dt, tzinfo) + assert localized_dt.hour == dt.hour + assert localized_dt.tzinfo is not None + assert tzinfo.utcoffset(dt) is not None + + def test_localize_zoneinfo_naive(self): + dt = dtm.datetime(2023, 1, 1, 12, 0, 0) + tzinfo = zoneinfo.ZoneInfo("Europe/Berlin") + localized_dt = tg_dtm.localize(dt, tzinfo) + assert localized_dt.hour == dt.hour + assert localized_dt.tzinfo is not None + assert tzinfo.utcoffset(dt) is not None + + def test_localize_zoneinfo_aware(self): + dt = dtm.datetime(2023, 1, 1, 12, 0, 0, tzinfo=dtm.timezone.utc) + tzinfo = zoneinfo.ZoneInfo("Europe/Berlin") + localized_dt = tg_dtm.localize(dt, tzinfo) + assert localized_dt.hour == dt.hour + 1 + assert localized_dt.tzinfo is not None + assert tzinfo.utcoffset(dt) is not None def test_to_float_timestamp_absolute_naive(self): """Conversion from timezone-naive datetime to timestamp. @@ -75,20 +97,12 @@ def test_to_float_timestamp_absolute_naive(self): datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5) assert tg_dtm.to_float_timestamp(datetime) == 1573431976.1 - def test_to_float_timestamp_absolute_naive_no_pytz(self, monkeypatch): - """Conversion from timezone-naive datetime to timestamp. - Naive datetimes should be assumed to be in UTC. - """ - monkeypatch.setattr(tg_dtm, "UTC", tg_dtm.DTM_UTC) - datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5) - assert tg_dtm.to_float_timestamp(datetime) == 1573431976.1 - def test_to_float_timestamp_absolute_aware(self, timezone): """Conversion from timezone-aware datetime to timestamp""" # we're parametrizing this with two different UTC offsets to exclude the possibility # of an xpass when the test is run in a timezone with the same UTC offset test_datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5) - datetime = self.localize(test_datetime, timezone) + datetime = tg_dtm.localize(test_datetime, timezone) assert ( tg_dtm.to_float_timestamp(datetime) == 1573431976.1 - timezone.utcoffset(test_datetime).total_seconds() @@ -105,9 +119,9 @@ def test_to_float_timestamp_delta(self): reference_t = 0 for i in DELTA_TIME_SPECS: delta = i.total_seconds() if hasattr(i, "total_seconds") else i - assert ( - tg_dtm.to_float_timestamp(i, reference_t) == reference_t + delta - ), f"failed for {i}" + assert tg_dtm.to_float_timestamp(i, reference_t) == reference_t + delta, ( + f"failed for {i}" + ) def test_to_float_timestamp_time_of_day(self): """Conversion from time-of-day specification to timestamp""" @@ -125,8 +139,11 @@ def test_to_float_timestamp_time_of_day_timezone(self, timezone): # of an xpass when the test is run in a timezone with the same UTC offset ref_datetime = dtm.datetime(1970, 1, 1, 12) utc_offset = timezone.utcoffset(ref_datetime) - ref_t, time_of_day = tg_dtm._datetime_to_float_timestamp(ref_datetime), ref_datetime.time() - aware_time_of_day = self.localize(ref_datetime, timezone).timetz() + ref_t, time_of_day = ( + tg_dtm._datetime_to_float_timestamp(ref_datetime), + ref_datetime.time(), + ) + aware_time_of_day = tg_dtm.localize(ref_datetime, timezone).timetz() # first test that naive time is assumed to be utc: assert tg_dtm.to_float_timestamp(time_of_day, ref_t) == pytest.approx(ref_t) @@ -155,7 +172,7 @@ def test_to_timestamp(self): assert tg_dtm.to_timestamp(i) == int(tg_dtm.to_float_timestamp(i)), f"Failed for {i}" def test_to_timestamp_none(self): - # this 'convenience' behaviour has been left left for backwards compatibility + # this 'convenience' behaviour has been left for backwards compatibility assert tg_dtm.to_timestamp(None) is None def test_from_timestamp_none(self): @@ -169,7 +186,7 @@ def test_from_timestamp_aware(self, timezone): # we're parametrizing this with two different UTC offsets to exclude the possibility # of an xpass when the test is run in a timezone with the same UTC offset test_datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5) - datetime = self.localize(test_datetime, timezone) + datetime = tg_dtm.localize(test_datetime, timezone) assert ( tg_dtm.from_timestamp(1573431976.1 - timezone.utcoffset(test_datetime).total_seconds()) == datetime @@ -179,3 +196,35 @@ def test_extract_tzinfo_from_defaults(self, tz_bot, bot, raw_bot): assert tg_dtm.extract_tzinfo_from_defaults(tz_bot) == tz_bot.defaults.tzinfo assert tg_dtm.extract_tzinfo_from_defaults(bot) is None assert tg_dtm.extract_tzinfo_from_defaults(raw_bot) is None + + def test_get_zone_info_with_valid_timezone_string(self): + """Test with a valid timezone string.""" + tz = "Asia/Tokyo" + result = get_zone_info(tz) + assert isinstance(result, zoneinfo.ZoneInfo) + assert str(result) == "Asia/Tokyo" + + def test_get_zone_info_with_invalid_timezone_string(self): + """Test with an invalid timezone string.""" + with pytest.raises( + zoneinfo.ZoneInfoNotFoundError, + match=r"No time zone found.*Invalid/Timezone.*install the tzdata", + ): + get_zone_info("Invalid/Timezone") + + @pytest.mark.parametrize( + ("arg", "timedelta_result", "number_result"), + [ + (None, None, None), + (dtm.timedelta(seconds=10), dtm.timedelta(seconds=10), 10), + (dtm.timedelta(seconds=10.5), dtm.timedelta(seconds=10.5), 10.5), + ], + ) + def test_get_timedelta_value(self, PTB_TIMEDELTA, arg, timedelta_result, number_result): + result = tg_dtm.get_timedelta_value(arg, attribute="") + + if PTB_TIMEDELTA: + assert result == timedelta_result + else: + assert result == number_result + assert type(result) is type(number_result) diff --git a/tests/_utils/test_defaultvalue.py b/tests/_utils/test_defaultvalue.py index 04d72614fb6..b9a8f8eb99c 100644 --- a/tests/_utils/test_defaultvalue.py +++ b/tests/_utils/test_defaultvalue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/_utils/test_files.py b/tests/_utils/test_files.py index 9ec70c43345..8797ffcb9c4 100644 --- a/tests/_utils/test_files.py +++ b/tests/_utils/test_files.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -33,8 +33,6 @@ class TestFiles: @pytest.mark.parametrize( ("string", "expected"), [ - (str(data_file("game.gif")), True), - (str(TEST_DATA_PATH), False), (str(data_file("game.gif")), True), (str(TEST_DATA_PATH), False), (data_file("game.gif"), True), @@ -77,7 +75,7 @@ def test_parse_file_input_string(self, string, expected_local, expected_non_loca telegram._utils.files.parse_file_input(string, local_mode=False), InputFile ) elif expected_non_local is ValueError: - with pytest.raises(ValueError, match="but local mode is not enabled."): + with pytest.raises(ValueError, match="but local mode is not enabled\\."): telegram._utils.files.parse_file_input(string, local_mode=False) else: assert ( @@ -158,3 +156,13 @@ def test_load_file_subprocess_pipe(self): proc.kill() # This exception may be thrown if the process has finished before we had the chance # to kill it. + + @pytest.mark.filterwarnings("error::ResourceWarning") + def test_parse_file_input_path_no_resource_warning(self): + """Test that parsing a Path input doesn't generate ResourceWarning.""" + test_file = data_file(filename="telegram.png") + + # This should not raise a ResourceWarning + result = telegram._utils.files.parse_file_input(test_file) + assert isinstance(result, InputFile) + assert result.filename.endswith(".png") diff --git a/tests/auxil/__init__.py b/tests/auxil/__init__.py index 1eaba12c869..c95cb3c9741 100644 --- a/tests/auxil/__init__.py +++ b/tests/auxil/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/auxil/asyncio_helpers.py b/tests/auxil/asyncio_helpers.py index a9fbfb103de..ab97105976a 100644 --- a/tests/auxil/asyncio_helpers.py +++ b/tests/auxil/asyncio_helpers.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -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/]. import asyncio -from typing import Callable +from collections.abc import Callable def call_after(function: Callable, after: Callable): diff --git a/tests/auxil/bot_method_checks.py b/tests/auxil/bot_method_checks.py index 3a1c8b25e80..1d2c4a923b6 100644 --- a/tests/auxil/bot_method_checks.py +++ b/tests/auxil/bot_method_checks.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,11 +17,15 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Provides functions to test both methods.""" -import datetime + +import datetime as dtm import functools import inspect import re -from typing import Any, Callable, Dict, Iterable, List, Optional +import zoneinfo +from collections.abc import Callable, Collection, Iterable +from types import GenericAlias +from typing import Any, ForwardRef import pytest @@ -29,22 +33,22 @@ from telegram import ( Bot, ChatPermissions, - File, InlineQueryResultArticle, InlineQueryResultCachedPhoto, InputMediaPhoto, + InputPaidMediaPhoto, InputTextMessageContent, + LinkPreviewOptions, + ReplyParameters, + Sticker, TelegramObject, ) +from telegram._utils.datetime import to_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue from telegram.constants import InputMediaType from telegram.ext import Defaults, ExtBot from telegram.request import RequestData -from tests.auxil.envvars import TEST_WITH_OPT_DEPS - -if TEST_WITH_OPT_DEPS: - import pytz - +from tests.auxil.dummy_objects import get_dummy_object_json_dict FORWARD_REF_PATTERN = re.compile(r"ForwardRef\('(?P\w+)'\)") """ A pattern to find a class name in a ForwardRef typing annotation. @@ -55,8 +59,9 @@ def check_shortcut_signature( shortcut: Callable, bot_method: Callable, - shortcut_kwargs: List[str], - additional_kwargs: List[str], + shortcut_kwargs: list[str], + additional_kwargs: list[str], + annotation_overrides: dict[str, tuple[Any, Any]] | None = None, ) -> bool: """ Checks that the signature of a shortcut matches the signature of the underlying bot method. @@ -66,13 +71,17 @@ def check_shortcut_signature( bot_method: The bot method, e.g. :meth:`telegram.Bot.send_message` shortcut_kwargs: The kwargs passed by the shortcut directly, e.g. ``chat_id`` additional_kwargs: Additional kwargs of the shortcut that the bot method doesn't have, e.g. - ``quote``. + ``do_quote``. + annotation_overrides: A dictionary of exceptions for the annotation comparison. The key is + the name of the argument, the value is a tuple of the expected annotation and + the default value. E.g. ``{'parse_mode': (str, 'None')}``. Returns: :obj:`bool`: Whether or not the signature matches. """ + annotation_overrides = annotation_overrides or {} - def resolve_class(class_name: str) -> Optional[type]: + def resolve_class(class_name: str) -> type | None: """Attempts to resolve a PTB class (telegram module only) from a ForwardRef. E.g. resolves from "StickerSet". @@ -93,9 +102,18 @@ def resolve_class(class_name: str) -> Optional[type]: expected_args = set(bot_sig.parameters.keys()).difference(shortcut_kwargs) expected_args.discard("self") - args_check = expected_args == effective_shortcut_args - if not args_check: - raise Exception(f"Expected arguments {expected_args}, got {effective_shortcut_args}") + len_expected = len(expected_args) + len_effective = len(effective_shortcut_args) + if len_expected > len_effective: + raise Exception( + f"Shortcut signature is missing {len_expected - len_effective} arguments " + f"of the underlying Bot method: {expected_args - effective_shortcut_args}" + ) + if len_expected < len_effective: + raise Exception( + f"Shortcut signature has {len_effective - len_expected} additional arguments " + f"that the Bot method doesn't have: {effective_shortcut_args - expected_args}" + ) # TODO: Also check annotation of return type. Would currently be a hassle b/c typing doesn't # resolve `ForwardRef('Type')` to `Type`. For now we rely on MyPy, which probably allows the @@ -106,6 +124,14 @@ def resolve_class(class_name: str) -> Optional[type]: if shortcut_sig.parameters[kwarg].kind != expected_kind: raise Exception(f"Argument {kwarg} must be of kind {expected_kind}.") + if kwarg in annotation_overrides: + if shortcut_sig.parameters[kwarg].annotation != annotation_overrides[kwarg][0]: + raise Exception( + f"For argument {kwarg} I expected {annotation_overrides[kwarg]}, " + f"but got {shortcut_sig.parameters[kwarg].annotation}" + ) + continue + if bot_sig.parameters[kwarg].annotation != shortcut_sig.parameters[kwarg].annotation: if FORWARD_REF_PATTERN.search(str(shortcut_sig.parameters[kwarg])): # If a shortcut signature contains a ForwardRef, the simple comparison of @@ -114,6 +140,7 @@ def resolve_class(class_name: str) -> Optional[type]: for shortcut_arg, bot_arg in zip( shortcut_sig.parameters[kwarg].annotation.__args__, bot_sig.parameters[kwarg].annotation.__args__, + strict=False, ): shortcut_arg_to_check = shortcut_arg # for ruff match = FORWARD_REF_PATTERN.search(str(shortcut_arg)) @@ -144,12 +171,22 @@ def resolve_class(class_name: str) -> Optional[type]: bot_method_sig = inspect.signature(bot_method) shortcut_sig = inspect.signature(shortcut) for arg in expected_args: + if arg in annotation_overrides: + if shortcut_sig.parameters[arg].default == annotation_overrides[arg][1]: + continue + raise Exception( + f"For argument {arg} I expected default {annotation_overrides[arg][1]}, " + f"but got {shortcut_sig.parameters[arg].default}" + ) if not shortcut_sig.parameters[arg].default == bot_method_sig.parameters[arg].default: raise Exception( f"Default for argument {arg} does not match the default of the Bot method." ) for kwarg in additional_kwargs: + if kwarg == "reply_to_message_id": + # special case for deprecated argument of Message.reply_* + continue if not shortcut_sig.parameters[kwarg].kind == inspect.Parameter.KEYWORD_ONLY: raise Exception(f"Argument {kwarg} must be a positional-only argument!") @@ -160,8 +197,8 @@ async def check_shortcut_call( shortcut_method: Callable, bot: ExtBot, bot_method_name: str, - skip_params: Optional[Iterable[str]] = None, - shortcut_kwargs: Optional[Iterable[str]] = None, + skip_params: Iterable[str] | None = None, + shortcut_kwargs: Iterable[str] | None = None, ) -> bool: """ Checks that a shortcut passes all the existing arguments to the underlying bot method. Use as:: @@ -193,25 +230,35 @@ async def check_shortcut_call( shortcut_signature = inspect.signature(shortcut_method) # auto_pagination: Special casing for InlineQuery.answer - kwargs = {name: name for name in shortcut_signature.parameters if name != "auto_pagination"} + kwargs = { + name: name for name in shortcut_signature.parameters if name not in ["auto_pagination"] + } + if "reply_parameters" in kwargs: + kwargs["reply_parameters"] = ReplyParameters(message_id=1) + + # We tested this for a long time, but Bot API 7.0 deprecated it in favor of + # reply_parameters. Testing both cases would require a lot of additional code, so we just + # ignore these parameters here. + for arg in ["reply_to_message_id", "allow_sending_without_reply"]: + kwargs.pop(arg, None) + expected_args.discard(arg) async def make_assertion(**kw): # name == value makes sure that # a) we receive non-None input for all parameters # b) we receive the correct input for each kwarg received_kwargs = { - name for name, value in kw.items() if name in ignored_args or value == name + name + for name, value in kw.items() + if name in ignored_args + or (value == name or (name == "reply_parameters" and value.message_id == 1)) } - if not received_kwargs == expected_args: + if received_kwargs != expected_args: raise Exception( f"{orig_bot_method.__name__} did not receive correct value for the parameters " f"{expected_args - received_kwargs}" ) - if bot_method_name == "get_file": - # This is here mainly for PassportFile.get_file, which calls .set_credentials on the - # return value - return File(file_id="result", file_unique_id="result") return True setattr(bot, bot_method_name, make_assertion) @@ -225,7 +272,9 @@ async def make_assertion(**kw): return True -def build_kwargs(signature: inspect.Signature, default_kwargs, dfv: Any = DEFAULT_NONE): +def build_kwargs( + signature: inspect.Signature, default_kwargs, manually_passed_value: Any = DEFAULT_NONE +): kws = {} for name, param in signature.parameters.items(): # For required params we need to pass something @@ -236,55 +285,307 @@ def build_kwargs(signature: inspect.Signature, default_kwargs, dfv: Any = DEFAUL elif name in ["prices", "commands", "errors"]: kws[name] = [] elif name == "media": - media = InputMediaPhoto("media", parse_mode=dfv) - if "list" in str(param.annotation).lower(): + if "star_count" in signature.parameters: + media = InputPaidMediaPhoto("media") + else: + media = InputMediaPhoto("media", parse_mode=manually_passed_value) + + param_annotation = str(param.annotation).lower() + if "sequence" in param_annotation or "list" in param_annotation: kws[name] = [media] else: kws[name] = media elif name == "results": itmc = InputTextMessageContent( - "text", parse_mode=dfv, disable_web_page_preview=dfv + "text", + parse_mode=manually_passed_value, + link_preview_options=LinkPreviewOptions( + is_disabled=manually_passed_value, url=manually_passed_value + ), ) kws[name] = [ InlineQueryResultArticle("id", "title", input_message_content=itmc), InlineQueryResultCachedPhoto( - "id", "photo_file_id", parse_mode=dfv, input_message_content=itmc + "id", + "photo_file_id", + parse_mode=manually_passed_value, + input_message_content=itmc, ), ] elif name == "ok": kws["ok"] = False kws["error_message"] = "error" + elif name == "options": + kws[name] = ["option1", "option2"] + elif name in ("sticker", "old_sticker"): + kws[name] = Sticker( + file_id="file_id", + file_unique_id="file_unique_id", + width=1, + height=1, + is_animated=False, + is_video=False, + type="regular", + ) else: kws[name] = True + # pass values for params that can have defaults only if we don't want to use the # standard default elif name in default_kwargs: - if dfv != DEFAULT_NONE: - kws[name] = dfv + if manually_passed_value != DEFAULT_NONE: + if name == "link_preview_options": + kws[name] = LinkPreviewOptions( + is_disabled=manually_passed_value, url=manually_passed_value + ) + else: + kws[name] = manually_passed_value # Some special casing for methods that have "exactly one of the optionals" type args elif name in ["location", "contact", "venue", "inline_message_id"]: kws[name] = True - # Special casing for some methods where the parameter is actually required, but is optional - # for compatibility reasons - # TODO: remove this once these arguments are marked as required - elif name in {"sticker", "stickers", "sticker_format"}: - kws[name] = "something passed" - elif name == "until_date": - if dfv == "non-None-value": + elif name.endswith("_date"): + if manually_passed_value not in [None, DEFAULT_NONE]: # Europe/Berlin - kws[name] = pytz.timezone("Europe/Berlin").localize( - datetime.datetime(2000, 1, 1, 0) - ) + kws[name] = dtm.datetime(2000, 1, 1, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Berlin")) else: - # UTC - kws[name] = datetime.datetime(2000, 1, 1, 0) + # naive UTC + kws[name] = dtm.datetime(2000, 1, 1, 0) + elif name == "reply_parameters": + kws[name] = telegram.ReplyParameters( + message_id=1, + allow_sending_without_reply=manually_passed_value, + quote_parse_mode=manually_passed_value, + ) + return kws +def make_assertion_for_link_preview_options( + expected_defaults_value, lpo, manual_value_expected, manually_passed_value +): + if not lpo: + return + + # if no_value_expected: + # # We always expect a value for link_preview_options, because we don't test the + # # case send_message(…, link_preview_options=None). Instead we focus on the more + # # compicated case of send_message(…, link_preview_options=LinkPreviewOptions(arg=None)) + if manual_value_expected: + if lpo.get("is_disabled") != manually_passed_value: + pytest.fail( + f"Got value {lpo.get('is_disabled')} for link_preview_options.is_disabled, " + f"expected it to be {manually_passed_value}" + ) + if lpo.get("url") != manually_passed_value: + pytest.fail( + f"Got value {lpo.get('url')} for link_preview_options.url, " + f"expected it to be {manually_passed_value}" + ) + if expected_defaults_value: + if lpo.get("show_above_text") != expected_defaults_value: + pytest.fail( + f"Got value {lpo.get('show_above_text')} for link_preview_options.show_above_text," + f" expected it to be {expected_defaults_value}" + ) + if manually_passed_value is DEFAULT_NONE and lpo.get("url") != expected_defaults_value: + pytest.fail( + f"Got value {lpo.get('url')} for link_preview_options.url, " + f"expected it to be {expected_defaults_value}" + ) + + +def _check_forward_ref(obj: object) -> str | object: + if isinstance(obj, ForwardRef): + return obj.__forward_arg__ + return obj + + +def guess_return_type_name(method: Callable[[...], Any]) -> tuple[str | object, bool]: + # Using typing.get_type_hints(method) would be the nicer as it also resolves ForwardRefs + # and string annotations. But it also wants to resolve the parameter annotations, which + # need additional namespaces and that's not worth the struggle for now … + return_annotation = _check_forward_ref(inspect.signature(method).return_annotation) + as_tuple = False + + if isinstance(return_annotation, GenericAlias): + if return_annotation.__origin__ is tuple: + as_tuple = True + else: + raise ValueError( + f"Return type of {method.__name__} is a GenericAlias. This can not be handled yet." + ) + + # For tuples and Unions, we simply take the first element + if hasattr(return_annotation, "__args__"): + return _check_forward_ref(return_annotation.__args__[0]), as_tuple + return return_annotation, as_tuple + + +_EUROPE_BERLIN_TS = to_timestamp( + dtm.datetime(2000, 1, 1, 0, tzinfo=zoneinfo.ZoneInfo("Europe/Berlin")) +) +_UTC_TS = to_timestamp(dtm.datetime(2000, 1, 1, 0), tzinfo=zoneinfo.ZoneInfo("UTC")) +_AMERICA_NEW_YORK_TS = to_timestamp( + dtm.datetime(2000, 1, 1, 0, tzinfo=zoneinfo.ZoneInfo("America/New_York")) +) + + +async def make_assertion( + url, + request_data: RequestData, + method_name: str, + kwargs_need_default: list[str], + return_value, + manually_passed_value: Any = DEFAULT_NONE, + expected_defaults_value: Any = DEFAULT_NONE, + *args, + **kwargs, +): + data = request_data.parameters + + no_value_expected = (manually_passed_value is None) or ( + manually_passed_value is DEFAULT_NONE and expected_defaults_value is None + ) + manual_value_expected = (manually_passed_value is not DEFAULT_NONE) and not no_value_expected + default_value_expected = (not manual_value_expected) and (not no_value_expected) + + # Check reply_parameters - needs special handling b/c we merge this with the default + # value for `allow_sending_without_reply` + reply_parameters = data.pop("reply_parameters", None) + if reply_parameters: + for param in ["allow_sending_without_reply", "quote_parse_mode"]: + if no_value_expected and param in reply_parameters: + pytest.fail(f"Got value for reply_parameters.{param}, expected it to be absent") + param_value = reply_parameters.get(param) + if manual_value_expected and param_value != manually_passed_value: + pytest.fail( + f"Got value {param_value} for reply_parameters.{param} " + f"instead of {manually_passed_value}" + ) + elif default_value_expected and param_value != expected_defaults_value: + pytest.fail( + f"Got value {param_value} for reply_parameters.{param} " + f"instead of {expected_defaults_value}" + ) + + # Check link_preview_options - needs special handling b/c we merge this with the default + # values specified in `Defaults.link_preview_options` + make_assertion_for_link_preview_options( + expected_defaults_value, + data.get("link_preview_options", None), + manual_value_expected, + manually_passed_value, + ) + + # Check regular arguments that need defaults + for arg in kwargs_need_default: + if arg == "link_preview_options": + # already handled above + continue + + # 'None' should not be passed along to Telegram + if no_value_expected and arg in data: + pytest.fail(f"Got value {data[arg]} for argument {arg}, expected it to be absent") + + value = data.get(arg, "`not passed at all`") + if manual_value_expected and value != manually_passed_value: + pytest.fail(f"Got value {value} for argument {arg} instead of {manually_passed_value}") + elif default_value_expected and value != expected_defaults_value: + pytest.fail( + f"Got value {value} for argument {arg} instead of {expected_defaults_value}" + ) + + # Check InputMedia (parse_mode can have a default) + def check_input_media(m: dict): + parse_mode = m.get("parse_mode") + if no_value_expected and parse_mode is not None: + pytest.fail("InputMedia has non-None parse_mode, expected it to be absent") + elif default_value_expected and parse_mode != expected_defaults_value: + pytest.fail( + f"Got value {parse_mode} for InputMedia.parse_mode instead " + f"of {expected_defaults_value}" + ) + elif manual_value_expected and parse_mode != manually_passed_value: + pytest.fail( + f"Got value {parse_mode} for InputMedia.parse_mode instead " + f"of {manually_passed_value}" + ) + + media = data.pop("media", None) + paid_media = media and data.pop("star_count", None) + if media and not paid_media: + if isinstance(media, dict) and isinstance(media.get("type", None), InputMediaType): + check_input_media(media) + else: + for m in media: + check_input_media(m) + + # Check InlineQueryResults + results = data.pop("results", []) + for result in results: + if no_value_expected and "parse_mode" in result: + pytest.fail("ILQR has a parse mode, expected it to be absent") + # Here we explicitly use that we only pass ILQRPhoto and ILQRArticle for testing + elif "photo" in result: + parse_mode = result.get("parse_mode") + if manually_passed_value and parse_mode != manually_passed_value: + pytest.fail( + f"Got value {parse_mode} for ILQR.parse_mode instead of " + f"{manually_passed_value}" + ) + elif default_value_expected and parse_mode != expected_defaults_value: + pytest.fail( + f"Got value {parse_mode} for ILQR.parse_mode instead of " + f"{expected_defaults_value}" + ) + + # Here we explicitly use that we only pass InputTextMessageContent for testing + # which has both parse_mode and link_preview_options + imc = result.get("input_message_content") + if not imc: + continue + if no_value_expected and "parse_mode" in imc: + pytest.fail("ILQR.i_m_c has a parse_mode, expected it to be absent") + parse_mode = imc.get("parse_mode") + if manual_value_expected and parse_mode != manually_passed_value: + pytest.fail( + f"Got value {imc.parse_mode} for ILQR.i_m_c.parse_mode " + f"instead of {manual_value_expected}" + ) + elif default_value_expected and parse_mode != expected_defaults_value: + pytest.fail( + f"Got value {imc.parse_mode} for ILQR.i_m_c.parse_mode " + f"instead of {expected_defaults_value}" + ) + + make_assertion_for_link_preview_options( + expected_defaults_value, + imc.get("link_preview_options", None), + manual_value_expected, + manually_passed_value, + ) + + # Check datetime conversion + date_keys = [key for key in data if key.endswith("_date")] + for key in date_keys: + date_param = data.pop(key) + if date_param: + if manual_value_expected and date_param != _EUROPE_BERLIN_TS: + pytest.fail(f"Non-naive `{key}` should have been interpreted as Europe/Berlin.") + if not any((manually_passed_value, expected_defaults_value)) and date_param != _UTC_TS: + pytest.fail(f"Naive `{key}` should have been interpreted as UTC") + if default_value_expected and date_param != _AMERICA_NEW_YORK_TS: + pytest.fail(f"Naive `{key}` should have been interpreted as America/New_York") + + if isinstance(return_value, TelegramObject): + return return_value.to_dict() + return return_value + + async def check_defaults_handling( method: Callable, bot: Bot, - return_value=None, + no_default_kwargs: Collection[str] = frozenset(), ) -> bool: """ Checks that tg.ext.Defaults are handled correctly. @@ -293,160 +594,97 @@ async def check_defaults_handling( method: The shortcut/bot_method bot: The bot. May be a telegram.Bot or a telegram.ext.ExtBot. In the former case, all default values will be converted to None. - return_value: Optional. The return value of Bot._post that the method expects. Defaults to - None. get_file is automatically handled. If this is a `TelegramObject`, Bot._post will - return the `to_dict` representation of it. + no_default_kwargs: Optional. A collection of keyword arguments that should not have default + values. Defaults to an empty frozenset. """ raw_bot = not isinstance(bot, ExtBot) get_updates = method.__name__.lower().replace("_", "") == "getupdates" shortcut_signature = inspect.signature(method) - kwargs_need_default = [ + kwargs_need_default = { kwarg for kwarg, value in shortcut_signature.parameters.items() - if isinstance(value.default, DefaultValue) and not kwarg.endswith("_timeout") - ] + if isinstance(value.default, DefaultValue) + and not kwarg.endswith("_timeout") + and kwarg not in no_default_kwargs + } + # We tested this for a long time, but Bot API 7.0 deprecated it in favor of + # reply_parameters. In the transition phase, both exist in a mutually exclusive + # way. Testing both cases would require a lot of additional code, so we for now are content + # with the explicit tests that we have inplace for allow_sending_without_reply + kwargs_need_default.discard("allow_sending_without_reply") if method.__name__.endswith("_media_group"): # the parse_mode is applied to the first media item, and we test this elsewhere kwargs_need_default.remove("parse_mode") defaults_no_custom_defaults = Defaults() - kwargs = {kwarg: "custom_default" for kwarg in inspect.signature(Defaults).parameters} - kwargs["tzinfo"] = pytz.timezone("America/New_York") + kwargs = dict.fromkeys(inspect.signature(Defaults).parameters, "custom_default") + kwargs["tzinfo"] = zoneinfo.ZoneInfo("America/New_York") + kwargs["link_preview_options"] = LinkPreviewOptions( + url="custom_default", show_above_text="custom_default" + ) defaults_custom_defaults = Defaults(**kwargs) - expected_return_values = [None, ()] if return_value is None else [return_value] - - async def make_assertion( - url, request_data: RequestData, df_value=DEFAULT_NONE, *args, **kwargs - ): - data = request_data.parameters - - # Check regular arguments that need defaults - for arg in kwargs_need_default: - # 'None' should not be passed along to Telegram - if df_value in [None, DEFAULT_NONE]: - if arg in data: - pytest.fail( - f"Got value {data[arg]} for argument {arg}, expected it to be absent" - ) - else: - value = data.get(arg, "`not passed at all`") - if value != df_value: - pytest.fail(f"Got value {value} for argument {arg} instead of {df_value}") - - # Check InputMedia (parse_mode can have a default) - def check_input_media(m: Dict): - parse_mode = m.get("parse_mode", None) - if df_value is DEFAULT_NONE: - if parse_mode is not None: - pytest.fail("InputMedia has non-None parse_mode") - elif parse_mode != df_value: - pytest.fail( - f"Got value {parse_mode} for InputMedia.parse_mode instead of {df_value}" - ) - - media = data.pop("media", None) - if media: - if isinstance(media, dict) and isinstance(media.get("type", None), InputMediaType): - check_input_media(media) - else: - for m in media: - check_input_media(m) - - # Check InlineQueryResults - results = data.pop("results", []) - for result in results: - if df_value in [DEFAULT_NONE, None]: - if "parse_mode" in result: - pytest.fail("ILQR has a parse mode, expected it to be absent") - # Here we explicitly use that we only pass ILQRPhoto and ILQRArticle for testing - # so ILQRPhoto is expected to have parse_mode if df_value is not in [DF_NONE, NONE] - elif "photo" in result and result.get("parse_mode") != df_value: - pytest.fail( - f'Got value {result.get("parse_mode")} for ' - f"ILQR.parse_mode instead of {df_value}" - ) - imc = result.get("input_message_content") - if not imc: - continue - for attr in ["parse_mode", "disable_web_page_preview"]: - if df_value in [DEFAULT_NONE, None]: - if attr in imc: - pytest.fail(f"ILQR.i_m_c has a {attr}, expected it to be absent") - # Here we explicitly use that we only pass InputTextMessageContent for testing - # which has both attributes - elif imc.get(attr) != df_value: - pytest.fail( - f"Got value {imc.get(attr)} for ILQR.i_m_c.{attr} instead of {df_value}" - ) - - # Check datetime conversion - until_date = data.pop("until_date", None) - if until_date: - if df_value == "non-None-value" and until_date != 946681200: - pytest.fail("Non-naive until_date was interpreted as Europe/Berlin.") - if df_value is DEFAULT_NONE and until_date != 946684800: - pytest.fail("Naive until_date was not interpreted as UTC") - if df_value == "custom_default" and until_date != 946702800: - pytest.fail("Naive until_date was not interpreted as America/New_York") - - if method.__name__ in ["get_file", "get_small_file", "get_big_file"]: - # This is here mainly for PassportFile.get_file, which calls .set_credentials on the - # return value - out = File(file_id="result", file_unique_id="result") - nonlocal expected_return_values - expected_return_values = [out] - return out.to_dict() - # Otherwise return None by default, as TGObject.de_json/list(None) in [None, []] - # That way we can check what gets passed to Request.post without having to actually - # make a request - # Some methods expect specific output, so we allow to customize that - if isinstance(return_value, TelegramObject): - return return_value.to_dict() - return return_value - request = bot._request[0] if get_updates else bot.request orig_post = request.post + return_value = get_dummy_object_json_dict(*guess_return_type_name(method)) + try: if raw_bot: - combinations = [(DEFAULT_NONE, None)] + combinations = [(None, None)] else: combinations = [ - (DEFAULT_NONE, defaults_no_custom_defaults), + (None, defaults_no_custom_defaults), ("custom_default", defaults_custom_defaults), ] - for default_value, defaults in combinations: + for expected_defaults_value, defaults in combinations: if not raw_bot: bot._defaults = defaults # 1: test that we get the correct default value, if we don't specify anything - kwargs = build_kwargs( - shortcut_signature, - kwargs_need_default, + kwargs = build_kwargs(shortcut_signature, kwargs_need_default) + assertion_callback = functools.partial( + make_assertion, + kwargs_need_default=kwargs_need_default, + method_name=method.__name__, + return_value=return_value, + expected_defaults_value=expected_defaults_value, ) - assertion_callback = functools.partial(make_assertion, df_value=default_value) request.post = assertion_callback - assert await method(**kwargs) in expected_return_values + await method(**kwargs) # 2: test that we get the manually passed non-None value - kwargs = build_kwargs(shortcut_signature, kwargs_need_default, dfv="non-None-value") - assertion_callback = functools.partial(make_assertion, df_value="non-None-value") + kwargs = build_kwargs( + shortcut_signature, kwargs_need_default, manually_passed_value="non-None-value" + ) + assertion_callback = functools.partial( + make_assertion, + manually_passed_value="non-None-value", + kwargs_need_default=kwargs_need_default, + method_name=method.__name__, + return_value=return_value, + expected_defaults_value=expected_defaults_value, + ) request.post = assertion_callback - assert await method(**kwargs) in expected_return_values + await method(**kwargs) # 3: test that we get the manually passed None value kwargs = build_kwargs( - shortcut_signature, - kwargs_need_default, - dfv=None, + shortcut_signature, kwargs_need_default, manually_passed_value=None + ) + assertion_callback = functools.partial( + make_assertion, + manually_passed_value=None, + kwargs_need_default=kwargs_need_default, + method_name=method.__name__, + return_value=return_value, + expected_defaults_value=expected_defaults_value, ) - assertion_callback = functools.partial(make_assertion, df_value=None) request.post = assertion_callback - assert await method(**kwargs) in expected_return_values + await method(**kwargs) except Exception as exc: raise exc finally: diff --git a/tests/auxil/build_messages.py b/tests/auxil/build_messages.py index 9e9ef288714..710a5a915ec 100644 --- a/tests/auxil/build_messages.py +++ b/tests/auxil/build_messages.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,7 +16,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/]. -import datetime +import datetime as dtm import re from telegram import Chat, Message, MessageEntity, Update, User @@ -24,19 +24,20 @@ from tests.auxil.pytest_classes import make_bot CMD_PATTERN = re.compile(r"/[\da-z_]{1,32}(?:@\w{1,32})?") -DATE = datetime.datetime.now() +DATE = dtm.datetime.now() -def make_message(text, **kwargs): +def make_message(text: str, offline: bool = True, **kwargs): """ Testing utility factory to create a fake ``telegram.Message`` with reasonable defaults for mimicking a real message. :param text: (str) message text + :param offline: (bool) whether the bot should be offline :return: a (fake) ``telegram.Message`` """ bot = kwargs.pop("bot", None) if bot is None: - bot = make_bot(BOT_INFO_PROVIDER.get_info()) + bot = make_bot(BOT_INFO_PROVIDER.get_info(), offline=offline) message = Message( message_id=1, from_user=kwargs.pop("user", User(id=1, first_name="", is_bot=False)), diff --git a/tests/auxil/ci_bots.py b/tests/auxil/ci_bots.py index b1561433450..f3124fbcead 100644 --- a/tests/auxil/ci_bots.py +++ b/tests/auxil/ci_bots.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,35 +17,40 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Provide a bot to tests""" + import base64 import json import os import random +from telegram._utils.strings import TextEncoding + +from .envvars import GITHUB_ACTIONS + # Provide some public fallbacks so it's easy for contributors to run tests on their local machine # These bots are only able to talk in our test chats, so they are quite useless for other # purposes than testing. FALLBACKS = ( - "W3sidG9rZW4iOiAiNTc5Njk0NzE0OkFBRnBLOHc2emtrVXJENHhTZVl3RjNNTzhlLTRHcm1jeTdjIiwgInBheW1lbnRfc" - "HJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6TmpRME5qWmxOekk1WWpKaSIsICJjaGF0X2 lkIjogIjY3NTY2N" - "jIyNCIsICJzdXBlcl9ncm91cF9pZCI6ICItMTAwMTMxMDkxMTEzNSIsICJmb3J1bV9ncm91cF9pZCI6ICItMTAwMTgzOD" - "AwNDU3NyIsICJjaGFubmVsX2lkIjogIkBweXRob250ZWxlZ3JhbWJvdHRlc3RzIi wgIm5hbWUiOiAiUFRCIHRlc3RzIG" - "ZhbGxiYWNrIDEiLCAidXNlcm5hbWUiOiAiQHB0Yl9mYWxsYmFja18xX2JvdCJ9LCB7InRva2VuIjogIjU1ODE5NDA2Njp" - "BQUZ3RFBJRmx6R1VsQ2FXSHRUT0VYNFJGclg4dTlETXFmbyIsIC JwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY" - "4NTA2MzpURVNUOllqRXdPRFF3TVRGbU5EY3kiLCAiY2hhdF9pZCI6ICI2NzU2NjYyMjQiLCAic3VwZXJfZ3JvdXBfaWQi" - "OiAiLTEwMDEyMjEyMTY4MzAiLCAiZm9ydW1fZ3 JvdXBfaWQiOiAiLTEwMDE4NTc4NDgzMTQiLCAiY2hhbm5lbF9pZCI6" - "ICJAcHl0aG9udGVsZWdyYW1ib3R0ZXN0cyIsICJuYW1lIjogIlBUQiB0ZXN0cyBmYWxsYmFjayAyIiwgInVzZXJuYW1lI" - "jogIkBwdGJfZmFsbGJhY2tfMl9ib3QifV0=" + "W3sidG9rZW4iOiAiNTc5Njk0NzE0OkFBRmdqXzhmNFVlV1hrb3VVUnpUZThhRUY0UGNFQkRxdlY0IiwgInBheW1lbnRfc" + "HJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6TmpRME5qWmxOekk1WWpKaSIsICJjaGF0X2lkIjogIjY3NTY2Nj" + "IyNCIsICJzdXBlcl9ncm91cF9pZCI6ICItMTAwMTMxMDkxMTEzNSIsICJmb3J1bV9ncm91cF9pZCI6ICItMTAwMTgzODA" + "wNDU3NyIsICJjaGFubmVsX2lkIjogIkBweXRob250ZWxlZ3JhbWJvdHRlc3RzIiwgIm5hbWUiOiAiUFRCIHRlc3RzIGZh" + "bGxiYWNrIDEiLCAidXNlcm5hbWUiOiAiQHB0Yl9mYWxsYmFja18xX2JvdCIsICJzdWJzY3JpcHRpb25fY2hhbm5lbF9pZ" + "CI6IC0xMDAyMjI5NjQ5MzAzfSwgeyJ0b2tlbiI6ICI1NTgxOTQwNjY6QUFGd0RQSUZsekdVbENhV0h0VE9FWDRSRnJYOH" + "U5RE1xZm8iLCAicGF5bWVudF9wcm92aWRlcl90b2tlbiI6ICIyODQ2ODUwNjM6VEVTVDpZakV3T0RRd01URm1ORGN5Iiw" + "gImNoYXRfaWQiOiAiNjc1NjY2MjI0IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxMjIxMjE2ODMwIiwgImZvcnVtX2dy" + "b3VwX2lkIjogIi0xMDAxODU3ODQ4MzE0IiwgImNoYW5uZWxfaWQiOiAiQHB5dGhvbnRlbGVncmFtYm90dGVzdHMiLCAib" + "mFtZSI6ICJQVEIgdGVzdHMgZmFsbGJhY2sgMiIsICJ1c2VybmFtZSI6ICJAcHRiX2ZhbGxiYWNrXzJfYm90IiwgInN1Yn" + "NjcmlwdGlvbl9jaGFubmVsX2lkIjogLTEwMDIyMjk2NDkzMDN9XQ==" ) -GITHUB_ACTION = os.getenv("GITHUB_ACTION", None) BOTS = os.getenv("BOTS", None) JOB_INDEX = os.getenv("JOB_INDEX", None) -if GITHUB_ACTION is not None and BOTS is not None and JOB_INDEX is not None: - BOTS = json.loads(base64.b64decode(BOTS).decode("utf-8")) +if GITHUB_ACTIONS and BOTS is not None and JOB_INDEX is not None: + BOTS = json.loads(base64.b64decode(BOTS).decode(TextEncoding.UTF_8)) JOB_INDEX = int(JOB_INDEX) -FALLBACKS = json.loads(base64.b64decode(FALLBACKS).decode("utf-8")) # type: list[dict[str, str]] +FALLBACKS = json.loads(base64.b64decode(FALLBACKS).decode(TextEncoding.UTF_8)) # type: list[dict[str, str]] class BotInfoProvider: @@ -55,7 +60,7 @@ def __init__(self): @staticmethod def _get_value(key, fallback): # If we're running as a github action then fetch bots from the repo secrets - if GITHUB_ACTION is not None and BOTS is not None and JOB_INDEX is not None: + if GITHUB_ACTIONS and BOTS is not None and JOB_INDEX is not None: try: return BOTS[JOB_INDEX][key] except (IndexError, KeyError): diff --git a/tests/auxil/constants.py b/tests/auxil/constants.py index 602e6ef8edc..4eef931d972 100644 --- a/tests/auxil/constants.py +++ b/tests/auxil/constants.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,3 +20,7 @@ # THIS KEY IS OBVIOUSLY COMPROMISED # DO NOT USE IN PRODUCTION! PRIVATE_KEY = b"-----BEGIN RSA PRIVATE KEY-----\r\nMIIEowIBAAKCAQEA0AvEbNaOnfIL3GjB8VI4M5IaWe+GcK8eSPHkLkXREIsaddum\r\nwPBm/+w8lFYdnY+O06OEJrsaDtwGdU//8cbGJ/H/9cJH3dh0tNbfszP7nTrQD+88\r\nydlcYHzClaG8G+oTe9uEZSVdDXj5IUqR0y6rDXXb9tC9l+oSz+ShYg6+C4grAb3E\r\nSTv5khZ9Zsi/JEPWStqNdpoNuRh7qEYc3t4B/a5BH7bsQENyJSc8AWrfv+drPAEe\r\njQ8xm1ygzWvJp8yZPwOIYuL+obtANcoVT2G2150Wy6qLC0bD88Bm40GqLbSazueC\r\nRHZRug0B9rMUKvKc4FhG4AlNzBCaKgIcCWEqKwIDAQABAoIBACcIjin9d3Sa3S7V\r\nWM32JyVF3DvTfN3XfU8iUzV7U+ZOswA53eeFM04A/Ly4C4ZsUNfUbg72O8Vd8rg/\r\n8j1ilfsYpHVvphwxaHQlfIMa1bKCPlc/A6C7b2GLBtccKTbzjARJA2YWxIaqk9Nz\r\nMjj1IJK98i80qt29xRnMQ5sqOO3gn2SxTErvNchtBiwOH8NirqERXig8VCY6fr3n\r\nz7ZImPU3G/4qpD0+9ULrt9x/VkjqVvNdK1l7CyAuve3D7ha3jPMfVHFtVH5gqbyp\r\nKotyIHAyD+Ex3FQ1JV+H7DkP0cPctQiss7OiO9Zd9C1G2OrfQz9el7ewAPqOmZtC\r\nKjB3hUECgYEA/4MfKa1cvaCqzd3yUprp1JhvssVkhM1HyucIxB5xmBcVLX2/Kdhn\r\nhiDApZXARK0O9IRpFF6QVeMEX7TzFwB6dfkyIePsGxputA5SPbtBlHOvjZa8omMl\r\nEYfNa8x/mJkvSEpzvkWPascuHJWv1cEypqphu/70DxubWB5UKo/8o6cCgYEA0HFy\r\ncgwPMB//nltHGrmaQZPFT7/Qgl9ErZT3G9S8teWY4o4CXnkdU75tBoKAaJnpSfX3\r\nq8VuRerF45AFhqCKhlG4l51oW7TUH50qE3GM+4ivaH5YZB3biwQ9Wqw+QyNLAh/Q\r\nnS4/Wwb8qC9QuyEgcCju5lsCaPEXZiZqtPVxZd0CgYEAshBG31yZjO0zG1TZUwfy\r\nfN3euc8mRgZpSdXIHiS5NSyg7Zr8ZcUSID8jAkJiQ3n3OiAsuq1MGQ6kNa582kLT\r\nFPQdI9Ea8ahyDbkNR0gAY9xbM2kg/Gnro1PorH9PTKE0ekSodKk1UUyNrg4DBAwn\r\nqE6E3ebHXt/2WmqIbUD653ECgYBQCC8EAQNX3AFegPd1GGxU33Lz4tchJ4kMCNU0\r\nN2NZh9VCr3nTYjdTbxsXU8YP44CCKFG2/zAO4kymyiaFAWEOn5P7irGF/JExrjt4\r\nibGy5lFLEq/HiPtBjhgsl1O0nXlwUFzd7OLghXc+8CPUJaz5w42unqT3PBJa40c3\r\nQcIPdQKBgBnSb7BcDAAQ/Qx9juo/RKpvhyeqlnp0GzPSQjvtWi9dQRIu9Pe7luHc\r\nm1Img1EO1OyE3dis/rLaDsAa2AKu1Yx6h85EmNjavBqP9wqmFa0NIQQH8fvzKY3/\r\nP8IHY6009aoamLqYaexvrkHVq7fFKiI6k8myMJ6qblVNFv14+KXU\r\n-----END RSA PRIVATE KEY-----" # noqa: E501 + +TEST_MSG_TEXT = "Topics are forever" +TEST_TOPIC_ICON_COLOR = 0x6FB9F0 +TEST_TOPIC_NAME = "Sad bot true: real stories" diff --git a/tests/auxil/deprecations.py b/tests/auxil/deprecations.py deleted file mode 100644 index 224ecdc4067..00000000000 --- a/tests/auxil/deprecations.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python -# -# A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 -# 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 _pytest.recwarn import WarningsRecorder - -from telegram.warnings import PTBDeprecationWarning - - -def check_thumb_deprecation_warnings_for_args_and_attrs( - recwarn: WarningsRecorder, - calling_file: str, - deprecated_name: str = "thumb", - new_name: str = "thumbnail", - expected_recwarn_length: int = 2, -) -> bool: - """Check that the correct deprecation warnings are issued. This includes - - * a warning for using the deprecated `thumb...` argument - * a warning for using the deprecated `thumb...` attribute - - Args: - recwarn: pytest's recwarn fixture. - calling_file: The file that called this function. - deprecated_name: Name of deprecated argument/attribute to check in the warning text. - new_name: Name of new argument/attribute to check in the warning text. - expected_recwarn_length: expected number of warnings issued. - - Returns: - True if the correct deprecation warnings were raised, False otherwise. - - Raises: - AssertionError: If the correct deprecation warnings were not raised. - """ - names = ( - ("argument", "attribute") - if expected_recwarn_length == 2 - else ("argument", "argument", "attribute") - ) - actual_recwarn_length = len(recwarn) - assert actual_recwarn_length == expected_recwarn_length, ( - f"expected recwarn length {expected_recwarn_length}, actual length {actual_recwarn_length}" - f". Contents: {[item.message for item in recwarn.list]}" - ) - for i in range(expected_recwarn_length): - assert issubclass(recwarn[i].category, PTBDeprecationWarning) - assert f"{names[i]} '{deprecated_name}' to '{new_name}'" in str(recwarn[i].message), ( - f'Warning issued by file {recwarn[i].filename} ("{str(recwarn[i].message)}") ' - "does not contain expected phrase: " - f"\"{names[i]} '{deprecated_name}' to '{new_name}'\"" - ) - - assert recwarn[i].filename == calling_file, ( - f'Warning for {names[i]} ("{str(recwarn[i].message)}") was issued by file ' - f"{recwarn[i].filename}, expected {calling_file}" - ) - - return True - - -def check_thumb_deprecation_warning_for_method_args( - recwarn: WarningsRecorder, - calling_file: str, - deprecated_name: str = "thumb", - new_name: str = "thumbnail", -): - """Similar as `check_thumb_deprecation_warnings_for_args_and_attrs`, but for bot methods.""" - assert len(recwarn) == 1 - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == calling_file - assert f"argument '{deprecated_name}' to '{new_name}'" in str(recwarn[0].message) diff --git a/tests/auxil/dummy_objects.py b/tests/auxil/dummy_objects.py new file mode 100644 index 00000000000..84bba3aa45b --- /dev/null +++ b/tests/auxil/dummy_objects.py @@ -0,0 +1,199 @@ +import datetime as dtm +from collections.abc import Sequence +from typing import TypeAlias + +from telegram import ( + AcceptedGiftTypes, + BotCommand, + BotDescription, + BotName, + BotShortDescription, + BusinessBotRights, + BusinessConnection, + Chat, + ChatAdministratorRights, + ChatBoost, + ChatBoostSource, + ChatFullInfo, + ChatInviteLink, + ChatMember, + File, + ForumTopic, + GameHighScore, + Gift, + Gifts, + MenuButton, + MessageId, + OwnedGiftRegular, + OwnedGifts, + Poll, + PollOption, + PreparedInlineMessage, + SentWebAppMessage, + StarAmount, + StarTransaction, + StarTransactions, + Sticker, + StickerSet, + Story, + TelegramObject, + Update, + User, + UserChatBoosts, + UserProfilePhotos, + WebhookInfo, +) +from tests.auxil.build_messages import make_message + +_DUMMY_USER = User( + id=123456, is_bot=False, first_name="Dummy", last_name="User", username="dummy_user" +) +_DUMMY_DATE = dtm.datetime(1970, 1, 1, 0, 0, 0, 0, tzinfo=dtm.timezone.utc) +_DUMMY_STICKER = Sticker( + file_id="dummy_file_id", + file_unique_id="dummy_file_unique_id", + width=1, + height=1, + is_animated=False, + is_video=False, + type="dummy_type", +) + +_PREPARED_DUMMY_OBJECTS: dict[str, object] = { + "bool": True, + "BotCommand": BotCommand(command="dummy_command", description="dummy_description"), + "BotDescription": BotDescription(description="dummy_description"), + "BotName": BotName(name="dummy_name"), + "BotShortDescription": BotShortDescription(short_description="dummy_short_description"), + "BusinessConnection": BusinessConnection( + user=_DUMMY_USER, + id="123", + user_chat_id=123456, + date=_DUMMY_DATE, + is_enabled=True, + rights=BusinessBotRights(can_reply=True), + ), + "Chat": Chat(id=123456, type="dummy_type"), + "ChatAdministratorRights": ChatAdministratorRights.all_rights(), + "ChatFullInfo": ChatFullInfo( + id=123456, + type="dummy_type", + accent_color_id=1, + max_reaction_count=1, + accepted_gift_types=AcceptedGiftTypes( + unlimited_gifts=True, + limited_gifts=True, + unique_gifts=True, + premium_subscription=True, + gifts_from_channels=True, + ), + ), + "ChatInviteLink": ChatInviteLink( + "dummy_invite_link", + creator=_DUMMY_USER, + is_primary=True, + is_revoked=False, + creates_join_request=False, + ), + "ChatMember": ChatMember(user=_DUMMY_USER, status="dummy_status"), + "File": File(file_id="dummy_file_id", file_unique_id="dummy_file_unique_id"), + "ForumTopic": ForumTopic(message_thread_id=2, name="dummy_name", icon_color=1), + "Gifts": Gifts(gifts=[Gift(id="dummy_id", sticker=_DUMMY_STICKER, star_count=1)]), + "GameHighScore": GameHighScore(position=1, user=_DUMMY_USER, score=1), + "int": 123456, + "MenuButton": MenuButton(type="dummy_type"), + "Message": make_message("dummy_text"), + # Bad hack to get tests passing (we should not be using annotations as a key here) + "Message | bool": make_message("dummy_text"), + "MessageId": MessageId(123456), + "OwnedGifts": OwnedGifts( + total_count=1, + gifts=[ + OwnedGiftRegular( + gift=Gift( + id="id1", + sticker=Sticker( + "file_id", "file_unique_id", 512, 512, False, False, "regular" + ), + star_count=5, + ), + send_date=_DUMMY_DATE, + owned_gift_id="some_id_1", + ) + ], + ), + "Poll": Poll( + id="dummy_id", + question="dummy_question", + options=[PollOption(text="dummy_text", voter_count=1)], + is_closed=False, + is_anonymous=False, + total_voter_count=1, + type="dummy_type", + allows_multiple_answers=False, + ), + "PreparedInlineMessage": PreparedInlineMessage(id="dummy_id", expiration_date=_DUMMY_DATE), + "SentWebAppMessage": SentWebAppMessage(inline_message_id="dummy_inline_message_id"), + "StarAmount": StarAmount(amount=100, nanostar_amount=356), + "StarTransactions": StarTransactions( + transactions=[StarTransaction(id="dummy_id", amount=1, date=_DUMMY_DATE)] + ), + "Sticker": _DUMMY_STICKER, + "StickerSet": StickerSet( + name="dummy_name", + title="dummy_title", + stickers=[_DUMMY_STICKER], + sticker_type="dummy_type", + ), + "Story": Story(chat=Chat(123, "prive"), id=123), + "str": "dummy_string", + "Update": Update(update_id=123456), + "User": _DUMMY_USER, + "UserChatBoosts": UserChatBoosts( + boosts=[ + ChatBoost( + boost_id="dummy_id", + add_date=_DUMMY_DATE, + expiration_date=_DUMMY_DATE, + source=ChatBoostSource(source="dummy_source"), + ) + ] + ), + "UserProfilePhotos": UserProfilePhotos(total_count=1, photos=[[]]), + "WebhookInfo": WebhookInfo( + url="dummy_url", + has_custom_certificate=False, + pending_update_count=1, + ), +} + + +def get_dummy_object(obj_type: type | str, as_tuple: bool = False) -> object: + obj_type_name = obj_type.__name__ if isinstance(obj_type, type) else obj_type + if (return_value := _PREPARED_DUMMY_OBJECTS.get(obj_type_name)) is None: + raise ValueError( + f"Dummy object of type '{obj_type_name}' not found. Please add it manually." + ) + + if as_tuple: + return (return_value,) + return return_value + + +_RETURN_TYPES: TypeAlias = bool | int | str | dict[str, object] +_RETURN_TYPE: TypeAlias = _RETURN_TYPES | tuple[_RETURN_TYPES, ...] + + +def _serialize_dummy_object(obj: object) -> _RETURN_TYPE: + if isinstance(obj, Sequence) and not isinstance(obj, str): + return tuple(_serialize_dummy_object(item) for item in obj) + if isinstance(obj, str | int | bool): + return obj + if isinstance(obj, TelegramObject): + return obj.to_dict() + + raise ValueError(f"Serialization of object of type '{type(obj)}' is not supported yet.") + + +def get_dummy_object_json_dict(obj_type: type | str, as_tuple: bool = False) -> _RETURN_TYPE: + return _serialize_dummy_object(get_dummy_object(obj_type, as_tuple=as_tuple)) diff --git a/tests/auxil/envvars.py b/tests/auxil/envvars.py index e7456204341..a4d83051de2 100644 --- a/tests/auxil/envvars.py +++ b/tests/auxil/envvars.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -24,8 +24,12 @@ def env_var_2_bool(env_var: object) -> bool: return env_var if not isinstance(env_var, str): return False - return env_var.lower().strip() == "true" + return env_var.lower().strip() in ["true", "1"] -GITHUB_ACTION = os.getenv("GITHUB_ACTION", "") -TEST_WITH_OPT_DEPS = env_var_2_bool(os.getenv("TEST_WITH_OPT_DEPS", "true")) +GITHUB_ACTIONS: bool = env_var_2_bool(os.getenv("GITHUB_ACTIONS", "false")) +TEST_WITH_OPT_DEPS: bool = env_var_2_bool(os.getenv("TEST_WITH_OPT_DEPS", "false")) or ( + # on local setups, we usually want to test with optional dependencies + not GITHUB_ACTIONS +) +RUN_TEST_OFFICIAL: bool = env_var_2_bool(os.getenv("TEST_OFFICIAL")) diff --git a/tests/auxil/files.py b/tests/auxil/files.py index 486686c0f94..d1bbc75e8b5 100644 --- a/tests/auxil/files.py +++ b/tests/auxil/files.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,6 +19,7 @@ from pathlib import Path PROJECT_ROOT_PATH = Path(__file__).parent.parent.parent.resolve() +SOURCE_ROOT_PATH = PROJECT_ROOT_PATH / "src" / "telegram" TEST_DATA_PATH = PROJECT_ROOT_PATH / "tests" / "data" diff --git a/tests/auxil/monkeypatch.py b/tests/auxil/monkeypatch.py new file mode 100644 index 00000000000..303c4e42629 --- /dev/null +++ b/tests/auxil/monkeypatch.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 asyncio + + +async def return_true(*args, **kwargs): + return True + + +async def empty_get_updates(*args, **kwargs): + # The `await` gives the event loop a chance to run other tasks + await asyncio.sleep(0) + return [] diff --git a/tests/auxil/networking.py b/tests/auxil/networking.py index dec83df23f3..7aa49b096cf 100644 --- a/tests/auxil/networking.py +++ b/tests/auxil/networking.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,15 +16,16 @@ # # 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 typing import Optional +from pathlib import Path import pytest -from httpx import AsyncClient, Response +from httpx import AsyncClient, AsyncHTTPTransport, Response from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.strings import TextEncoding from telegram._utils.types import ODVInput from telegram.error import BadRequest, RetryAfter, TimedOut -from telegram.request import HTTPXRequest, RequestData +from telegram.request import BaseRequest, HTTPXRequest, RequestData class NonchalantHttpxRequest(HTTPXRequest): @@ -36,7 +37,7 @@ async def _request_wrapper( self, method: str, url: str, - request_data: Optional[RequestData] = None, + request_data: RequestData | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -58,6 +59,37 @@ async def _request_wrapper( pytest.xfail(f"Ignoring TimedOut error: {e}") +class OfflineRequest(BaseRequest): + """This Request class disallows making requests to Telegram's servers. + Use this in tests that should not depend on the network. + """ + + async def initialize(self) -> None: + pass + + async def shutdown(self) -> None: + pass + + @property + def read_timeout(self): + return 1 + + def __init__(self, *args, **kwargs): + pass + + async def do_request( + self, + url: str, + method: str, + request_data: RequestData | None = None, + read_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE, + write_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE, + connect_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE, + pool_timeout: ODVInput[float] = BaseRequest.DEFAULT_NONE, + ) -> tuple[int, bytes]: + pytest.fail("OfflineRequest: Network access disallowed in this test") + + async def expect_bad_request(func, message, reason): """ Wrapper for testing bot functions expected to result in an :class:`telegram.error.BadRequest`. @@ -84,12 +116,13 @@ async def expect_bad_request(func, message, reason): async def send_webhook_message( ip: str, port: int, - payload_str: Optional[str], + payload_str: str | None, url_path: str = "", content_len: int = -1, content_type: str = "application/json", - get_method: Optional[str] = None, - secret_token: Optional[str] = None, + get_method: str | None = None, + secret_token: str | None = None, + unix: Path | None = None, ) -> Response: headers = { "content-type": content_type, @@ -101,7 +134,7 @@ async def send_webhook_message( content_len = None payload = None else: - payload = bytes(payload_str, encoding="utf-8") + payload = bytes(payload_str, encoding=TextEncoding.UTF_8) if content_len == -1: content_len = len(payload) @@ -111,7 +144,9 @@ async def send_webhook_message( url = f"http://{ip}:{port}/{url_path}" - async with AsyncClient() as client: + transport = AsyncHTTPTransport(uds=unix) if unix else None + + async with AsyncClient(transport=transport) as client: return await client.request( url=url, method=get_method or "POST", data=payload, headers=headers ) diff --git a/tests/auxil/plugin_github_group.py b/tests/auxil/plugin_github_group.py deleted file mode 100644 index 7f4c7291d37..00000000000 --- a/tests/auxil/plugin_github_group.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python -# -# A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 -# 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.config -import pytest - -fold_plugins = {"_cov": "Coverage report", "flaky": "Flaky report"} - - -def terminal_summary_wrapper(original, plugin_name): - text = fold_plugins[plugin_name] - - def pytest_terminal_summary(terminalreporter): - terminalreporter.write(f"##[group] {text}\n") - original(terminalreporter) - terminalreporter.write("##[endgroup]") - - return pytest_terminal_summary - - -@pytest.mark.trylast() -def pytest_configure(config): - for hookimpl in config.pluginmanager.hook.pytest_terminal_summary._nonwrappers: - if hookimpl.plugin_name in fold_plugins: - hookimpl.function = terminal_summary_wrapper(hookimpl.function, hookimpl.plugin_name) - - -class PytestPluginHelpers: - terminal = None - previous_name = None - - -def _get_name(location): - if location[0].startswith("tests/"): - return location[0][6:] - return location[0] - - -@pytest.mark.trylast() -def pytest_itemcollected(item): - item._nodeid = item._nodeid.split("::", 1)[1] - - -@pytest.hookimpl(hookwrapper=True, tryfirst=True) -def pytest_runtest_protocol(item, nextitem): - # This is naughty but pytests' own plugins does something similar too, so who cares - if PytestPluginHelpers.terminal is None: - PytestPluginHelpers.terminal = _pytest.config.create_terminal_writer(item.config) - - name = _get_name(item.location) - - if PytestPluginHelpers.previous_name is None or PytestPluginHelpers.previous_name != name: - PytestPluginHelpers.previous_name = name - PytestPluginHelpers.terminal.write(f"\n##[group] {name}") - - yield - - if nextitem is None or _get_name(nextitem.location) != name: - PytestPluginHelpers.terminal.write("\n##[endgroup]") diff --git a/tests/auxil/pytest_classes.py b/tests/auxil/pytest_classes.py index 15ade7975c1..f79cb187d84 100644 --- a/tests/auxil/pytest_classes.py +++ b/tests/auxil/pytest_classes.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,12 +20,13 @@ modify behavior of the respective parent classes in order to make them easier to use in the pytest framework. A common change is to allow monkeypatching of the class members by not enforcing slots in the subclasses.""" -from telegram import Bot, User -from telegram.ext import Application, ExtBot + +from telegram import Bot, Message, User +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 -from tests.auxil.networking import NonchalantHttpxRequest +from tests.auxil.networking import NonchalantHttpxRequest, OfflineRequest def _get_bot_user(token: str) -> User: @@ -66,7 +67,7 @@ def __init__(self, *args, **kwargs): self._unfreeze() # Here we override get_me for caching because we don't want to call the API repeatedly in tests - async def get_me(self, *args, **kwargs): + async def get_me(self, *args, **kwargs) -> User: return await _mocked_get_me(self) @@ -77,7 +78,7 @@ def __init__(self, *args, **kwargs): self._unfreeze() # Here we override get_me for caching because we don't want to call the API repeatedly in tests - async def get_me(self, *args, **kwargs): + async def get_me(self, *args, **kwargs) -> User: return await _mocked_get_me(self) @@ -85,17 +86,28 @@ class PytestApplication(Application): pass -def make_bot(bot_info=None, **kwargs): +class PytestMessage(Message): + pass + + +class PytestUpdater(Updater): + pass + + +def make_bot(bot_info=None, offline: bool = True, **kwargs): """ Tests are executed on tg.ext.ExtBot, as that class only extends the functionality of tg.bot """ token = kwargs.pop("token", (bot_info or {}).get("token")) private_key = kwargs.pop("private_key", PRIVATE_KEY) kwargs.pop("token", None) + + request_class = OfflineRequest if offline else NonchalantHttpxRequest + return PytestExtBot( token=token, private_key=private_key if TEST_WITH_OPT_DEPS else None, - request=NonchalantHttpxRequest(8), - get_updates_request=NonchalantHttpxRequest(1), + request=request_class(8), + get_updates_request=request_class(1), **kwargs, ) diff --git a/tests/auxil/slots.py b/tests/auxil/slots.py index 0d05dd0c9ca..a8eef155264 100644 --- a/tests/auxil/slots.py +++ b/tests/auxil/slots.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/auxil/string_manipulation.py b/tests/auxil/string_manipulation.py new file mode 100644 index 00000000000..82934396d24 --- /dev/null +++ b/tests/auxil/string_manipulation.py @@ -0,0 +1,15 @@ +import re + + +def to_camel_case(snake_str): + """https://stackoverflow.com/a/19053800""" + components = snake_str.split("_") + # We capitalize the first letter of each component except the first one + # with the 'title' method and join them together. + return components[0] + "".join(x.title() for x in components[1:]) + + +def to_snake_case(camel_str): + """https://stackoverflow.com/a/1176023""" + name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", camel_str) + return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower() diff --git a/tests/auxil/timezones.py b/tests/auxil/timezones.py index 34e42afeeab..cb2eee776c4 100644 --- a/tests/auxil/timezones.py +++ b/tests/auxil/timezones.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,10 +16,10 @@ # # 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 +import datetime as dtm -class BasicTimezone(datetime.tzinfo): +class BasicTimezone(dtm.tzinfo): def __init__(self, offset, name): self.offset = offset self.name = name @@ -28,4 +28,4 @@ def utcoffset(self, dt): return self.offset def dst(self, dt): - return datetime.timedelta(0) + return dtm.timedelta(0) diff --git a/tests/conftest.py b/tests/conftest.py index 1f8171eda70..ed0c3f64231 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,9 +17,11 @@ # 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 asyncio -import datetime +import os import sys -from typing import Dict, List +import zoneinfo +from pathlib import Path +from uuid import uuid4 import pytest @@ -34,16 +36,14 @@ Update, User, ) -from telegram.ext import ApplicationBuilder, Defaults, Updater -from telegram.ext.filters import MessageFilter, UpdateFilter -from tests.auxil.build_messages import DATE -from tests.auxil.ci_bots import BOT_INFO_PROVIDER -from tests.auxil.constants import PRIVATE_KEY -from tests.auxil.envvars import GITHUB_ACTION, TEST_WITH_OPT_DEPS +from telegram.ext import Defaults +from tests.auxil.build_messages import DATE, make_message +from tests.auxil.ci_bots import BOT_INFO_PROVIDER, JOB_INDEX +from tests.auxil.constants import PRIVATE_KEY, TEST_TOPIC_ICON_COLOR, TEST_TOPIC_NAME +from tests.auxil.envvars import GITHUB_ACTIONS, TEST_WITH_OPT_DEPS, env_var_2_bool from tests.auxil.files import data_file from tests.auxil.networking import NonchalantHttpxRequest -from tests.auxil.pytest_classes import PytestApplication, PytestBot, make_bot -from tests.auxil.timezones import BasicTimezone +from tests.auxil.pytest_classes import PytestBot, make_bot if TEST_WITH_OPT_DEPS: import pytz @@ -59,13 +59,13 @@ def pytest_runtestloop(session: pytest.Session): def no_rerun_after_xfail_or_flood(error, name, test: pytest.Function, plugin): """Don't rerun tests that have xfailed when marked with xfail, or when we hit a flood limit.""" xfail_present = test.get_closest_marker(name="xfail") + if getattr(error[1], "msg", "") is None: + raise error[1] did_we_flood = "flood" in getattr(error[1], "msg", "") # _pytest.outcomes.XFailed has 'msg' - if xfail_present or did_we_flood: - return False - return True + return not (xfail_present or did_we_flood) -def pytest_collection_modifyitems(items: List[pytest.Item]): +def pytest_collection_modifyitems(items: list[pytest.Item]): """Here we add a flaky marker to all request making tests and a (no_)req marker to the rest.""" for item in items: # items are the test methods parent = item.parent # Get the parent of the item (class, or module if defined outside) @@ -87,8 +87,49 @@ def pytest_collection_modifyitems(items: List[pytest.Item]): parent.add_marker(pytest.mark.no_req) -if GITHUB_ACTION: - pytest_plugins = ["tests.auxil.plugin_github_group"] +if GITHUB_ACTIONS and JOB_INDEX == 0: + # let's not slow down the tests too much with these additional checks + # that's why we run them only in GitHub actions and only on *one* of the several test + # matrix entries + @pytest.fixture(autouse=True) + def _disallow_requests_in_without_request_tests(request): + """This fixture prevents tests that don't require requests from using the online-bot. + This is a sane-effort approach on trying to prevent requests from being made in the + *WithoutRequest classes. Note that we can not prevent all requests, as one can still + manually build a `Bot` object or use `httpx` directly. See #4317 and #4465 for some + discussion. + """ + + if type(request).__name__ == "SubRequest": + # Some fixtures used in the *WithoutRequests test classes do use requests, e.g. + # `animation`. Separating that would be too much effort, hence we allow that. + # Unfortunately the `SubRequest` class is not public, so we check only the name for + # less dependency on pytest's internal structure. + return + + if not request.cls: + return + name = request.cls.__name__ + if not name.endswith("WithoutRequest") or not request.fixturenames: + return + + if "bot" in request.fixturenames: + pytest.fail( + f"Test function {request.function} in test class {name} should not have a `bot` " + f"fixture. Use `offline_bot` instead." + ) + + +@pytest.fixture(scope="module", params=["true", "1", "false", "gibberish", None]) +def PTB_TIMEDELTA(request): + # Here we manually use monkeypatch to give this fixture module scope + monkeypatch = pytest.MonkeyPatch() + if request.param is not None: + monkeypatch.setenv("PTB_TIMEDELTA", request.param) + else: + monkeypatch.delenv("PTB_TIMEDELTA", raising=False) + yield env_var_2_bool(os.getenv("PTB_TIMEDELTA")) + monkeypatch.undo() # Redefine the event_loop fixture to have a session scope. Otherwise `bot` fixture can't be @@ -98,34 +139,43 @@ def event_loop(request): # ever since ProactorEventLoop became the default in Win 3.8+, the app crashes after the loop # is closed. Hence, we use SelectorEventLoop on Windows to avoid this. See # https://github.com/python/cpython/issues/83413, https://github.com/encode/httpx/issues/914 - if sys.version_info[0] == 3 and sys.version_info[1] >= 8 and sys.platform.startswith("win"): + if sys.platform.startswith("win"): asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) return asyncio.get_event_loop_policy().new_event_loop() # loop.close() # instead of closing here, do that at the every end of the test session @pytest.fixture(scope="session") -def bot_info() -> Dict[str, str]: +def bot_info() -> dict[str, str]: return BOT_INFO_PROVIDER.get_info() @pytest.fixture(scope="session") async def bot(bot_info): """Makes an ExtBot instance with the given bot_info""" - async with make_bot(bot_info) as _bot: + async with make_bot(bot_info, offline=False) as _bot: + yield _bot + + +@pytest.fixture(scope="session") +async def offline_bot(bot_info): + """Makes an offline Bot instance with the given bot_info + Note that in tests/ext we also override the `bot` fixture to return the offline bot instead. + """ + async with make_bot(bot_info, offline=True) as _bot: yield _bot -@pytest.fixture() +@pytest.fixture def one_time_bot(bot_info): """A function scoped bot since the session bot would shutdown when `async with app` finishes""" - return make_bot(bot_info) + return make_bot(bot_info, offline=False) @pytest.fixture(scope="session") async def cdc_bot(bot_info): """Makes an ExtBot instance with the given bot_info that uses arbitrary callback_data""" - async with make_bot(bot_info, arbitrary_callback_data=True) as _bot: + async with make_bot(bot_info, arbitrary_callback_data=True, offline=False) as _bot: yield _bot @@ -153,9 +203,9 @@ async def default_bot(request, bot_info): defaults = Defaults(**param) # If the bot is already created, return it. Else make a new one. - default_bot = _default_bots.get(defaults, None) + default_bot = _default_bots.get(defaults) if default_bot is None: - default_bot = make_bot(bot_info, defaults=defaults) + default_bot = make_bot(bot_info, defaults=defaults, offline=False) await default_bot.initialize() _default_bots[defaults] = default_bot # Defaults object is hashable return default_bot @@ -167,7 +217,7 @@ async def tz_bot(timezone, bot_info): try: # If the bot is already created, return it. Saves time since get_me is not called again. return _default_bots[defaults] except KeyError: - default_bot = make_bot(bot_info, defaults=defaults) + default_bot = make_bot(bot_info, defaults=defaults, offline=False) await default_bot.initialize() _default_bots[defaults] = default_bot return default_bot @@ -198,29 +248,12 @@ def provider_token(bot_info): return bot_info["payment_provider_token"] -@pytest.fixture() -async def app(bot_info): - # We build a new bot each time so that we use `app` in a context manager without problems - application = ( - ApplicationBuilder().bot(make_bot(bot_info)).application_class(PytestApplication).build() - ) - yield application - if application.running: - await application.stop() - await application.shutdown() - - -@pytest.fixture() -async def updater(bot_info): - # We build a new bot each time so that we use `updater` in a context manager without problems - up = Updater(bot=make_bot(bot_info), update_queue=asyncio.Queue()) - yield up - if up.running: - await up.stop() - await up.shutdown() +@pytest.fixture(scope="session") +def subscription_channel_id(bot_info): + return bot_info["subscription_channel_id"] -@pytest.fixture() +@pytest.fixture def thumb_file(): with data_file("thumb.jpg").open("rb") as f: yield f @@ -232,21 +265,28 @@ def class_thumb_file(): yield f -@pytest.fixture( - scope="class", - params=[{"class": MessageFilter}, {"class": UpdateFilter}], - ids=["MessageFilter", "UpdateFilter"], -) -def mock_filter(request): - class MockFilter(request.param["class"]): - def __init__(self): - super().__init__() - self.tested = False +@pytest.fixture(scope="session") +async def emoji_id(bot): + emoji_sticker_list = await bot.get_forum_topic_icon_stickers() + first_sticker = emoji_sticker_list[0] + return first_sticker.custom_emoji_id + + +@pytest.fixture +async def real_topic(bot, emoji_id, forum_group_id): + result = await bot.create_forum_topic( + chat_id=forum_group_id, + name=TEST_TOPIC_NAME, + icon_color=TEST_TOPIC_ICON_COLOR, + icon_custom_emoji_id=emoji_id, + ) - def filter(self, _): - self.tested = True + yield result - return MockFilter() + result = await bot.delete_forum_topic( + chat_id=forum_group_id, message_thread_id=result.message_thread_id + ) + assert result is True, "Topic was not deleted" def _get_false_update_fixture_decorator_params(): @@ -270,14 +310,37 @@ def false_update(request): return Update(update_id=1, **request.param) +@pytest.fixture( + scope="session", + params=[pytz.timezone, zoneinfo.ZoneInfo] if TEST_WITH_OPT_DEPS else [zoneinfo.ZoneInfo], +) +def _tz_implementation(request): + # This fixture is used to parametrize the timezone fixture + # This is similar to what @pyttest.mark.parametrize does but for fixtures + # However, this is needed only internally for the `tzinfo` fixture, so we keep it private + return request.param + + @pytest.fixture(scope="session", params=["Europe/Berlin", "Asia/Singapore", "UTC"]) -def tzinfo(request): - if TEST_WITH_OPT_DEPS: - return pytz.timezone(request.param) - hours_offset = {"Europe/Berlin": 2, "Asia/Singapore": 8, "UTC": 0}[request.param] - return BasicTimezone(offset=datetime.timedelta(hours=hours_offset), name=request.param) +def tzinfo(request, _tz_implementation): + return _tz_implementation(request.param) @pytest.fixture(scope="session") def timezone(tzinfo): return tzinfo + + +@pytest.fixture +def tmp_file(tmp_path) -> Path: + return tmp_path / uuid4().hex + + +@pytest.fixture(scope="session") +def dummy_message(): + return make_message("dummy_message") + + +@pytest.fixture(scope="session") +def dummy_message_dict(dummy_message): + return dummy_message.to_dict() diff --git a/tests/docs/admonition_inserter.py b/tests/docs/admonition_inserter.py index c135cbfd1cb..6f5f258c30f 100644 --- a/tests/docs/admonition_inserter.py +++ b/tests/docs/admonition_inserter.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,10 +17,12 @@ # 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 is intentionally named without "test_" prefix. -# These tests are supposed to be run on GitHub when building docs. -# The tests require Python 3.9+ (just like AdmonitionInserter being tested), -# so they cannot be included in the main suite while older versions of Python are supported. +""" +This module is intentionally named without "test_" prefix. +These tests are supposed to be run on GitHub when building docs. +The tests require Python 3.10+ (just like AdmonitionInserter being tested), +so they cannot be included in the main suite while older versions of Python are supported. +""" import collections.abc @@ -96,13 +98,33 @@ def test_admonitions_dict(self, admonition_inserter): ( "available_in", telegram.Sticker, - ":attr:`telegram.StickerSet.stickers`", # Tuple[telegram.Sticker] + ":attr:`telegram.StickerSet.stickers`", # tuple[telegram.Sticker] ), ( "available_in", telegram.ResidentialAddress, # mentioned on the second line of docstring of .data ":attr:`telegram.EncryptedPassportElement.data`", ), + ( + "available_in", + telegram.ext.JobQueue, + ":attr:`telegram.ext.CallbackContext.job_queue`", + ), + ( + "available_in", + telegram.ext.Application, + ":attr:`telegram.ext.CallbackContext.application`", + ), + ( + "available_in", + telegram.Bot, + ":attr:`telegram.ext.CallbackContext.bot`", + ), + ( + "available_in", + telegram.Bot, + ":attr:`telegram.ext.Application.bot`", + ), ( "returned_in", telegram.StickerSet, @@ -113,6 +135,11 @@ def test_admonitions_dict(self, admonition_inserter): telegram.ChatMember, ":meth:`telegram.Bot.get_chat_member`", ), + ( + "returned_in", + telegram.GameHighScore, + ":meth:`telegram.Bot.get_game_high_scores`", + ), ( "returned_in", telegram.ChatMemberOwner, @@ -135,6 +162,18 @@ def test_admonitions_dict(self, admonition_inserter): # one of which is with Bot ":meth:`telegram.CallbackQuery.edit_message_caption`", ), + ( + "shortcuts", + telegram.Bot.ban_chat_member, + # ban_member is defined on the private parent class _ChatBase + ":meth:`telegram.Chat.ban_member`", + ), + ( + "shortcuts", + telegram.Bot.ban_chat_member, + # ban_member is defined on the private parent class _ChatBase + ":meth:`telegram.ChatFullInfo.ban_member`", + ), ( "use_in", telegram.InlineQueryResult, @@ -147,8 +186,8 @@ def test_admonitions_dict(self, admonition_inserter): ), ( "use_in", - telegram.MaskPosition, - ":meth:`telegram.Bot.add_sticker_to_set`", # optional + telegram.InlineKeyboardMarkup, + ":meth:`telegram.Bot.send_message`", # optional ), ( "use_in", @@ -205,9 +244,16 @@ def test_check_presence(self, admonition_inserter, admonition_type, cls, link): "returned_in", telegram.ext.CallbackContext, # -> Application[BT, CCT, UD, CD, BD, JQ]. - # In this case classes inside square brackets must not be parsed + # The type vars are not really part of the return value, so we don't expect them ":meth:`telegram.ext.ApplicationBuilder.build`", ), + ( + "returned_in", + telegram.Bot, + # -> Application[BT, CCT, UD, CD, BD, JQ]. + # The type vars are not really part of the return value, so we don't expect them + ":meth:`telegram.ext.ApplicationBuilder.bot`", + ), ], ) def test_check_absence(self, admonition_inserter, admonition_type, cls, link): diff --git a/tests/ext/__init__.py b/tests/ext/__init__.py index 1eaba12c869..c95cb3c9741 100644 --- a/tests/ext/__init__.py +++ b/tests/ext/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/_utils/__init__.py b/tests/ext/_utils/__init__.py index 1eaba12c869..c95cb3c9741 100644 --- a/tests/ext/_utils/__init__.py +++ b/tests/ext/_utils/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/_utils/test_networkloop.py b/tests/ext/_utils/test_networkloop.py new file mode 100644 index 00000000000..ba3f64a3e10 --- /dev/null +++ b/tests/ext/_utils/test_networkloop.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 tests for the network_retry_loop function. + +Note: + Most of the retry loop functionality is already covered in test_updater and test_application. + These tests focus specifically on the max_retries behavior for different exception types + and the error callback handling, which were added as part of the bug fix in #5030. +""" + +import pytest + +from telegram.error import InvalidToken, RetryAfter, TelegramError, TimedOut +from telegram.ext._utils.networkloop import network_retry_loop + + +class TestNetworkRetryLoop: + """Tests for the network_retry_loop function. + + Note: + The general retry loop functionality is extensively tested in test_updater and + test_application. These tests focus on the specific max_retries behavior for + different exception types. + """ + + @pytest.mark.parametrize( + ("exception_class", "exception_args"), + [ + (RetryAfter, (1,)), + (TimedOut, ("Test timeout",)), + ], + ids=["RetryAfter", "TimedOut"], + ) + async def test_exception_respects_max_retries(self, exception_class, exception_args): + """Test that RetryAfter and TimedOut exceptions respect max_retries limit.""" + call_count = 0 + + async def action_with_exception(): + nonlocal call_count + call_count += 1 + raise exception_class(*exception_args) + + with pytest.raises(exception_class): + await network_retry_loop( + action_cb=action_with_exception, + description=f"Test {exception_class.__name__}", + interval=0, + max_retries=2, + ) + + # Should be called 3 times: initial call + 2 retries + assert call_count == 3 + + @pytest.mark.parametrize( + ("exception_class", "exception_args"), + [ + (RetryAfter, (1,)), + (TimedOut, ("Test timeout",)), + ], + ids=["RetryAfter", "TimedOut"], + ) + async def test_exception_with_zero_max_retries(self, exception_class, exception_args): + """Test that RetryAfter and TimedOut with max_retries=0 don't retry.""" + call_count = 0 + + async def action_with_exception(): + nonlocal call_count + call_count += 1 + raise exception_class(*exception_args) + + with pytest.raises(exception_class): + await network_retry_loop( + action_cb=action_with_exception, + description=f"Test {exception_class.__name__} no retries", + interval=0, + max_retries=0, + ) + + # Should be called only once with max_retries=0 + assert call_count == 1 + + async def test_invalid_token_aborts_immediately(self): + """Test that InvalidToken exceptions abort immediately without retries.""" + call_count = 0 + + async def action_with_invalid_token(): + nonlocal call_count + call_count += 1 + raise InvalidToken("Invalid token") + + with pytest.raises(InvalidToken): + await network_retry_loop( + action_cb=action_with_invalid_token, + description="Test InvalidToken", + interval=0, + max_retries=5, + ) + + # Should be called only once, no retries for invalid token + assert call_count == 1 + + async def test_telegram_error_respects_max_retries(self): + """Test that general TelegramError exceptions respect max_retries limit.""" + call_count = 0 + + async def action_with_telegram_error(): + nonlocal call_count + call_count += 1 + raise TelegramError("Test error") + + with pytest.raises(TelegramError): + await network_retry_loop( + action_cb=action_with_telegram_error, + description="Test TelegramError", + interval=0, + max_retries=3, + ) + + # Should be called 4 times: initial call + 3 retries + assert call_count == 4 + + @pytest.mark.parametrize( + ("exception_class", "exception_args"), + [ + (RetryAfter, (1,)), + (TimedOut, ("Test timeout",)), + (InvalidToken, ("Invalid token",)), + ], + ids=["RetryAfter", "TimedOut", "InvalidToken"], + ) + async def test_error_callback_not_called_for_specific_exceptions( + self, exception_class, exception_args + ): + """Test that error callback is not called for RetryAfter, TimedOut, or InvalidToken.""" + error_callback_called = False + + def error_callback(exc): + nonlocal error_callback_called + error_callback_called = True + + async def action_with_exception(): + raise exception_class(*exception_args) + + with pytest.raises(exception_class): + await network_retry_loop( + action_cb=action_with_exception, + on_err_cb=error_callback, + description=f"Test {exception_class.__name__} callback", + interval=0, + max_retries=1, + ) + + assert not error_callback_called + + async def test_error_callback_called_for_telegram_error(self): + """Test that error callback is called for general TelegramError exceptions.""" + error_callback_count = 0 + caught_exception = None + + def error_callback(exc): + nonlocal error_callback_count, caught_exception + error_callback_count += 1 + caught_exception = exc + + async def action_with_telegram_error(): + raise TelegramError("Test error") + + with pytest.raises(TelegramError): + await network_retry_loop( + action_cb=action_with_telegram_error, + on_err_cb=error_callback, + description="Test TelegramError callback", + interval=0, + max_retries=2, + ) + + # Should be called 3 times (initial + 2 retries) + assert error_callback_count == 3 + assert isinstance(caught_exception, TelegramError) + + async def test_success_after_retries(self): + """Test that action succeeds after some retries.""" + call_count = 0 + + async def action_succeeds_on_third_try(): + nonlocal call_count + call_count += 1 + if call_count < 3: + raise TimedOut("Test timeout") + # Success on third try + + await network_retry_loop( + action_cb=action_succeeds_on_third_try, + description="Test success after retries", + interval=0, + max_retries=5, + ) + + assert call_count == 3 + + @pytest.mark.parametrize( + ("exception_class", "exception_args", "success_after"), + [ + (RetryAfter, (0.01,), 5), + (TimedOut, ("Test timeout",), 4), + ], + ids=["RetryAfter", "TimedOut"], + ) + async def test_exception_with_negative_max_retries( + self, exception_class, exception_args, success_after + ): + """Test that exceptions with max_retries=-1 retry indefinitely until success.""" + call_count = 0 + + async def action_succeeds_after_few_tries(): + nonlocal call_count + call_count += 1 + if call_count < success_after: + raise exception_class(*exception_args) + # Success after specified tries + + await network_retry_loop( + action_cb=action_succeeds_after_few_tries, + description=f"Test {exception_class.__name__} infinite retries", + interval=0, + max_retries=-1, + ) + + assert call_count == success_after diff --git a/tests/ext/_utils/test_stack.py b/tests/ext/_utils/test_stack.py index 13305b5029d..22a33b9bc5e 100644 --- a/tests/ext/_utils/test_stack.py +++ b/tests/ext/_utils/test_stack.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -80,7 +80,7 @@ def caller_func(): symlink_to(symlink_file, temp_file) sys.path.append(tmp_path.as_posix()) - from caller_link import caller_func + from caller_link import caller_func # noqa: PLC0415 frame = caller_func() assert was_called_by(frame, temp_file) @@ -111,7 +111,7 @@ def outer_func(): symlink_to(symlink_file2, temp_file2) sys.path.append(tmp_path.as_posix()) - from outer_link import outer_func + from outer_link import outer_func # noqa: PLC0415 frame = outer_func() assert was_called_by(frame, temp_file2) diff --git a/tests/ext/_utils/test_trackingdict.py b/tests/ext/_utils/test_trackingdict.py index 5ed327c7161..a5054b2f25b 100644 --- a/tests/ext/_utils/test_trackingdict.py +++ b/tests/ext/_utils/test_trackingdict.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,14 +23,14 @@ from tests.auxil.slots import mro_slots -@pytest.fixture() +@pytest.fixture def td() -> TrackingDict: td = TrackingDict() td.update_no_track({1: 1}) return td -@pytest.fixture() +@pytest.fixture def data() -> dict: return {1: 1} diff --git a/tests/ext/conftest.py b/tests/ext/conftest.py new file mode 100644 index 00000000000..f1a877b6e27 --- /dev/null +++ b/tests/ext/conftest.py @@ -0,0 +1,102 @@ +import asyncio + +import pytest + +from telegram.ext import ApplicationBuilder, Updater +from telegram.ext.filters import MessageFilter, UpdateFilter +from tests.auxil.constants import PRIVATE_KEY +from tests.auxil.envvars import TEST_WITH_OPT_DEPS +from tests.auxil.monkeypatch import return_true +from tests.auxil.networking import OfflineRequest +from tests.auxil.pytest_classes import PytestApplication, PytestBot, make_bot + +# This module overrides the bot fixtures defined in the global conftest.py to use the offline bot. +# We don't want the tests on telegram.ext to depend on the network, so we use the offline bot +# instead. + + +@pytest.fixture(scope="session") +async def bot(bot_info, offline_bot): + return offline_bot + + +@pytest.fixture +async def app(bot_info, monkeypatch): + # We build a new bot each time so that we use `app` in a context manager without problems + application = ( + ApplicationBuilder() + .bot(make_bot(bot_info, offline=True)) + .application_class(PytestApplication) + .build() + ) + monkeypatch.setattr(application.bot, "delete_webhook", return_true) + monkeypatch.setattr(application.bot, "set_webhook", return_true) + yield application + if application.running: + await application.stop() + await application.shutdown() + + +@pytest.fixture +async def updater(bot_info, monkeypatch): + # We build a new bot each time so that we use `updater` in a context manager without problems + up = Updater(bot=make_bot(bot_info, offline=True), update_queue=asyncio.Queue()) + monkeypatch.setattr(up.bot, "delete_webhook", return_true) + monkeypatch.setattr(up.bot, "set_webhook", return_true) + yield up + if up.running: + await up.stop() + await up.shutdown() + + +@pytest.fixture +def one_time_bot(bot_info): + """A function scoped bot since the session bot would shutdown when `async with app` finishes""" + return make_bot(bot_info, offline=True) + + +@pytest.fixture(scope="session") +async def cdc_bot(bot_info): + """Makes an ExtBot instance with the given bot_info that uses arbitrary callback_data""" + async with make_bot(bot_info, arbitrary_callback_data=True, offline=True) as _bot: + yield _bot + + +@pytest.fixture(scope="session") +async def raw_bot(bot_info): + """Makes an regular Bot instance with the given bot_info""" + async with PytestBot( + bot_info["token"], + private_key=PRIVATE_KEY if TEST_WITH_OPT_DEPS else None, + request=OfflineRequest(), + get_updates_request=OfflineRequest(), + ) as _bot: + yield _bot + + +@pytest.fixture +async def one_time_raw_bot(bot_info): + """Makes an regular Bot instance with the given bot_info""" + return PytestBot( + bot_info["token"], + private_key=PRIVATE_KEY if TEST_WITH_OPT_DEPS else None, + request=OfflineRequest(), + get_updates_request=OfflineRequest(), + ) + + +@pytest.fixture( + scope="class", + params=[{"class": MessageFilter}, {"class": UpdateFilter}], + ids=["MessageFilter", "UpdateFilter"], +) +def mock_filter(request): + class MockFilter(request.param["class"]): + def __init__(self): + super().__init__() + self.tested = False + + def filter(self, _): + self.tested = True + + return MockFilter() diff --git a/tests/ext/test_application.py b/tests/ext/test_application.py index 00001524853..c26e8b3f32a 100644 --- a/tests/ext/test_application.py +++ b/tests/ext/test_application.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,14 +16,16 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -"""The integration of persistence into the application is tested in test_basepersistence. -""" +"""The integration of persistence into the application is tested in test_basepersistence.""" + import asyncio +import functools import inspect import logging import os import platform import signal +import sys import threading import time from collections import defaultdict @@ -31,12 +33,11 @@ from queue import Queue from random import randrange from threading import Thread -from typing import Optional import pytest from telegram import Bot, Chat, Message, MessageEntity, User -from telegram.error import TelegramError +from telegram.error import InvalidToken, TelegramError from telegram.ext import ( Application, ApplicationBuilder, @@ -54,12 +55,13 @@ Updater, filters, ) -from telegram.warnings import PTBUserWarning +from telegram.warnings import PTBDeprecationWarning, PTBUserWarning from tests.auxil.asyncio_helpers import call_after from tests.auxil.build_messages import make_message_update -from tests.auxil.files import PROJECT_ROOT_PATH +from tests.auxil.files import SOURCE_ROOT_PATH +from tests.auxil.monkeypatch import empty_get_updates, return_true 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 @@ -93,7 +95,7 @@ async def error_handler_raise_error(self, update, context): async def callback_increase_count(self, update, context): self.count += 1 - def callback_set_count(self, count, sleep: Optional[float] = None): + def callback_set_count(self, count, sleep: float | None = None): async def callback(update, context): if sleep: await asyncio.sleep(sleep) @@ -200,10 +202,13 @@ async def post_stop(application: Application) -> None: assert isinstance(app.chat_data[1], dict) assert isinstance(app.user_data[1], dict) + async def test_repr(self, app): + assert repr(app) == f"PytestApplication[bot={app.bot!r}]" + def test_job_queue(self, one_time_bot, app, recwarn): expected_warning = ( "No `JobQueue` set up. To use `JobQueue`, you must install PTB via " - "`pip install python-telegram-bot[job-queue]`." + '`pip install "python-telegram-bot[job-queue]"`.' ) assert app.job_queue is app._job_queue application = ApplicationBuilder().bot(one_time_bot).job_queue(None).build() @@ -296,15 +301,19 @@ def after_updater_shutdown(*args, **kwargs): ) if updater: - async with ApplicationBuilder().bot(one_time_bot).concurrent_updates( - update_processor - ).build(): + async with ( + ApplicationBuilder().bot(one_time_bot).concurrent_updates(update_processor).build() + ): pass assert self.test_flag == {"bot", "update_processor", "updater"} else: - async with ApplicationBuilder().bot(one_time_bot).updater(None).concurrent_updates( - update_processor - ).build(): + async with ( + ApplicationBuilder() + .bot(one_time_bot) + .updater(None) + .concurrent_updates(update_processor) + .build() + ): pass assert self.test_flag == {"bot", "update_processor"} @@ -427,7 +436,7 @@ def test_builder(self, app): @pytest.mark.parametrize("job_queue", [True, False]) @pytest.mark.filterwarnings("ignore::telegram.warnings.PTBUserWarning") - async def test_start_stop_processing_updates(self, one_time_bot, job_queue): + async def test_start_stop_processing_updates(self, one_time_bot, job_queue, monkeypatch): # TODO: repeat a similar test for create_task, persistence processing and job queue if job_queue: app = ApplicationBuilder().bot(one_time_bot).build() @@ -437,6 +446,9 @@ async def test_start_stop_processing_updates(self, one_time_bot, job_queue): async def callback(u, c): self.received = u + monkeypatch.setattr(app.bot, "get_updates", empty_get_updates) + monkeypatch.setattr(app.bot, "delete_webhook", return_true) + assert not app.running assert not app.updater.running if job_queue: @@ -453,6 +465,8 @@ async def callback(u, c): async with app: await app.start() assert app.running + tasks = asyncio.all_tasks() + assert any(":update_fetcher" in task.get_name() for task in tasks) if job_queue: assert app.job_queue.scheduler.running else: @@ -551,7 +565,6 @@ async def test_add_remove_handler_non_default_group(self, app): app.remove_handler(handler) app.remove_handler(handler, group=2) - # async def test_handler_order_in_group(self, app): app.add_handler(MessageHandler(filters.PHOTO, self.callback_set_count(1))) app.add_handler(MessageHandler(filters.ALL, self.callback_set_count(2))) @@ -633,11 +646,11 @@ async def test_add_handlers(self, app): assert len(app.handlers[-1]) == 1 # Now lets test the errors which can be produced- - with pytest.raises(ValueError, match="The `group` argument"): + with pytest.raises(TypeError, match="The `group` argument"): app.add_handlers({2: [msg_handler_set_count]}, group=0) - with pytest.raises(ValueError, match="Handlers for group 3"): + with pytest.raises(TypeError, match="Handlers for group 3"): app.add_handlers({3: msg_handler_set_count}) - with pytest.raises(ValueError, match="The `handlers` argument must be a sequence"): + with pytest.raises(TypeError, match="The `handlers` argument must be a sequence"): app.add_handlers({msg_handler_set_count}) await app.stop() @@ -964,6 +977,8 @@ async def callback(update, context): await app.update_queue.put(1) task = asyncio.create_task(app.stop()) await asyncio.sleep(0.05) + tasks = asyncio.all_tasks() + assert any(":process_update_non_blocking" in t.get_name() for t in tasks) assert self.count == 1 # Make sure that app stops only once all non blocking callbacks are done assert not task.done() @@ -990,9 +1005,9 @@ async def callback(update, context): str(recwarn[0].message) == "ApplicationHandlerStop is not supported with handlers running non-blocking." ) - assert ( - Path(recwarn[0].filename) == PROJECT_ROOT_PATH / "telegram" / "ext" / "_application.py" - ), "incorrect stacklevel!" + assert Path(recwarn[0].filename) == SOURCE_ROOT_PATH / "ext" / "_application.py", ( + "incorrect stacklevel!" + ) async def test_non_blocking_no_error_handler(self, app, caplog): app.add_handler(TypeHandler(object, self.callback_raise_error("Test error"), block=False)) @@ -1029,6 +1044,8 @@ async def normal_error_handler(update, context): await app.update_queue.put(self.message_update) task = asyncio.create_task(app.stop()) await asyncio.sleep(0.05) + tasks = asyncio.all_tasks() + assert any(":process_error:non_blocking" in t.get_name() for t in tasks) assert self.count == 42 assert self.received is None event.set() @@ -1061,9 +1078,9 @@ async def error_handler(update, context): str(recwarn[0].message) == "ApplicationHandlerStop is not supported with handlers running non-blocking." ) - assert ( - Path(recwarn[0].filename) == PROJECT_ROOT_PATH / "telegram" / "ext" / "_application.py" - ), "incorrect stacklevel!" + assert Path(recwarn[0].filename) == SOURCE_ROOT_PATH / "ext" / "_application.py", ( + "incorrect stacklevel!" + ) @pytest.mark.parametrize(("block", "expected_output"), [(False, 0), (True, 5)]) async def test_default_block_error_handler(self, bot_info, block, expected_output): @@ -1196,7 +1213,8 @@ async def callback(): self.count = 42 return 43 - task = app.create_task(callback()) + task = app.create_task(callback(), name="test_task") + assert task.get_name() == "test_task" await asyncio.sleep(0.01) assert not task.done() out = await task @@ -1313,7 +1331,8 @@ async def callback(): out = await app.create_task(asyncio.gather(callback())) assert out == [42] - async def test_create_task_awaiting_generator(self, app): + @pytest.mark.skipif(sys.version_info >= (3, 12), reason="generator coroutines are deprecated") + async def test_create_task_awaiting_generator(self, app, recwarn): event = asyncio.Event() def gen(): @@ -1322,6 +1341,9 @@ def gen(): await app.create_task(gen()) assert event.is_set() + assert len(recwarn) == 2 # 1st warning is: tasks not being awaited when app isn't running + assert recwarn[1].category is PTBDeprecationWarning + assert "Generator-based coroutines are deprecated" in str(recwarn[1].message) async def test_no_update_processor(self, app): queue = asyncio.Queue() @@ -1377,6 +1399,8 @@ async def callback(u, c): assert not events[i].is_set() await asyncio.sleep(0.9) + tasks = asyncio.all_tasks() + assert any(":process_concurrent_update" in task.get_name() for task in tasks) for i in range(app.update_processor.max_concurrent_updates): assert events[i].is_set() for i in range( @@ -1414,8 +1438,9 @@ async def callback(update, context): platform.system() == "Windows", reason="Can't send signals without stopping whole process on windows", ) - def test_run_polling_basic(self, app, monkeypatch): + def test_run_polling_basic(self, app, monkeypatch, caplog): exception_event = threading.Event() + exception_testing_done = threading.Event() update_event = threading.Event() exception = TelegramError("This is a test error") assertions = {} @@ -1423,8 +1448,14 @@ def test_run_polling_basic(self, app, monkeypatch): async def get_updates(*args, **kwargs): if exception_event.is_set(): raise exception + # This makes sure that other coroutines have a chance of running as well - await asyncio.sleep(0) + if exception_testing_done.is_set() and app.updater.running: + # the longer sleep makes sure that we can exit also while get_updates is running + await asyncio.sleep(20) + else: + await asyncio.sleep(0.01) + update_event.set() return [self.message_update] @@ -1450,7 +1481,12 @@ def thread_target(): exception_event.set() time.sleep(0.05) assertions["exception_handling"] = self.received == exception.message + exception_testing_done.set() + + # So that the get_updates call on shutdown doesn't fail + exception_event.clear() + time.sleep(1) os.kill(os.getpid(), signal.SIGINT) time.sleep(0.1) @@ -1465,13 +1501,20 @@ def thread_target(): thread = Thread(target=thread_target) thread.start() - app.run_polling(drop_pending_updates=True, close_loop=False) - thread.join() + with caplog.at_level(logging.DEBUG): + app.run_polling(drop_pending_updates=True, close_loop=False) + thread.join(timeout=10) assert len(assertions) == 8 for key, value in assertions.items(): assert value, f"assertion '{key}' failed!" + found_log = False + for record in caplog.records: + if "received stop signal" in record.getMessage() and record.levelno == logging.DEBUG: + found_log = True + assert found_log + @pytest.mark.skipif( platform.system() == "Windows", reason="Can't send signals without stopping whole process on windows", @@ -1479,11 +1522,6 @@ def thread_target(): def test_run_polling_post_init(self, one_time_bot, monkeypatch): events = [] - async def get_updates(*args, **kwargs): - # This makes sure that other coroutines have a chance of running as well - await asyncio.sleep(0) - return [] - def thread_target(): waited = 0 while not app.running: @@ -1497,9 +1535,15 @@ 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(app.bot, "get_updates", empty_get_updates) monkeypatch.setattr( app, "initialize", call_after(app.initialize, lambda _: events.append("init")) ) @@ -1508,11 +1552,12 @@ async def post_init(app: Application) -> None: "start_polling", call_after(app.updater.start_polling, lambda _: events.append("start_polling")), ) + monkeypatch.setattr(app.bot, "delete_webhook", return_true) thread = Thread(target=thread_target) thread.start() app.run_polling(drop_pending_updates=True, close_loop=False) - thread.join() + thread.join(timeout=10) assert events == ["init", "post_init", "start_polling"], "Wrong order of events detected!" @pytest.mark.skipif( @@ -1522,11 +1567,6 @@ async def post_init(app: Application) -> None: def test_run_polling_post_shutdown(self, one_time_bot, monkeypatch): events = [] - async def get_updates(*args, **kwargs): - # This makes sure that other coroutines have a chance of running as well - await asyncio.sleep(0) - return [] - def thread_target(): waited = 0 while not app.running: @@ -1540,9 +1580,15 @@ 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(app.bot, "get_updates", empty_get_updates) monkeypatch.setattr( app, "shutdown", call_after(app.shutdown, lambda _: events.append("shutdown")) ) @@ -1551,11 +1597,12 @@ async def post_shutdown(app: Application) -> None: "shutdown", call_after(app.updater.shutdown, lambda _: events.append("updater.shutdown")), ) + monkeypatch.setattr(app.bot, "delete_webhook", return_true) thread = Thread(target=thread_target) thread.start() app.run_polling(drop_pending_updates=True, close_loop=False) - thread.join() + thread.join(timeout=10) assert events == [ "updater.shutdown", "shutdown", @@ -1566,14 +1613,9 @@ 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): - # This makes sure that other coroutines have a chance of running as well - await asyncio.sleep(0) - return [] - def thread_target(): waited = 0 while not app.running: @@ -1587,9 +1629,15 @@ 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.bot, "get_updates", empty_get_updates) monkeypatch.setattr(app, "stop", call_after(app.stop, lambda _: events.append("stop"))) monkeypatch.setattr( app.updater, @@ -1601,11 +1649,12 @@ async def post_stop(app: Application) -> None: "shutdown", call_after(app.updater.shutdown, lambda _: events.append("updater.shutdown")), ) + monkeypatch.setattr(app.bot, "delete_webhook", return_true) thread = Thread(target=thread_target) thread.start() app.run_polling(drop_pending_updates=True, close_loop=False) - thread.join() + thread.join(timeout=10) assert events == [ "updater.stop", "stop", @@ -1654,7 +1703,7 @@ def thread_target(): thread = Thread(target=thread_target) thread.start() app.run_polling(close_loop=False) - thread.join() + thread.join(timeout=10) assert set(self.received.keys()) == set(updater_signature.parameters.keys()) for name, param in updater_signature.parameters.items(): @@ -1666,10 +1715,11 @@ def thread_target(): expected = { name: name for name in updater_signature.parameters if name != "error_callback" } + expected["bootstrap_retries"] = 42 thread = Thread(target=thread_target) thread.start() app.run_polling(close_loop=False, **expected) - thread.join() + thread.join(timeout=10) assert set(self.received.keys()) == set(updater_signature.parameters.keys()) assert self.received.pop("error_callback", None) @@ -1679,15 +1729,9 @@ def thread_target(): platform.system() == "Windows", reason="Can't send signals without stopping whole process on windows", ) - def test_run_webhook_basic(self, app, monkeypatch): + def test_run_webhook_basic(self, app, monkeypatch, caplog): assertions = {} - async def delete_webhook(*args, **kwargs): - return True - - async def set_webhook(*args, **kwargs): - return True - def thread_target(): waited = 0 while not app.running: @@ -1718,8 +1762,6 @@ def thread_target(): assertions["updater_not_running"] = not app.updater.running assertions["job_queue_not_running"] = not app.job_queue.scheduler.running - monkeypatch.setattr(app.bot, "set_webhook", set_webhook) - monkeypatch.setattr(app.bot, "delete_webhook", delete_webhook) app.add_handler(TypeHandler(object, self.callback_set_count(42))) thread = Thread(target=thread_target) @@ -1728,19 +1770,26 @@ def thread_target(): ip = "127.0.0.1" port = randrange(1024, 49152) - app.run_webhook( - ip_address=ip, - port=port, - url_path="TOKEN", - drop_pending_updates=True, - close_loop=False, - ) - thread.join() + with caplog.at_level(logging.DEBUG): + app.run_webhook( + ip_address=ip, + port=port, + url_path="TOKEN", + drop_pending_updates=True, + close_loop=False, + ) + thread.join(timeout=10) assert len(assertions) == 7 for key, value in assertions.items(): assert value, f"assertion '{key}' failed!" + found_log = False + for record in caplog.records: + if "received stop signal" in record.getMessage() and record.levelno == logging.DEBUG: + found_log = True + assert found_log + @pytest.mark.skipif( platform.system() == "Windows", reason="Can't send signals without stopping whole process on windows", @@ -1748,17 +1797,6 @@ def thread_target(): def test_run_webhook_post_init(self, one_time_bot, monkeypatch): events = [] - async def delete_webhook(*args, **kwargs): - return True - - async def set_webhook(*args, **kwargs): - return True - - async def get_updates(*args, **kwargs): - # This makes sure that other coroutines have a chance of running as well - await asyncio.sleep(0) - return [] - def thread_target(): waited = 0 while not app.running: @@ -1772,10 +1810,15 @@ 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) + monkeypatch.setattr( app, "initialize", call_after(app.initialize, lambda _: events.append("init")) ) @@ -1784,6 +1827,8 @@ async def post_init(app: Application) -> None: "start_webhook", call_after(app.updater.start_webhook, lambda _: events.append("start_webhook")), ) + monkeypatch.setattr(app.bot, "delete_webhook", return_true) + monkeypatch.setattr(app.bot, "set_webhook", return_true) thread = Thread(target=thread_target) thread.start() @@ -1798,7 +1843,7 @@ async def post_init(app: Application) -> None: drop_pending_updates=True, close_loop=False, ) - thread.join() + thread.join(timeout=10) assert events == ["init", "post_init", "start_webhook"], "Wrong order of events detected!" @pytest.mark.skipif( @@ -1808,17 +1853,6 @@ async def post_init(app: Application) -> None: def test_run_webhook_post_shutdown(self, one_time_bot, monkeypatch): events = [] - async def delete_webhook(*args, **kwargs): - return True - - async def set_webhook(*args, **kwargs): - return True - - async def get_updates(*args, **kwargs): - # This makes sure that other coroutines have a chance of running as well - await asyncio.sleep(0) - return [] - def thread_target(): waited = 0 while not app.running: @@ -1832,10 +1866,15 @@ 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) + monkeypatch.setattr( app, "shutdown", call_after(app.shutdown, lambda _: events.append("shutdown")) ) @@ -1844,6 +1883,8 @@ async def post_shutdown(app: Application) -> None: "shutdown", call_after(app.updater.shutdown, lambda _: events.append("updater.shutdown")), ) + monkeypatch.setattr(app.bot, "delete_webhook", return_true) + monkeypatch.setattr(app.bot, "set_webhook", return_true) thread = Thread(target=thread_target) thread.start() @@ -1858,7 +1899,7 @@ async def post_shutdown(app: Application) -> None: drop_pending_updates=True, close_loop=False, ) - thread.join() + thread.join(timeout=10) assert events == [ "updater.shutdown", "shutdown", @@ -1869,20 +1910,9 @@ 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): - return True - - async def set_webhook(*args, **kwargs): - return True - - async def get_updates(*args, **kwargs): - # This makes sure that other coroutines have a chance of running as well - await asyncio.sleep(0) - return [] - def thread_target(): waited = 0 while not app.running: @@ -1896,10 +1926,15 @@ 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) + monkeypatch.setattr(app, "stop", call_after(app.stop, lambda _: events.append("stop"))) monkeypatch.setattr( app.updater, @@ -1911,6 +1946,8 @@ async def post_stop(app: Application) -> None: "shutdown", call_after(app.updater.shutdown, lambda _: events.append("updater.shutdown")), ) + monkeypatch.setattr(app.bot, "delete_webhook", return_true) + monkeypatch.setattr(app.bot, "set_webhook", return_true) thread = Thread(target=thread_target) thread.start() @@ -1925,7 +1962,7 @@ async def post_stop(app: Application) -> None: drop_pending_updates=True, close_loop=False, ) - thread.join() + thread.join(timeout=10) assert events == [ "updater.stop", "stop", @@ -1976,7 +2013,7 @@ def thread_target(): thread = Thread(target=thread_target) thread.start() app.run_webhook(close_loop=False) - thread.join() + thread.join(timeout=10) assert set(self.received.keys()) == set(updater_signature.parameters.keys()) - {"self"} for name, param in updater_signature.parameters.items(): @@ -1985,83 +2022,180 @@ def thread_target(): assert self.received[name] == param.default expected = {name: name for name in updater_signature.parameters if name != "self"} + expected["bootstrap_retries"] = 42 thread = Thread(target=thread_target) thread.start() app.run_webhook(close_loop=False, **expected) - thread.join() + thread.join(timeout=10) assert set(self.received.keys()) == set(expected.keys()) assert self.received == expected - @pytest.mark.skipif( - platform.system() == "Windows", - reason="Can't send signals without stopping whole process on windows", - ) - async def test_cancellation_error_does_not_stop_polling( - self, one_time_bot, monkeypatch, caplog + @pytest.mark.parametrize("exception", [SystemExit, KeyboardInterrupt]) + def test_raise_system_exit_keyboard_interrupt_post_init( + self, one_time_bot, monkeypatch, exception ): - """ - Ensures that hitting CTRL+C while polling *without* run_polling doesn't kill - the update_fetcher loop such that a shutdown is still possible. - This test is far from perfect, but it's the closest we can come with sane effort. - """ - - async def get_updates(*args, **kwargs): - await asyncio.sleep(0) - return [None] + async def post_init(application): + raise exception - monkeypatch.setattr(one_time_bot, "get_updates", get_updates) - app = ApplicationBuilder().bot(one_time_bot).build() + called_callbacks = set() + + async def callback(*args, **kwargs): + called_callbacks.add(kwargs["name"]) + + for cls, method, entry in [ + (Application, "initialize", "app_initialize"), + (Application, "start", "app_start"), + (Application, "stop", "app_stop"), + (Application, "shutdown", "app_shutdown"), + (Updater, "initialize", "updater_initialize"), + (Updater, "shutdown", "updater_shutdown"), + (Updater, "stop", "updater_stop"), + (Updater, "start_polling", "updater_start_polling"), + ]: + + def after(_, name): + called_callbacks.add(name) + + monkeypatch.setattr( + cls, + method, + call_after(getattr(cls, method), functools.partial(after, name=entry)), + ) - original_get = app.update_queue.get - raise_cancelled_error = threading.Event() + app = ( + ApplicationBuilder() + .bot(one_time_bot) + .post_init(post_init) + .post_stop(functools.partial(callback, name="post_stop")) + .post_shutdown(functools.partial(callback, name="post_shutdown")) + .build() + ) - async def get(*arg, **kwargs): - await asyncio.sleep(0.05) - if raise_cancelled_error.is_set(): - raise_cancelled_error.clear() - raise asyncio.CancelledError("Mocked CancelledError") - return await original_get(*arg, **kwargs) + app.run_polling(close_loop=False) - monkeypatch.setattr(app.update_queue, "get", get) + # This checks two things: + # 1. start/stop are *not* called! + # 2. we do have a graceful shutdown + assert called_callbacks == { + "app_initialize", + "updater_initialize", + "app_shutdown", + "post_shutdown", + "updater_shutdown", + } - def thread_target(): - waited = 0 - while not app.running: - time.sleep(0.05) - waited += 0.05 - if waited > 5: - pytest.fail("App apparently won't start") + @pytest.mark.parametrize("exception", [SystemExit("PTBTest"), KeyboardInterrupt("PTBTest")]) + @pytest.mark.parametrize("kind", ["handler", "error_handler", "job"]) + # @pytest.mark.parametrize("block", [True, False]) + # Testing with block=False would be nice but that doesn't work well with pytest for some reason + # in any case, block=False is the simpler behavior since it is roughly similar to what happens + # when you hit CTRL+C in the commandline. + def test_raise_system_exit_keyboard_jobs_handlers( + self, one_time_bot, monkeypatch, exception, kind, caplog + ): + async def queue_and_raise(application): + await application.update_queue.put("will_not_be_processed") + raise exception - time.sleep(0.1) - raise_cancelled_error.set() + async def handler_callback(update, context): + if kind == "handler": + await queue_and_raise(context.application) + elif kind == "error_handler": + raise TelegramError("Triggering error callback") - async with app: - with caplog.at_level(logging.WARNING): - thread = Thread(target=thread_target) - await app.start() - thread.start() - assert thread.is_alive() - raise_cancelled_error.wait() + async def error_callback(update, context): + await queue_and_raise(context.application) - # The exit should have been caught and the app should still be running - assert not thread.is_alive() - assert app.running + async def job_callback(context): + await queue_and_raise(context.application) - # Explicit shutdown is required - await app.stop() - thread.join() + async def enqueue_update(): + await asyncio.sleep(0.5) + await app.update_queue.put(1) - assert not thread.is_alive() - assert not app.running + async def post_init(application): + if kind == "job": + application.job_queue.run_once(when=0.5, callback=job_callback) + else: + app.create_task(enqueue_update()) + + async def update_logger_callback(update, context): + context.bot_data.setdefault("processed_updates", set()).add(update) + + called_callbacks = set() + + async def callback(*args, **kwargs): + called_callbacks.add(kwargs["name"]) + + for cls, method, entry in [ + (Application, "initialize", "app_initialize"), + (Application, "start", "app_start"), + (Application, "stop", "app_stop"), + (Application, "shutdown", "app_shutdown"), + (Updater, "initialize", "updater_initialize"), + (Updater, "shutdown", "updater_shutdown"), + (Updater, "stop", "updater_stop"), + (Updater, "start_polling", "updater_start_polling"), + ]: + + def after(_, name): + called_callbacks.add(name) + + monkeypatch.setattr( + cls, + method, + call_after(getattr(cls, method), functools.partial(after, name=entry)), + ) - # Make sure that we were warned about the necessity of a manual shutdown - assert len(caplog.records) == 1 - record = caplog.records[0] - assert record.name == "telegram.ext.Application" - assert record.getMessage().startswith( - "Fetching updates got a asyncio.CancelledError. Ignoring" + app = ( + ApplicationBuilder() + .bot(one_time_bot) + .post_init(post_init) + .post_stop(functools.partial(callback, name="post_stop")) + .post_shutdown(functools.partial(callback, name="post_shutdown")) + .build() ) + monkeypatch.setattr(app.bot, "get_updates", empty_get_updates) + monkeypatch.setattr(app.bot, "delete_webhook", return_true) + + app.add_handler(TypeHandler(object, update_logger_callback), group=-10) + app.add_handler(TypeHandler(object, handler_callback)) + app.add_error_handler(error_callback) + with caplog.at_level(logging.DEBUG): + app.run_polling(close_loop=False) + + # This checks that we have a clean shutdown even when the user raises SystemExit + # or KeyboardInterrupt in a handler/error handler/job callback + assert called_callbacks == { + "app_initialize", + "app_shutdown", + "app_start", + "app_stop", + "post_shutdown", + "post_stop", + "updater_initialize", + "updater_shutdown", + "updater_start_polling", + "updater_stop", + } + + # These next checks make sure that the update queue is properly cleaned even if there are + # still pending updates in the queue + # Unfortunately this is apparently extremely hard to get right with jobs, so we're + # skipping that case for the sake of simplicity + if kind == "job": + return + + found = False + for record in caplog.records: + if record.getMessage() != "Dropping pending update: will_not_be_processed": + continue + assert record.name == "telegram.ext.Application" + assert record.levelno == logging.DEBUG + found = True + assert found, "`Dropping pending updates` message not found in logs!" + assert "will_not_be_processed" not in app.bot_data.get("processed_updates", set()) def test_run_without_updater(self, one_time_bot): app = ApplicationBuilder().bot(one_time_bot).updater(None).build() @@ -2096,6 +2230,9 @@ def _after_shutdown(*args, **kwargs): Updater, "shutdown", call_after(Updater.shutdown, after_shutdown("updater")) ) app = ApplicationBuilder().bot(one_time_bot).build() + monkeypatch.setattr(app.bot, "delete_webhook", return_true) + monkeypatch.setattr(app.bot, "get_updates", empty_get_updates) + with pytest.raises(RuntimeError, match="Test Exception"): app.run_polling(close_loop=False) @@ -2177,10 +2314,103 @@ async def raise_method(*args, **kwargs): else: app.run_webhook(close_loop=False, stop_signals=None) - assert len(recwarn) == 0 + for record in recwarn: + assert not str(record.message).startswith("Could not add signal handlers for the stop") + + @pytest.mark.parametrize("exception_class", [InvalidToken, TelegramError]) + @pytest.mark.parametrize("retries", [3, 0]) + @pytest.mark.parametrize("method_name", ["run_polling", "run_webhook"]) + async def test_run_polling_webhook_bootstrap_retries( + self, monkeypatch, exception_class, retries, offline_bot, method_name + ): + """This doesn't test all of the internals of the network retry loop. We do that quite + intensively for the `Updater` and here we just want to make sure that the `Application` + does do the retries. + """ + + def thread_target(): + asyncio.set_event_loop(asyncio.new_event_loop()) + app = ( + ApplicationBuilder().bot(offline_bot).application_class(PytestApplication).build() + ) + + async def initialize(*args, **kwargs): + self.count += 1 + raise exception_class(str(self.count)) + + monkeypatch.setattr(app, "initialize", initialize) + method = functools.partial( + getattr(app, method_name), + bootstrap_retries=retries, + close_loop=False, + stop_signals=None, + ) + + if exception_class == InvalidToken: + with pytest.raises(InvalidToken, match="1"): + method() + else: + with pytest.raises(TelegramError, match=str(retries + 1)): + method() + + thread = Thread(target=thread_target) + thread.start() + thread.join(timeout=10) + assert not thread.is_alive(), "Test took to long to run. Aborting" + + @pytest.mark.parametrize("method_name", ["run_polling", "run_webhook"]) + async def test_run_polling_webhook_infinite_bootstrap_retries( + self, monkeypatch, offline_bot, method_name + ): + """Here we simply test that setting `bootstrap_retries=-1` does not lead to the wrong + infinite-loop behavior reported in #4966. Raising an exception on the first call to + `initialize` ensures that a retry actually happens. + """ + + def thread_target(): + asyncio.set_event_loop(asyncio.new_event_loop()) + + async def post_init(application): + application.stop_running() + + app = ( + ApplicationBuilder() + .bot(offline_bot) + .application_class(PytestApplication) + .post_init(post_init) + .build() + ) - @pytest.mark.flaky(3, 1) # loop.call_later will error the test when a flood error is received - def test_signal_handlers(self, app, monkeypatch): + async def do_pass(*args, **kwargs): + pass + + monkeypatch.setattr(app.bot, "initialize", do_pass) + monkeypatch.setattr(app.bot, "delete_webhook", do_pass) + + original_initialize = app.initialize + + async def initialize(*args, **kwargs): + if self.count >= 3: + pytest.fail("Should be called only once. Test failed.") + + self.count += 1 + if self.count == 1: + raise TelegramError("Test Exception") + await original_initialize(*args, **kwargs) + + monkeypatch.setattr(app, "initialize", initialize) + getattr(app, method_name)( + bootstrap_retries=-1, + close_loop=False, + stop_signals=None, + ) + + thread = Thread(target=thread_target) + thread.start() + thread.join(timeout=10) + assert not thread.is_alive(), "Test took to long to run. Aborting" + + def test_signal_handlers(self, offline_bot, monkeypatch): # this test should make sure that signal handlers are set by default on Linux + Mac, # and not on Windows. @@ -2188,17 +2418,30 @@ def test_signal_handlers(self, app, monkeypatch): def signal_handler_test(*args, **kwargs): # args[0] is the signal, [1] the callback - received_signals.append(args[0]) + received_signals.append(args[1]) + + app = ApplicationBuilder().bot(offline_bot).application_class(PytestApplication).build() + + # Mock the necessary methods to avoid network calls + monkeypatch.setattr(app.bot, "get_updates", empty_get_updates) + monkeypatch.setattr(app.bot, "delete_webhook", return_true) loop = asyncio.get_event_loop() - monkeypatch.setattr(loop, "add_signal_handler", signal_handler_test) - def abort_app(): - raise SystemExit + monkeypatch.setattr(loop.__class__, "add_signal_handler", signal_handler_test) - loop.call_later(0.6, abort_app) + # Mock initialize to exit quickly after testing signal handler setup + original_initialize = app.initialize - app.run_polling(close_loop=False) + async def quick_initialize(*args, **kwargs): + await original_initialize(*args, **kwargs) + # Exit quickly by raising an exception after successful initialization + raise TelegramError("Test completed successfully") + + monkeypatch.setattr(app, "initialize", quick_initialize) + + with pytest.raises(TelegramError, match="Test completed successfully"): + app.run_polling(close_loop=False) if platform.system() == "Windows": assert received_signals == [] @@ -2206,10 +2449,393 @@ def abort_app(): assert received_signals == [signal.SIGINT, signal.SIGTERM, signal.SIGABRT] received_signals.clear() - loop.call_later(0.6, abort_app) - app.run_webhook(port=49152, webhook_url="example.com", close_loop=False) + with pytest.raises(TelegramError, match="Test completed successfully"): + app.run_webhook(port=49152, webhook_url="example.com", close_loop=False) if platform.system() == "Windows": assert received_signals == [] else: assert received_signals == [signal.SIGINT, signal.SIGTERM, signal.SIGABRT] + + def test_stop_running_not_running(self, app, caplog): + with caplog.at_level(logging.DEBUG): + app.stop_running() + + assert len(caplog.records) == 1 + assert caplog.records[-1].name == "telegram.ext.Application" + assert caplog.records[-1].getMessage().endswith("`stop_running()` likely has no effect.") + + def test_stop_running_post_init(self, app, monkeypatch, caplog, one_time_bot): + async def post_init(app): + app.stop_running() + + called_callbacks = [] + + async def callback(*args, **kwargs): + called_callbacks.append(kwargs["name"]) + + monkeypatch.setattr(Application, "start", functools.partial(callback, name="start")) + monkeypatch.setattr( + Updater, "start_polling", functools.partialmethod(callback, name="start_polling") + ) + + app = ( + ApplicationBuilder() + .bot(one_time_bot) + .post_init(post_init) + .post_stop(functools.partial(callback, name="post_stop")) + .post_shutdown(functools.partial(callback, name="post_shutdown")) + .build() + ) + + with caplog.at_level(logging.INFO): + app.run_polling(close_loop=False) + + # The important part here is that start(_polling) are *not* called! + # post_stop must not be called either, since we never called stop() + assert called_callbacks == ["post_shutdown"] + + assert len(caplog.records) == 1 + assert caplog.records[-1].name == "telegram.ext.Application" + assert ( + "Application received stop signal via `stop_running`" + in caplog.records[-1].getMessage() + ) + + @pytest.mark.parametrize("method", ["polling", "webhook"]) + def test_stop_running(self, one_time_bot, monkeypatch, method): + # asyncio.Event() seems to be hard to use across different threads (awaiting in main + # thread, setting in another thread), so we use threading.Event() instead. + # This requires the use of run_in_executor, but that's fine. + put_update_event = threading.Event() + callback_done_event = threading.Event() + called_stop_running = threading.Event() + assertions = {} + + async def post_init(app): + # Simply calling app.update_queue.put_nowait(method) in the thread_target doesn't work + # for some reason (probably threading magic), so we use an event from the thread_target + # to put the update into the queue in the main thread. + async def task(app): + await asyncio.get_running_loop().run_in_executor(None, put_update_event.wait) + await app.update_queue.put(method) + + app.create_task(task(app)) + + app = ( + ApplicationBuilder() + .application_class(PytestApplication) + .updater(PytestUpdater(one_time_bot, asyncio.Queue())) + .post_init(post_init) + .build() + ) + monkeypatch.setattr(app.bot, "get_updates", empty_get_updates) + + events = [] + monkeypatch.setattr( + app.updater, + "stop", + call_after(app.updater.stop, lambda _: events.append("updater.stop")), + ) + monkeypatch.setattr( + app, + "stop", + call_after(app.stop, lambda _: events.append("app.stop")), + ) + monkeypatch.setattr( + app, + "shutdown", + call_after(app.shutdown, lambda _: events.append("app.shutdown")), + ) + monkeypatch.setattr(app.bot, "set_webhook", return_true) + monkeypatch.setattr(app.bot, "delete_webhook", return_true) + + def thread_target(): + waited = 0 + while not app.running: + time.sleep(0.05) + waited += 0.05 + if waited > 5: + pytest.fail("App apparently won't start") + + time.sleep(0.1) + assertions["called_stop_running_not_set"] = not called_stop_running.is_set() + + put_update_event.set() + time.sleep(0.1) + + assertions["called_stop_running_set"] = called_stop_running.is_set() + + # App should have entered `stop` now but not finished it yet because the callback + # is still running + assertions["updater.stop_event"] = events == ["updater.stop"] + assertions["app.running_False"] = not app.running + + callback_done_event.set() + time.sleep(0.1) + + # Now that the update is fully handled, we expect the full shutdown + assertions["events"] = events == ["updater.stop", "app.stop", "app.shutdown"] + + async def callback(update, context): + context.application.stop_running() + called_stop_running.set() + await asyncio.get_running_loop().run_in_executor(None, callback_done_event.wait) + + app.add_handler(TypeHandler(object, callback)) + + thread = Thread(target=thread_target) + thread.start() + + if method == "polling": + app.run_polling(close_loop=False, drop_pending_updates=True) + else: + ip = "127.0.0.1" + port = randrange(1024, 49152) + + app.run_webhook( + ip_address=ip, + port=port, + url_path="TOKEN", + drop_pending_updates=False, + close_loop=False, + ) + + thread.join(timeout=10) + + assert len(assertions) == 5 + for key, value in assertions.items(): + assert value, f"assertion '{key}' failed!" + + async def test_process_update_exception_in_building_context(self, monkeypatch, caplog, app): + # Makes sure that exceptions in building the context don't stop the application + exception = ValueError("TestException") + original_from_update = CallbackContext.from_update + + def raise_exception(update, application): + if update == 1: + raise exception + return original_from_update(update, application) + + monkeypatch.setattr(CallbackContext, "from_update", raise_exception) + + received_updates = set() + + async def callback(update, context): + received_updates.add(update) + + app.add_handler(TypeHandler(int, callback)) + + async with app: + with caplog.at_level(logging.CRITICAL): + await app.process_update(1) + + assert received_updates == set() + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.name == "telegram.ext.Application" + assert record.getMessage().startswith( + "Error while building CallbackContext for update 1" + ) + assert record.levelno == logging.CRITICAL + + # Let's also check that no critical log is produced when the exception is not raised + caplog.clear() + with caplog.at_level(logging.CRITICAL): + await app.process_update(2) + + assert received_updates == {2} + assert len(caplog.records) == 0 + + @pytest.mark.parametrize("change_type", ["remove", "add"]) + async def test_process_update_handler_change_groups_during_iteration(self, app, change_type): + run_groups = set() + + async def dummy_callback(_, __, g: int): + run_groups.add(g) + + for group in range(10, 20): + handler = TypeHandler(int, functools.partial(dummy_callback, g=group)) + app.add_handler(handler, group=group) + + async def wait_callback(_, context): + # Trigger a change of the app.handlers dict during the iteration + if change_type == "remove": + context.application.remove_handler(handler, group) + else: + context.application.add_handler( + TypeHandler(int, functools.partial(dummy_callback, g=42)), group=42 + ) + + app.add_handler(TypeHandler(int, wait_callback)) + + async with app: + await app.process_update(1) + + # check that exactly those handlers were called that were configured when + # process_update was called + assert run_groups == set(range(10, 20)) + + async def test_process_update_handler_change_group_during_iteration(self, app): + async def dummy_callback(_, __): + pass + + checked_handlers = set() + + class TrackHandler(TypeHandler): + def __init__(self, name: str, *args, **kwargs): + self.name = name + super().__init__(*args, **kwargs) + + def check_update(self, update: object) -> bool: + checked_handlers.add(self.name) + return super().check_update(update) + + remove_handler = TrackHandler("remove", int, dummy_callback) + add_handler = TrackHandler("add", int, dummy_callback) + + class TriggerHandler(TypeHandler): + def check_update(self, update: object) -> bool: + # Trigger a change of the app.handlers *in the same group* during the iteration + app.remove_handler(remove_handler) + app.add_handler(add_handler) + # return False to ensure that additional handlers in the same group are checked + return False + + app.add_handler(TriggerHandler(str, dummy_callback)) + app.add_handler(remove_handler) + async with app: + await app.process_update("string update") + + # check that exactly those handlers were checked that were configured when + # process_update was called + assert checked_handlers == {"remove"} + + async def test_process_error_exception_in_building_context(self, monkeypatch, caplog, app): + # Makes sure that exceptions in building the context don't stop the application + exception = ValueError("TestException") + original_from_error = CallbackContext.from_error + + def raise_exception(update, error, application, *args, **kwargs): + if error == 1: + raise exception + return original_from_error(update, error, application, *args, **kwargs) + + monkeypatch.setattr(CallbackContext, "from_error", raise_exception) + + received_errors = set() + + async def callback(update, context): + received_errors.add(context.error) + + app.add_error_handler(callback) + + async with app: + with caplog.at_level(logging.CRITICAL): + await app.process_error(update=None, error=1) + + assert received_errors == set() + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.name == "telegram.ext.Application" + assert record.getMessage().startswith( + "Error while building CallbackContext for exception 1" + ) + assert record.levelno == logging.CRITICAL + + # Let's also check that no critical log is produced when the exception is not raised + caplog.clear() + with caplog.at_level(logging.CRITICAL): + await app.process_error(update=None, error=2) + + assert received_errors == {2} + assert len(caplog.records) == 0 + + @pytest.mark.parametrize("change_type", ["remove", "add"]) + async def test_process_error_change_during_iteration(self, app, change_type): + called_handlers = set() + + async def dummy_process_error(name: str, *_, **__): + called_handlers.add(name) + + add_error_handler = functools.partial(dummy_process_error, "add_handler") + remove_error_handler = functools.partial(dummy_process_error, "remove_handler") + + async def trigger_change(*_, **__): + if change_type == "remove": + app.remove_error_handler(remove_error_handler) + else: + app.add_error_handler(add_error_handler) + + app.add_error_handler(trigger_change) + app.add_error_handler(remove_error_handler) + async with app: + await app.process_error(update=None, error=None) + + # check that exactly those handlers were checked that were configured when + # add_error_handler was called + assert called_handlers == {"remove_handler"} + + @pytest.mark.skipif( + sys.version_info < (3, 14), + reason="Only relevant for Python 3.14+ where get_event_loop() raises RuntimeError", + ) + def test_run_polling_no_event_loop_python314(self, offline_bot, monkeypatch): + """Test that run_polling works when no event loop exists (Python 3.14+ scenario). + + This simulates the Python 3.14+ behavior where get_event_loop() raises RuntimeError + when there's no current event loop in the main thread. The fix should create a new + event loop in this case. + """ + # Track if our test ran and whether any exceptions occurred + exception_captured = None + + def thread_target(): + nonlocal exception_captured + try: + # Intentionally DON'T set an event loop to simulate Python 3.14 scenario + # Note: the existing test_run_polling_webhook_bootstrap_retries DOES set one + + app = ( + ApplicationBuilder() + .bot(offline_bot) + .application_class(PytestApplication) + .build() + ) + + # Mock the necessary methods to avoid network calls + monkeypatch.setattr(app.bot, "get_updates", empty_get_updates) + monkeypatch.setattr(app.bot, "delete_webhook", return_true) + + # Mock initialize to exit quickly after testing event loop creation + original_initialize = app.initialize + + async def quick_initialize(*args, **kwargs): + await original_initialize(*args, **kwargs) + # Exit quickly by raising an exception after successful initialization + raise TelegramError("Test completed successfully") + + monkeypatch.setattr(app, "initialize", quick_initialize) + + # This should work - the key is that it creates an event loop and doesn't + # raise RuntimeError about no current event loop (Python 3.14+ issue) + with pytest.raises(TelegramError, match="Test completed successfully"): + app.run_polling( + bootstrap_retries=0, + close_loop=True, + stop_signals=None, # Can't use signals in threads + drop_pending_updates=True, + ) + # If we get here, the event loop was created successfully + except Exception as e: + exception_captured = e + + thread = Thread(target=thread_target) + thread.start() + thread.join(timeout=10) + + assert not thread.is_alive(), "Test took too long to run" + + # If there was an unexpected exception, fail the test + if exception_captured: + raise exception_captured diff --git a/tests/ext/test_applicationbuilder.py b/tests/ext/test_applicationbuilder.py index 0f9eb29ad7f..59fbbd4fb4f 100644 --- a/tests/ext/test_applicationbuilder.py +++ b/tests/ext/test_applicationbuilder.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,11 +17,16 @@ # 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 asyncio +import datetime as dtm +import inspect from dataclasses import dataclass +from http import HTTPStatus import httpx import pytest +from telegram import Bot +from telegram._utils.defaultvalue import DEFAULT_NONE from telegram.ext import ( AIORateLimiter, Application, @@ -43,7 +48,7 @@ from tests.auxil.slots import mro_slots -@pytest.fixture() +@pytest.fixture def builder(): return ApplicationBuilder() @@ -64,6 +69,37 @@ def test_slot_behaviour(self, builder): assert getattr(builder, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(builder)) == len(set(mro_slots(builder))), "duplicate slot" + @pytest.mark.parametrize("get_updates", [True, False]) + def test_all_methods_request(self, builder, get_updates): + arguments = inspect.signature(HTTPXRequest.__init__).parameters.keys() + prefix = "get_updates_" if get_updates else "" + for argument in arguments: + if argument in ("self", "httpx_kwargs"): + continue + if argument == "media_write_timeout" and get_updates: + # get_updates never makes media requests + continue + assert hasattr(builder, prefix + argument), f"missing method {prefix}{argument}" + + @pytest.mark.parametrize("bot_class", [Bot, ExtBot]) + def test_all_methods_bot(self, builder, bot_class): + arguments = inspect.signature(bot_class.__init__).parameters.keys() + for argument in arguments: + if argument == "self": + continue + if argument == "private_key_password": + argument = "private_key" # noqa: PLW2901 + assert hasattr(builder, argument), f"missing method {argument}" + + def test_all_methods_application(self, builder): + arguments = inspect.signature(Application.__init__).parameters.keys() + for argument in arguments: + if argument == "self": + continue + if argument == "update_processor": + argument = "concurrent_updates" # noqa: PLW2901 + assert hasattr(builder, argument), f"missing method {argument}" + def test_job_queue_init_exception(self, monkeypatch): def init_raises_runtime_error(*args, **kwargs): raise RuntimeError("RuntimeError") @@ -74,7 +110,7 @@ def init_raises_runtime_error(*args, **kwargs): ApplicationBuilder() def test_build_without_token(self, builder): - with pytest.raises(RuntimeError, match="No bot token was set."): + with pytest.raises(RuntimeError, match="No bot token was set\\."): builder.build() def test_build_custom_bot(self, builder, bot): @@ -87,10 +123,11 @@ def test_default_values(self, bot, monkeypatch, builder): @dataclass class Client: timeout: object - proxies: object + proxy: object limits: object http1: object http2: object + transport: object = None monkeypatch.setattr(httpx, "AsyncClient", Client) @@ -113,10 +150,8 @@ class Client: assert app.bot.local_mode is False get_updates_client = app.bot._request[0]._client - assert get_updates_client.limits == httpx.Limits( - max_connections=1, max_keepalive_connections=1 - ) - assert get_updates_client.proxies is None + assert get_updates_client.limits == httpx.Limits(max_connections=1) + assert get_updates_client.proxy is None assert get_updates_client.timeout == httpx.Timeout( connect=5.0, read=5.0, write=5.0, pool=1.0 ) @@ -124,8 +159,8 @@ class Client: assert not get_updates_client.http2 client = app.bot.request._client - assert client.limits == httpx.Limits(max_connections=256, max_keepalive_connections=256) - assert client.proxies is None + assert client.limits == httpx.Limits(max_connections=256) + assert client.proxy is None assert client.timeout == httpx.Timeout(connect=5.0, read=5.0, write=5.0, pool=1.0) assert client.http1 is True assert not client.http2 @@ -168,7 +203,9 @@ def test_mutually_exclusive_for_bot(self, builder, method, description): "pool_timeout", "read_timeout", "write_timeout", - "proxy_url", + "media_write_timeout", + "proxy", + "socket_options", "bot", "updater", "http_version", @@ -195,7 +232,8 @@ def test_mutually_exclusive_for_request(self, builder, method): "get_updates_pool_timeout", "get_updates_read_timeout", "get_updates_write_timeout", - "get_updates_proxy_url", + "get_updates_proxy", + "get_updates_socket_options", "get_updates_http_version", "bot", "updater", @@ -223,14 +261,17 @@ def test_mutually_exclusive_for_get_updates_request(self, builder, method): "get_updates_pool_timeout", "get_updates_read_timeout", "get_updates_write_timeout", - "get_updates_proxy_url", + "get_updates_proxy", + "get_updates_socket_options", "get_updates_http_version", "connection_pool_size", "connect_timeout", "pool_timeout", "read_timeout", "write_timeout", - "proxy_url", + "media_write_timeout", + "proxy", + "socket_options", "http_version", "bot", "update_queue", @@ -249,6 +290,7 @@ def test_mutually_exclusive_for_updater(self, builder, method): builder = ApplicationBuilder() getattr(builder, method)(data_file("private.key")) + with pytest.raises(RuntimeError, match=f"`updater` may only be set, if no {method}"): builder.updater(1) @@ -260,14 +302,17 @@ def test_mutually_exclusive_for_updater(self, builder, method): "get_updates_pool_timeout", "get_updates_read_timeout", "get_updates_write_timeout", - "get_updates_proxy_url", + "get_updates_proxy", + "get_updates_socket_options", "get_updates_http_version", "connection_pool_size", "connect_timeout", "pool_timeout", "read_timeout", "write_timeout", - "proxy_url", + "media_write_timeout", + "proxy", + "socket_options", "bot", "http_version", ] @@ -284,7 +329,13 @@ def test_mutually_non_exclusive_for_updater(self, builder, method): getattr(builder, method)(data_file("private.key")) builder.updater(None) - def test_all_bot_args_custom(self, builder, bot, monkeypatch): + def test_all_bot_args_custom( + self, + builder, + bot, + monkeypatch, + ): + # Only socket_options is tested in a standalone test, since that's easier defaults = Defaults() request = HTTPXRequest() get_updates_request = HTTPXRequest() @@ -293,11 +344,7 @@ def test_all_bot_args_custom(self, builder, bot, monkeypatch): PRIVATE_KEY ).defaults(defaults).arbitrary_callback_data(42).request(request).get_updates_request( get_updates_request - ).rate_limiter( - rate_limiter - ).local_mode( - True - ) + ).rate_limiter(rate_limiter).local_mode(True) built_bot = builder.build().bot # In the following we access some private attributes of bot and request. this is not @@ -318,44 +365,81 @@ def test_all_bot_args_custom(self, builder, bot, monkeypatch): @dataclass class Client: timeout: object - proxies: object + proxy: object limits: object http1: object http2: object + transport: object = None + + original_init = HTTPXRequest.__init__ + media_write_timeout = [] + + def init_httpx_request(self_, *args, **kwargs): + media_write_timeout.append(kwargs.get("media_write_timeout")) + original_init(self_, *args, **kwargs) monkeypatch.setattr(httpx, "AsyncClient", Client) + monkeypatch.setattr(HTTPXRequest, "__init__", init_httpx_request) builder = ApplicationBuilder().token(bot.token) builder.connection_pool_size(1).connect_timeout(2).pool_timeout(3).read_timeout( 4 - ).write_timeout(5).proxy_url("proxy_url").http_version("1.1") + ).write_timeout(5).media_write_timeout(6).http_version("1.1").proxy("proxy") app = builder.build() client = app.bot.request._client assert client.timeout == httpx.Timeout(pool=3, connect=2, read=4, write=5) - assert client.limits == httpx.Limits(max_connections=1, max_keepalive_connections=1) - assert client.proxies == "proxy_url" + assert client.limits == httpx.Limits(max_connections=1) + assert client.proxy == "proxy" assert client.http1 is True assert client.http2 is False + assert media_write_timeout == [6, None] + media_write_timeout.clear() builder = ApplicationBuilder().token(bot.token) builder.get_updates_connection_pool_size(1).get_updates_connect_timeout( 2 ).get_updates_pool_timeout(3).get_updates_read_timeout(4).get_updates_write_timeout( 5 - ).get_updates_proxy_url( - "proxy_url" - ).get_updates_http_version( - "1.1" - ) + ).get_updates_http_version("1.1").get_updates_proxy("get_updates_proxy") app = builder.build() client = app.bot._request[0]._client assert client.timeout == httpx.Timeout(pool=3, connect=2, read=4, write=5) - assert client.limits == httpx.Limits(max_connections=1, max_keepalive_connections=1) - assert client.proxies == "proxy_url" + assert client.limits == httpx.Limits(max_connections=1) + assert client.proxy == "get_updates_proxy" assert client.http1 is True assert client.http2 is False + assert media_write_timeout == [None, None] + + def test_custom_socket_options(self, builder, monkeypatch, bot): + httpx_request_kwargs = [] + httpx_request_init = HTTPXRequest.__init__ + + def init_transport(*args, **kwargs): + # This is called once for request and once for get_updates_request, so we make + # it a list + httpx_request_kwargs.append(kwargs.copy()) + httpx_request_init(*args, **kwargs) + + monkeypatch.setattr(HTTPXRequest, "__init__", init_transport) + + builder.token(bot.token).build() + assert httpx_request_kwargs[0].get("socket_options") is None + assert httpx_request_kwargs[1].get("socket_options") is None + + httpx_request_kwargs = [] + ApplicationBuilder().token(bot.token).socket_options(((1, 2, 3),)).connection_pool_size( + "request" + ).get_updates_socket_options(((4, 5, 6),)).get_updates_connection_pool_size( + "get_updates" + ).build() + + for kwargs in httpx_request_kwargs: + if kwargs.get("connection_pool_size") == "request": + assert kwargs.get("socket_options") == ((1, 2, 3),) + else: + assert kwargs.get("socket_options") == ((4, 5, 6),) def test_custom_application_class(self, bot, builder): class CustomApplication(Application): @@ -476,3 +560,35 @@ def test_no_job_queue(self, bot, builder): assert app.job_queue is None assert isinstance(app.update_queue, asyncio.Queue) assert isinstance(app.updater, Updater) + + @pytest.mark.parametrize( + ("read_timeout", "timeout", "expected"), + [ + (None, None, 0), + (1, None, 1), + (None, 1, 1), + (None, dtm.timedelta(seconds=1), 1), + (DEFAULT_NONE, None, 10), + (DEFAULT_NONE, 1, 11), + (DEFAULT_NONE, dtm.timedelta(seconds=1), 11), + (1, 2, 3), + (1, dtm.timedelta(seconds=2), 3), + ], + ) + async def test_get_updates_read_timeout_value_passing( + self, bot, read_timeout, timeout, expected, monkeypatch, builder + ): + # This test is a double check that ApplicationBuilder respects the changes of #3963 just + # like `Bot` does - see also the corresponding test in test_bot.py (same name) + caught_read_timeout = None + + async def catch_timeouts(*args, **kwargs): + nonlocal caught_read_timeout + caught_read_timeout = kwargs.get("read_timeout") + return HTTPStatus.OK, b'{"ok": "True", "result": {}}' + + monkeypatch.setattr(HTTPXRequest, "do_request", catch_timeouts) + + bot = builder.get_updates_read_timeout(10).token(bot.token).build().bot + await bot.get_updates(read_timeout=read_timeout, timeout=timeout) + assert caught_read_timeout == expected diff --git a/tests/ext/test_handler.py b/tests/ext/test_basehandler.py similarity index 55% rename from tests/ext/test_handler.py rename to tests/ext/test_basehandler.py index 6730d916da1..6279a946190 100644 --- a/tests/ext/test_handler.py +++ b/tests/ext/test_basehandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -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/]. -from telegram.ext._handler import BaseHandler +from telegram.ext import BaseHandler from tests.auxil.slots import mro_slots @@ -36,3 +36,39 @@ def check_update(self, update: object): 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_repr(self): + async def some_func(): + return None + + class SubclassHandler(BaseHandler): + __slots__ = () + + def __init__(self): + super().__init__(callback=some_func) + + def check_update(self, update: object): + pass + + sh = SubclassHandler() + assert repr(sh) == "SubclassHandler[callback=TestHandler.test_repr..some_func]" + + def test_repr_no_qualname(self): + class ClassBasedCallback: + async def __call__(self, *args, **kwargs): + pass + + def __repr__(self): + return "Repr of ClassBasedCallback" + + class SubclassHandler(BaseHandler): + __slots__ = () + + def __init__(self): + super().__init__(callback=ClassBasedCallback()) + + def check_update(self, update: object): + pass + + sh = SubclassHandler() + assert repr(sh) == "SubclassHandler[callback=Repr of ClassBasedCallback]" diff --git a/tests/ext/test_basepersistence.py b/tests/ext/test_basepersistence.py index 8c70903ef86..39891c777e5 100644 --- a/tests/ext/test_basepersistence.py +++ b/tests/ext/test_basepersistence.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -21,10 +21,13 @@ import copy import enum import functools +import itertools import logging +import sys import time +from http import HTTPStatus from pathlib import Path -from typing import NamedTuple, Optional +from typing import NamedTuple import pytest @@ -42,8 +45,9 @@ PersistenceInput, filters, ) +from telegram.request import HTTPXRequest from telegram.warnings import PTBUserWarning -from tests.auxil.build_messages import make_message_update +from tests.auxil.build_messages import make_message, make_message_update from tests.auxil.pytest_classes import PytestApplication, make_bot from tests.auxil.slots import mro_slots @@ -70,7 +74,7 @@ class TrackingPersistence(BasePersistence): def __init__( self, - store_data: Optional[PersistenceInput] = None, + store_data: PersistenceInput | None = None, update_interval: float = 60, fill_data: bool = False, ): @@ -219,20 +223,20 @@ def build_handler(cls, state: HandlerStates, callback=None): class PappInput(NamedTuple): - bot_data: Optional[bool] = None - chat_data: Optional[bool] = None - user_data: Optional[bool] = None - callback_data: Optional[bool] = None + bot_data: bool | None = None + chat_data: bool | None = None + user_data: bool | None = None + callback_data: bool | None = None conversations: bool = True update_interval: float = None fill_data: bool = False def build_papp( - bot_info: Optional[dict] = None, - token: Optional[str] = None, - store_data: Optional[dict] = None, - update_interval: Optional[float] = None, + bot_info: dict | None = None, + token: str | None = None, + store_data: dict | None = None, + update_interval: float | None = None, fill_data: bool = False, ) -> Application: store_data = PersistenceInput(**(store_data or {})) @@ -244,9 +248,9 @@ def build_papp( persistence = TrackingPersistence(store_data=store_data, fill_data=fill_data) if bot_info is not None: - bot = make_bot(bot_info, arbitrary_callback_data=True) + bot = make_bot(bot_info, arbitrary_callback_data=True, offline=False) else: - bot = make_bot(token=token, arbitrary_callback_data=True) + bot = make_bot(token=token, arbitrary_callback_data=True, offline=False) return ( ApplicationBuilder() .bot(bot) @@ -260,8 +264,8 @@ def build_conversation_handler(name: str, persistent: bool = True) -> BaseHandle return TrackingConversationHandler(name=name, persistent=persistent) -@pytest.fixture() -def papp(request, bot_info) -> Application: +@pytest.fixture +def papp(request, bot_info, monkeypatch) -> Application: papp_input = request.param store_data = {} if papp_input.bot_data is not None: @@ -273,6 +277,11 @@ def papp(request, bot_info) -> Application: if papp_input.callback_data is not None: store_data["callback_data"] = papp_input.callback_data + async def do_request(*args, **kwargs): + return HTTPStatus.OK, make_message(text="text") + + monkeypatch.setattr(HTTPXRequest, "do_request", do_request) + app = build_papp( bot_info=bot_info, store_data=store_data, @@ -311,7 +320,7 @@ class TestBasePersistence: """Tests basic behavior of BasePersistence and (most importantly) the integration of persistence into the Application.""" - def job_callback(self, chat_id: Optional[int] = None): + def job_callback(self, chat_id: int | None = None): async def callback(context): if context.user_data: context.user_data["key"] = "value" @@ -330,7 +339,7 @@ async def callback(context): return callback - def handler_callback(self, chat_id: Optional[int] = None, sleep: Optional[float] = None): + def handler_callback(self, chat_id: int | None = None, sleep: float | None = None): async def callback(update, context): if sleep: await asyncio.sleep(sleep) @@ -377,14 +386,14 @@ def test_init_store_data_update_interval(self, bot_data, chat_data, user_data, c assert persistence.store_data.callback_data == callback_data def test_abstract_methods(self): + methods = list(BasePersistence.__abstractmethods__) + methods.sort() with pytest.raises( TypeError, match=( - "drop_chat_data, drop_user_data, flush, get_bot_data, get_callback_data, " - "get_chat_data, get_conversations, " - "get_user_data, refresh_bot_data, refresh_chat_data, " - "refresh_user_data, update_bot_data, update_callback_data, " - "update_chat_data, update_conversation, update_user_data" + ", ".join(methods) + if sys.version_info < (3, 12) + else ", ".join(f"'{i}'" for i in methods) ), ): BasePersistence() @@ -396,7 +405,7 @@ def test_update_interval_immutable(self, papp): @default_papp def test_set_bot_error(self, papp): - with pytest.raises(TypeError, match="when using telegram.ext.ExtBot"): + with pytest.raises(TypeError, match="when using telegram\\.ext\\.ExtBot"): papp.persistence.set_bot(Bot(papp.bot.token)) # just making sure that setting an ExtBoxt without callback_data_cache doesn't raise an @@ -411,7 +420,7 @@ def __init__(self): self.store_data = PersistenceInput(False, False, False, False) with pytest.raises( - TypeError, match="persistence must be based on telegram.ext.BasePersistence" + TypeError, match="persistence must be based on telegram\\.ext\\.BasePersistence" ): ApplicationBuilder().bot(bot).persistence(MyPersistence()).build() @@ -552,6 +561,8 @@ async def test_add_conversation_handler_after_init(self, papp: Application, recw papp.add_handler(conversation) assert len(recwarn) >= 1 + tasks = asyncio.all_tasks() + assert any("conversation_handler_after_init" in t.get_name() for t in tasks) found = False for warning in recwarn: if "after `Application.initialize` was called" in str(warning.message): @@ -584,6 +595,28 @@ async def test_add_conversation_handler_without_name(self, papp: Application): with pytest.raises(ValueError, match="when handler is unnamed"): papp.add_handler(build_conversation_handler(name=None, persistent=True)) + @pytest.mark.parametrize( + "papp", + [ + PappInput(update_interval=0.0), + ], + indirect=True, + ) + async def test_update_persistence_called(self, papp: Application, monkeypatch): + """Tests if Application.update_persistence is called from app.start()""" + called = asyncio.Event() + + async def update_persistence(*args, **kwargs): + called.set() + + monkeypatch.setattr(papp, "update_persistence", update_persistence) + async with papp: + await papp.start() + tasks = asyncio.all_tasks() + assert any(":persistence_updater" in task.get_name() for task in tasks) + assert await called.wait() + await papp.stop() + @pytest.mark.flaky(3, 1) @pytest.mark.parametrize( "papp", @@ -607,7 +640,7 @@ async def update_persistence(*args, **kwargs): await papp.stop() # Make assertions before calling shutdown, as that calls update_persistence again! - diffs = [j - i for i, j in zip(call_times[:-1], call_times[1:])] + diffs = [j - i for i, j in itertools.pairwise(call_times)] assert sum(diffs) / len(diffs) == pytest.approx( papp.persistence.update_interval, rel=1e-1 ) @@ -1233,7 +1266,7 @@ async def callback(_, __): await papp.update_persistence() await asyncio.sleep(0.01) # Conversation should have been updated with the current state, i.e. None - assert papp.persistence.updated_conversations == {"conv": ({(1, 1): 1})} + assert papp.persistence.updated_conversations == {"conv": {(1, 1): 1}} assert papp.persistence.conversations == {"conv": {(1, 1): None}} # Ensure that we warn the user about this! @@ -1303,7 +1336,7 @@ async def callback_2(_, __): await papp.update_persistence() await asyncio.sleep(0.05) - assert papp.persistence.updated_conversations == {"conv": ({(1, 1): 1})} + assert papp.persistence.updated_conversations == {"conv": {(1, 1): 1}} # The result of the pending state wasn't retrieved by the CH yet, so we must be in # state `None` assert papp.persistence.conversations == {"conv": {(1, 1): None}} @@ -1413,10 +1446,63 @@ async def test_conversation_ends(self, papp): assert papp.persistence.updated_conversations == {} await papp.update_persistence() - assert papp.persistence.updated_conversations == {"conv_1": ({(1, 1): 1})} + assert papp.persistence.updated_conversations == {"conv_1": {(1, 1): 1}} # This is the important part: the persistence is updated with `None` when the conv ends assert papp.persistence.conversations == {"conv_1": {(1, 1): None}} + async def test_non_blocking_conversation_ends(self, bot): + papp = build_papp(token=bot.token, update_interval=100) + event = asyncio.Event() + + async def callback(_, __): + await event.wait() + return HandlerStates.END + + conversation = ConversationHandler( + entry_points=[ + TrackingConversationHandler.build_handler(HandlerStates.END, callback=callback) + ], + states={}, + fallbacks=[], + persistent=True, + name="conv", + block=False, + ) + papp.add_handler(conversation) + + async with papp: + await papp.start() + assert papp.persistence.updated_conversations == {} + + await papp.process_update( + TrackingConversationHandler.build_update(HandlerStates.END, 1) + ) + assert papp.persistence.updated_conversations == {} + + papp.persistence.reset_tracking() + event.set() + await asyncio.sleep(0.01) + await papp.update_persistence() + + # On shutdown, persisted data should include the END state b/c that's what the + # pending state is being resolved to + assert papp.persistence.updated_conversations == {"conv": {(1, 1): 1}} + assert papp.persistence.conversations == {"conv": {(1, 1): HandlerStates.END}} + + await papp.stop() + + async with papp: + # On the next restart/persistence loading the ConversationHandler should resolve + # the stored END state to None … + assert papp.persistence.conversations == {"conv": {(1, 1): HandlerStates.END}} + # … and the update should be accepted by the entry point again + assert conversation.check_update( + TrackingConversationHandler.build_update(HandlerStates.END, 1) + ) + + await papp.update_persistence() + assert papp.persistence.conversations == {"conv": {(1, 1): None}} + async def test_conversation_timeout(self, bot): # high update_interval so that we can instead manually call it papp = build_papp(token=bot.token, update_interval=150) @@ -1445,7 +1531,7 @@ async def callback(_, __): ) assert papp.persistence.updated_conversations == {} await papp.update_persistence() - assert papp.persistence.updated_conversations == {"conv": ({(1, 1): 1})} + assert papp.persistence.updated_conversations == {"conv": {(1, 1): 1}} assert papp.persistence.conversations == {"conv": {(1, 1): HandlerStates.STATE_1}} papp.persistence.reset_tracking() diff --git a/tests/ext/test_baseupdateprocessor.py b/tests/ext/test_baseupdateprocessor.py index 3ae10d2dd16..c8f06fe24a8 100644 --- a/tests/ext/test_baseupdateprocessor.py +++ b/tests/ext/test_baseupdateprocessor.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,6 +18,7 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """Here we run tests directly with SimpleUpdateProcessor because that's easier than providing dummy implementations for SimpleUpdateProcessor and we want to test SimpleUpdateProcessor anyway.""" + import asyncio import pytest @@ -28,7 +29,7 @@ from tests.auxil.slots import mro_slots -@pytest.fixture() +@pytest.fixture def mock_processor(): class MockProcessor(SimpleUpdateProcessor): test_flag = False @@ -164,3 +165,33 @@ async def shutdown(*args, **kwargs): pass assert self.test_flag == "shutdown" + + async def test_current_concurrent_updates(self, mock_processor): + async def callback(event: asyncio.Event): + await event.wait() + + events = {i: asyncio.Event() for i in range(10)} + coroutines = {i: callback(event) for i, event in events.items()} + + process_tasks = [ + asyncio.create_task(mock_processor.process_update(Update(i), coroutines[i])) + for i in range(10) + ] + await asyncio.sleep(0.01) + + assert mock_processor.current_concurrent_updates == mock_processor.max_concurrent_updates + for i in range(5): + events[i].set() + + await asyncio.sleep(0.01) + assert mock_processor.current_concurrent_updates == mock_processor.max_concurrent_updates + + for i in range(5, 10): + events[i].set() + await asyncio.sleep(0.01) + assert ( + mock_processor.current_concurrent_updates + == mock_processor.max_concurrent_updates - (i - 4) + ) + + await asyncio.gather(*process_tasks) diff --git a/tests/ext/test_businessconnectionhandler.py b/tests/ext/test_businessconnectionhandler.py new file mode 100644 index 00000000000..07ac3417c34 --- /dev/null +++ b/tests/ext/test_businessconnectionhandler.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 asyncio +import datetime as dtm + +import pytest + +from telegram import ( + Bot, + BusinessBotRights, + BusinessConnection, + CallbackQuery, + Chat, + ChosenInlineResult, + Message, + PreCheckoutQuery, + ShippingQuery, + Update, + User, +) +from telegram._utils.datetime import UTC +from telegram.ext import BusinessConnectionHandler, CallbackContext, JobQueue +from tests.auxil.slots import mro_slots + +message = Message(1, None, Chat(1, ""), from_user=User(1, "", False), text="Text") + +params = [ + {"message": message}, + {"edited_message": message}, + {"callback_query": CallbackQuery(1, User(1, "", False), "chat", message=message)}, + {"channel_post": message}, + {"edited_channel_post": message}, + {"chosen_inline_result": ChosenInlineResult("id", User(1, "", False), "")}, + {"shipping_query": ShippingQuery("id", User(1, "", False), "", None)}, + {"pre_checkout_query": PreCheckoutQuery("id", User(1, "", False), "", 0, "")}, + {"callback_query": CallbackQuery(1, User(1, "", False), "chat")}, +] + +ids = ( + "message", + "edited_message", + "callback_query", + "channel_post", + "edited_channel_post", + "chosen_inline_result", + "shipping_query", + "pre_checkout_query", + "callback_query_without_message", +) + + +@pytest.fixture(scope="class", params=params, ids=ids) +def false_update(request): + return Update(update_id=2, **request.param) + + +@pytest.fixture(scope="class") +def time(): + return dtm.datetime.now(tz=UTC) + + +@pytest.fixture(scope="class") +def business_connection(bot): + bc = BusinessConnection( + id="1", + user_chat_id=1, + user=User(1, "name", username="user_a", is_bot=False), + date=dtm.datetime.now(tz=UTC), + is_enabled=True, + rights=BusinessBotRights(can_reply=True), + ) + bc.set_bot(bot) + return bc + + +@pytest.fixture +def business_connection_update(bot, business_connection): + return Update(0, business_connection=business_connection) + + +class TestBusinessConnectionHandler: + test_flag = False + + def test_slot_behaviour(self): + action = BusinessConnectionHandler(self.callback) + for attr in action.__slots__: + assert getattr(action, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" + + @pytest.fixture(autouse=True) + def _reset(self): + self.test_flag = False + + async def callback(self, update, context): + self.test_flag = ( + isinstance(context, CallbackContext) + and isinstance(context.bot, Bot) + and isinstance(update, Update) + and isinstance(context.update_queue, asyncio.Queue) + and isinstance(context.job_queue, JobQueue) + and isinstance(context.user_data, dict) + and isinstance(context.bot_data, dict) + and isinstance( + update.business_connection, + BusinessConnection, + ) + ) + + def test_with_user_id(self, business_connection_update): + handler = BusinessConnectionHandler(self.callback, user_id=1) + assert handler.check_update(business_connection_update) + handler = BusinessConnectionHandler(self.callback, user_id=[1]) + assert handler.check_update(business_connection_update) + handler = BusinessConnectionHandler(self.callback, user_id=2, username="@user_a") + assert handler.check_update(business_connection_update) + + handler = BusinessConnectionHandler(self.callback, user_id=2) + assert not handler.check_update(business_connection_update) + handler = BusinessConnectionHandler(self.callback, user_id=[2]) + assert not handler.check_update(business_connection_update) + + def test_with_username(self, business_connection_update): + handler = BusinessConnectionHandler(self.callback, username="user_a") + assert handler.check_update(business_connection_update) + handler = BusinessConnectionHandler(self.callback, username="@user_a") + assert handler.check_update(business_connection_update) + handler = BusinessConnectionHandler(self.callback, username=["user_a"]) + assert handler.check_update(business_connection_update) + handler = BusinessConnectionHandler(self.callback, username=["@user_a"]) + assert handler.check_update(business_connection_update) + handler = BusinessConnectionHandler(self.callback, user_id=1, username="@user_b") + assert handler.check_update(business_connection_update) + + handler = BusinessConnectionHandler(self.callback, username="user_b") + assert not handler.check_update(business_connection_update) + handler = BusinessConnectionHandler(self.callback, username="@user_b") + assert not handler.check_update(business_connection_update) + handler = BusinessConnectionHandler(self.callback, username=["user_b"]) + assert not handler.check_update(business_connection_update) + handler = BusinessConnectionHandler(self.callback, username=["@user_b"]) + assert not handler.check_update(business_connection_update) + + business_connection_update.business_connection.user._unfreeze() + business_connection_update.business_connection.user.username = None + assert not handler.check_update(business_connection_update) + + def test_other_update_types(self, false_update): + handler = BusinessConnectionHandler(self.callback) + assert not handler.check_update(false_update) + assert not handler.check_update(True) + + async def test_context(self, app, business_connection_update): + handler = BusinessConnectionHandler(callback=self.callback) + app.add_handler(handler) + + async with app: + await app.process_update(business_connection_update) + assert self.test_flag diff --git a/tests/ext/test_businessmessagesdeletedhandler.py b/tests/ext/test_businessmessagesdeletedhandler.py new file mode 100644 index 00000000000..f002f50c55f --- /dev/null +++ b/tests/ext/test_businessmessagesdeletedhandler.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 asyncio +import datetime as dtm + +import pytest + +from telegram import ( + Bot, + BusinessMessagesDeleted, + CallbackQuery, + Chat, + ChosenInlineResult, + Message, + PreCheckoutQuery, + ShippingQuery, + Update, + User, +) +from telegram._utils.datetime import UTC +from telegram.ext import BusinessMessagesDeletedHandler, CallbackContext, JobQueue +from tests.auxil.slots import mro_slots + +message = Message(1, None, Chat(1, ""), from_user=User(1, "", False), text="Text") + +params = [ + {"message": message}, + {"edited_message": message}, + {"callback_query": CallbackQuery(1, User(1, "", False), "chat", message=message)}, + {"channel_post": message}, + {"edited_channel_post": message}, + {"chosen_inline_result": ChosenInlineResult("id", User(1, "", False), "")}, + {"shipping_query": ShippingQuery("id", User(1, "", False), "", None)}, + {"pre_checkout_query": PreCheckoutQuery("id", User(1, "", False), "", 0, "")}, + {"callback_query": CallbackQuery(1, User(1, "", False), "chat")}, +] + +ids = ( + "message", + "edited_message", + "callback_query", + "channel_post", + "edited_channel_post", + "chosen_inline_result", + "shipping_query", + "pre_checkout_query", + "callback_query_without_message", +) + + +@pytest.fixture(scope="class", params=params, ids=ids) +def false_update(request): + return Update(update_id=2, **request.param) + + +@pytest.fixture(scope="class") +def time(): + return dtm.datetime.now(tz=UTC) + + +@pytest.fixture(scope="class") +def business_messages_deleted(bot): + bmd = BusinessMessagesDeleted( + business_connection_id="1", + chat=Chat(1, Chat.PRIVATE, username="user_a"), + message_ids=[1, 2, 3], + ) + bmd.set_bot(bot) + return bmd + + +@pytest.fixture +def business_messages_deleted_update(bot, business_messages_deleted): + return Update(0, deleted_business_messages=business_messages_deleted) + + +class TestBusinessMessagesDeletedHandler: + test_flag = False + + def test_slot_behaviour(self): + action = BusinessMessagesDeletedHandler(self.callback) + for attr in action.__slots__: + assert getattr(action, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" + + @pytest.fixture(autouse=True) + def _reset(self): + self.test_flag = False + + async def callback(self, update, context): + self.test_flag = ( + isinstance(context, CallbackContext) + and isinstance(context.bot, Bot) + and isinstance(update, Update) + and isinstance(context.update_queue, asyncio.Queue) + and isinstance(context.job_queue, JobQueue) + and isinstance(context.chat_data, dict) + and isinstance(context.bot_data, dict) + and isinstance( + update.deleted_business_messages, + BusinessMessagesDeleted, + ) + ) + + def test_with_chat_id(self, business_messages_deleted_update): + handler = BusinessMessagesDeletedHandler(self.callback, chat_id=1) + assert handler.check_update(business_messages_deleted_update) + handler = BusinessMessagesDeletedHandler(self.callback, chat_id=[1]) + assert handler.check_update(business_messages_deleted_update) + handler = BusinessMessagesDeletedHandler(self.callback, chat_id=2, username="@user_a") + assert handler.check_update(business_messages_deleted_update) + + handler = BusinessMessagesDeletedHandler(self.callback, chat_id=2) + assert not handler.check_update(business_messages_deleted_update) + handler = BusinessMessagesDeletedHandler(self.callback, chat_id=[2]) + assert not handler.check_update(business_messages_deleted_update) + + def test_with_username(self, business_messages_deleted_update): + handler = BusinessMessagesDeletedHandler(self.callback, username="user_a") + assert handler.check_update(business_messages_deleted_update) + handler = BusinessMessagesDeletedHandler(self.callback, username="@user_a") + assert handler.check_update(business_messages_deleted_update) + handler = BusinessMessagesDeletedHandler(self.callback, username=["user_a"]) + assert handler.check_update(business_messages_deleted_update) + handler = BusinessMessagesDeletedHandler(self.callback, username=["@user_a"]) + assert handler.check_update(business_messages_deleted_update) + handler = BusinessMessagesDeletedHandler(self.callback, chat_id=1, username="@user_b") + assert handler.check_update(business_messages_deleted_update) + + handler = BusinessMessagesDeletedHandler(self.callback, username="user_b") + assert not handler.check_update(business_messages_deleted_update) + handler = BusinessMessagesDeletedHandler(self.callback, username="@user_b") + assert not handler.check_update(business_messages_deleted_update) + handler = BusinessMessagesDeletedHandler(self.callback, username=["user_b"]) + assert not handler.check_update(business_messages_deleted_update) + handler = BusinessMessagesDeletedHandler(self.callback, username=["@user_b"]) + assert not handler.check_update(business_messages_deleted_update) + + business_messages_deleted_update.deleted_business_messages.chat._unfreeze() + business_messages_deleted_update.deleted_business_messages.chat.username = None + assert not handler.check_update(business_messages_deleted_update) + + def test_other_update_types(self, false_update): + handler = BusinessMessagesDeletedHandler(self.callback) + assert not handler.check_update(false_update) + assert not handler.check_update(True) + + async def test_context(self, app, business_messages_deleted_update): + handler = BusinessMessagesDeletedHandler(callback=self.callback) + app.add_handler(handler) + + async with app: + await app.process_update(business_messages_deleted_update) + assert self.test_flag diff --git a/tests/ext/test_callbackcontext.py b/tests/ext/test_callbackcontext.py index 32d518714b9..8ff88792a2b 100644 --- a/tests/ext/test_callbackcontext.py +++ b/tests/ext/test_callbackcontext.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,7 +20,6 @@ import pytest from telegram import ( - Bot, CallbackQuery, Chat, InlineKeyboardButton, @@ -32,6 +31,7 @@ from telegram.error import TelegramError from telegram.ext import ApplicationBuilder, CallbackContext, Job from telegram.warnings import PTBUserWarning +from tests.auxil.pytest_classes import make_bot from tests.auxil.slots import mro_slots """ @@ -63,7 +63,7 @@ def test_from_job(self, app): def test_job_queue(self, bot, app, recwarn): expected_warning = ( "No `JobQueue` set up. To use `JobQueue`, you must install PTB via " - "`pip install python-telegram-bot[job-queue]`." + '`pip install "python-telegram-bot[job-queue]"`.' ) callback_context = CallbackContext(app) @@ -193,26 +193,26 @@ def test_application_attribute(self, app): callback_context = CallbackContext(app) assert callback_context.application is app - def test_drop_callback_data_exception(self, bot, app): - non_ext_bot = Bot(bot.token) + def test_drop_callback_data_exception(self, bot, app, raw_bot): update = Update( 0, message=Message(0, None, Chat(1, "chat"), from_user=User(1, "user", False)) ) callback_context = CallbackContext.from_update(update, app) - with pytest.raises(RuntimeError, match="This telegram.ext.ExtBot instance does not"): + with pytest.raises(RuntimeError, match="This telegram\\.ext\\.ExtBot instance does not"): callback_context.drop_callback_data(None) try: - app.bot = non_ext_bot - with pytest.raises(RuntimeError, match="telegram.Bot does not allow for"): + app.bot = raw_bot + with pytest.raises(RuntimeError, match="telegram\\.Bot does not allow for"): callback_context.drop_callback_data(None) finally: app.bot = bot - async def test_drop_callback_data(self, bot, monkeypatch, chat_id): - app = ApplicationBuilder().token(bot.token).arbitrary_callback_data(True).build() + async def test_drop_callback_data(self, bot, chat_id): + new_bot = make_bot(token=bot.token, arbitrary_callback_data=True, offline=False) + app = ApplicationBuilder().bot(new_bot).build() update = Update( 0, message=Message(0, None, Chat(1, "chat"), from_user=User(1, "user", False)) @@ -228,7 +228,7 @@ async def test_drop_callback_data(self, bot, monkeypatch, chat_id): ), ) keyboard_uuid = app.bot.callback_data_cache.persistence_data[0][0][0] - button_uuid = list(app.bot.callback_data_cache.persistence_data[0][0][2])[0] + button_uuid = next(iter(app.bot.callback_data_cache.persistence_data[0][0][2])) callback_data = keyboard_uuid + button_uuid callback_query = CallbackQuery( id="1", diff --git a/tests/ext/test_callbackdatacache.py b/tests/ext/test_callbackdatacache.py index 86282081257..380d594e253 100644 --- a/tests/ext/test_callbackdatacache.py +++ b/tests/ext/test_callbackdatacache.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,9 +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 datetime as dtm import time from copy import deepcopy -from datetime import datetime from uuid import uuid4 import pytest @@ -31,7 +31,7 @@ from tests.auxil.slots import mro_slots -@pytest.fixture() +@pytest.fixture def callback_data_cache(bot): return CallbackDataCache(bot) @@ -68,9 +68,9 @@ def test_slot_behaviour(self): keyboard_data = _KeyboardData("uuid") for attr in keyboard_data.__slots__: assert getattr(keyboard_data, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(keyboard_data)) == len( - set(mro_slots(keyboard_data)) - ), "duplicate slot" + assert len(mro_slots(keyboard_data)) == len(set(mro_slots(keyboard_data))), ( + "duplicate slot" + ) @pytest.mark.skipif( @@ -86,9 +86,9 @@ def test_slot_behaviour(self, callback_data_cache): else attr ) assert getattr(callback_data_cache, at, "err") != "err", f"got extra slot '{at}'" - assert len(mro_slots(callback_data_cache)) == len( - set(mro_slots(callback_data_cache)) - ), "duplicate slot" + assert len(mro_slots(callback_data_cache)) == len(set(mro_slots(callback_data_cache))), ( + "duplicate slot" + ) @pytest.mark.parametrize("maxsize", [1, 5, 2048]) def test_init_maxsize(self, maxsize, bot): @@ -159,8 +159,8 @@ def test_process_keyboard_full(self, bot): out2 = cdc.process_keyboard(reply_markup) assert len(cdc.persistence_data[0]) == 1 - keyboard_1, button_1 = cdc.extract_uuids(out1.inline_keyboard[0][1].callback_data) - keyboard_2, button_2 = cdc.extract_uuids(out2.inline_keyboard[0][2].callback_data) + keyboard_1, _ = cdc.extract_uuids(out1.inline_keyboard[0][1].callback_data) + keyboard_2, _ = cdc.extract_uuids(out2.inline_keyboard[0][2].callback_data) assert cdc.persistence_data[0][0][0] != keyboard_1 assert cdc.persistence_data[0][0][0] == keyboard_2 @@ -181,7 +181,9 @@ def test_process_callback_query(self, callback_data_cache, data, message, invali callback_data_cache.clear_callback_data() chat = Chat(1, "private") - effective_message = Message(message_id=1, date=datetime.now(), chat=chat, reply_markup=out) + effective_message = Message( + message_id=1, date=dtm.datetime.now(), chat=chat, reply_markup=out + ) effective_message._unfreeze() effective_message.reply_to_message = deepcopy(effective_message) effective_message.pinned_message = deepcopy(effective_message) @@ -202,9 +204,8 @@ def test_process_callback_query(self, callback_data_cache, data, message, invali assert callback_query.data == "some data 1" # make sure that we stored the mapping CallbackQuery.id -> keyboard_uuid correctly assert len(callback_data_cache._keyboard_data) == 1 - assert ( - callback_data_cache._callback_queries[cq_id] - == list(callback_data_cache._keyboard_data.keys())[0] + assert callback_data_cache._callback_queries[cq_id] == next( + iter(callback_data_cache._keyboard_data.keys()) ) else: assert callback_query.data is None @@ -319,7 +320,7 @@ def test_drop_data_missing_data(self, callback_data_cache): data=out.inline_keyboard[0][1].callback_data, ) - with pytest.raises(KeyError, match="CallbackQuery was not found in cache."): + with pytest.raises(KeyError, match="CallbackQuery was not found in cache\\."): callback_data_cache.drop_data(callback_query) callback_data_cache.process_callback_query(callback_query) @@ -375,9 +376,9 @@ def test_clear_cutoff(self, callback_data_cache, time_method, tz_bot): if time_method == "time": cutoff = time.time() elif time_method == "datetime": - cutoff = datetime.now(UTC) + cutoff = dtm.datetime.now(UTC) else: - cutoff = datetime.now(tz_bot.defaults.tzinfo).replace(tzinfo=None) + cutoff = dtm.datetime.now(tz_bot.defaults.tzinfo).replace(tzinfo=None) callback_data_cache.bot = tz_bot time.sleep(0.1) @@ -399,6 +400,6 @@ def test_clear_cutoff(self, callback_data_cache, time_method, tz_bot): assert len(callback_data_cache.persistence_data[0]) == 50 assert len(callback_data_cache.persistence_data[1]) == 100 callback_data = [ - list(data[2].values())[0] for data in callback_data_cache.persistence_data[0] + next(iter(data[2].values())) for data in callback_data_cache.persistence_data[0] ] assert callback_data == [str(i) for i in range(50, 100)] diff --git a/tests/ext/test_callbackqueryhandler.py b/tests/ext/test_callbackqueryhandler.py index f150f778f05..9d998b6e1c4 100644 --- a/tests/ext/test_callbackqueryhandler.py +++ b/tests/ext/test_callbackqueryhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -65,7 +65,7 @@ def false_update(request): return Update(update_id=2, **request.param) -@pytest.fixture() +@pytest.fixture def callback_query(bot): update = Update(0, callback_query=CallbackQuery(2, User(1, "", False), None, data="test data")) update._unfreeze() @@ -228,3 +228,47 @@ async def pattern(): with pytest.raises(TypeError, match="must not be a coroutine function"): CallbackQueryHandler(self.callback, pattern=pattern) + + def test_game_pattern(self, callback_query): + callback_query.callback_query.data = None + + callback_query.callback_query.game_short_name = "test data" + handler = CallbackQueryHandler(self.callback_basic, game_pattern=".*est.*") + assert handler.check_update(callback_query) + + callback_query.callback_query.game_short_name = "nothing here" + assert not handler.check_update(callback_query) + + callback_query.callback_query.game_short_name = "this is a short game name" + assert not handler.check_update(callback_query) + + callback_query.callback_query.data = "something" + handler = CallbackQueryHandler(self.callback_basic, game_pattern="") + assert not handler.check_update(callback_query) + + @pytest.mark.parametrize( + ("data", "pattern", "game_short_name", "game_pattern", "expected_result"), + [ + (None, None, None, None, True), + (None, ".*data", None, None, True), + (None, None, None, ".*game", True), + (None, ".*data", None, ".*game", True), + ("some_data", None, None, None, True), + ("some_data", ".*data", None, None, True), + ("some_data", None, None, ".*game", False), + ("some_data", ".*data", None, ".*game", True), + (None, None, "some_game", None, True), + (None, ".*data", "some_game", None, False), + (None, None, "some_game", ".*game", True), + (None, ".*data", "some_game", ".*game", True), + ], + ) + def test_pattern_and_game_pattern_interaction( + self, callback_query, data, pattern, game_short_name, game_pattern, expected_result + ): + callback_query.callback_query.data = data + callback_query.callback_query.game_short_name = game_short_name + handler = CallbackQueryHandler( + callback=self.callback, pattern=pattern, game_pattern=game_pattern + ) + assert bool(handler.check_update(callback_query)) == expected_result diff --git a/tests/ext/test_chatboosthandler.py b/tests/ext/test_chatboosthandler.py new file mode 100644 index 00000000000..8b567415d6d --- /dev/null +++ b/tests/ext/test_chatboosthandler.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 time + +import pytest + +from telegram import ( + Chat, + ChatBoost, + ChatBoostRemoved, + ChatBoostSourcePremium, + ChatBoostUpdated, + Update, + User, +) +from telegram._utils.datetime import from_timestamp +from telegram.ext import CallbackContext, ChatBoostHandler +from tests.auxil.slots import mro_slots +from tests.test_update import all_types as really_all_types +from tests.test_update import params as all_params + +# Remove "chat_boost" from params +params = [param for param in all_params for key in param if "chat_boost" not in key] +all_types = [param for param in really_all_types if "chat_boost" not in param] +ids = (*all_types, "callback_query_without_message") + + +@pytest.fixture(scope="class", params=params, ids=ids) +def false_update(request): + return Update(update_id=2, **request.param) + + +def chat_boost(): + return ChatBoost( + "1", + from_timestamp(int(time.time())), + from_timestamp(int(time.time())), + ChatBoostSourcePremium( + User(1, "first_name", False), + ), + ) + + +@pytest.fixture(scope="module") +def removed_chat_boost(): + return ChatBoostRemoved( + Chat(1, "group", username="chat"), + "1", + from_timestamp(int(time.time())), + ChatBoostSourcePremium( + User(1, "first_name", False), + ), + ) + + +def removed_chat_boost_update(): + return Update( + update_id=2, + removed_chat_boost=ChatBoostRemoved( + Chat(1, "group", username="chat"), + "1", + from_timestamp(int(time.time())), + ChatBoostSourcePremium( + User(1, "first_name", False), + ), + ), + ) + + +@pytest.fixture(scope="module") +def chat_boost_updated(): + return ChatBoostUpdated(Chat(1, "group", username="chat"), chat_boost()) + + +def chat_boost_updated_update(): + return Update( + update_id=2, + chat_boost=ChatBoostUpdated( + Chat(1, "group", username="chat"), + chat_boost(), + ), + ) + + +class TestChatBoostHandler: + test_flag = False + + def test_slot_behaviour(self): + action = ChatBoostHandler(self.cb_chat_boost_removed) + for attr in action.__slots__: + assert getattr(action, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" + + @pytest.fixture(autouse=True) + def _reset(self): + self.test_flag = False + + async def cb_chat_boost_updated(self, update, context): + self.test_flag = ( + isinstance(context, CallbackContext) + and isinstance(update.chat_boost, ChatBoostUpdated) + and not isinstance(update.removed_chat_boost, ChatBoostRemoved) + ) + + async def cb_chat_boost_removed(self, update, context): + self.test_flag = ( + isinstance(context, CallbackContext) + and isinstance(update.removed_chat_boost, ChatBoostRemoved) + and not isinstance(update.chat_boost, ChatBoostUpdated) + ) + + async def cb_chat_boost_any(self, update, context): + self.test_flag = isinstance(context, CallbackContext) and ( + isinstance(update.removed_chat_boost, ChatBoostRemoved) + or isinstance(update.chat_boost, ChatBoostUpdated) + ) + + @pytest.mark.parametrize( + argnames=("allowed_types", "cb", "expected"), + argvalues=[ + (ChatBoostHandler.CHAT_BOOST, "cb_chat_boost_updated", (True, False)), + (ChatBoostHandler.REMOVED_CHAT_BOOST, "cb_chat_boost_removed", (False, True)), + (ChatBoostHandler.ANY_CHAT_BOOST, "cb_chat_boost_any", (True, True)), + ], + ids=["CHAT_BOOST", "REMOVED_CHAT_BOOST", "ANY_CHAT_MEMBER"], + ) + async def test_chat_boost_types(self, app, cb, expected, allowed_types): + result_1, result_2 = expected + + update_type, other = chat_boost_updated_update(), removed_chat_boost_update() + + handler = ChatBoostHandler(getattr(self, cb), chat_boost_types=allowed_types) + app.add_handler(handler) + + async with app: + assert handler.check_update(update_type) == result_1 + await app.process_update(update_type) + assert self.test_flag == result_1 + + self.test_flag = False + + assert handler.check_update(other) == result_2 + await app.process_update(other) + assert self.test_flag == result_2 + + def test_other_update_types(self, false_update): + handler = ChatBoostHandler(self.cb_chat_boost_removed) + assert not handler.check_update(false_update) + assert not handler.check_update(True) + + async def test_context(self, app): + handler = ChatBoostHandler(self.cb_chat_boost_updated) + app.add_handler(handler) + + async with app: + await app.process_update(chat_boost_updated_update()) + assert self.test_flag + + def test_with_chat_id(self): + update = chat_boost_updated_update() + cb = self.cb_chat_boost_updated + handler = ChatBoostHandler(cb, chat_id=1) + assert handler.check_update(update) + handler = ChatBoostHandler(cb, chat_id=[1]) + assert handler.check_update(update) + handler = ChatBoostHandler(cb, chat_id=2, chat_username="@chat") + assert handler.check_update(update) + + handler = ChatBoostHandler(cb, chat_id=2) + assert not handler.check_update(update) + handler = ChatBoostHandler(cb, chat_id=[2]) + assert not handler.check_update(update) + + def test_with_username(self): + update = removed_chat_boost_update() + cb = self.cb_chat_boost_removed + handler = ChatBoostHandler(cb, chat_boost_types=0, chat_username="chat") + assert handler.check_update(update) + handler = ChatBoostHandler(cb, chat_boost_types=0, chat_username="@chat") + assert handler.check_update(update) + handler = ChatBoostHandler(cb, chat_boost_types=0, chat_username=["chat"]) + assert handler.check_update(update) + handler = ChatBoostHandler(cb, chat_boost_types=0, chat_username=["@chat"]) + assert handler.check_update(update) + handler = ChatBoostHandler( + cb, chat_boost_types=0, chat_id=1, chat_username="@chat_something" + ) + assert handler.check_update(update) + + handler = ChatBoostHandler(cb, chat_boost_types=0, chat_username="chat_b") + assert not handler.check_update(update) + handler = ChatBoostHandler(cb, chat_boost_types=0, chat_username="@chat_b") + assert not handler.check_update(update) + handler = ChatBoostHandler(cb, chat_boost_types=0, chat_username=["chat_b"]) + assert not handler.check_update(update) + handler = ChatBoostHandler(cb, chat_boost_types=0, chat_username=["@chat_b"]) + assert not handler.check_update(update) + + update.removed_chat_boost.chat._unfreeze() + update.removed_chat_boost.chat.username = None + assert not handler.check_update(update) diff --git a/tests/ext/test_chatjoinrequesthandler.py b/tests/ext/test_chatjoinrequesthandler.py index bc359a6c1d5..4a5ea46bcea 100644 --- a/tests/ext/test_chatjoinrequesthandler.py +++ b/tests/ext/test_chatjoinrequesthandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -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/]. import asyncio -import datetime +import datetime as dtm import pytest @@ -72,7 +72,7 @@ def false_update(request): @pytest.fixture(scope="class") def time(): - return datetime.datetime.now(tz=UTC) + return dtm.datetime.now(tz=UTC) @pytest.fixture(scope="class") @@ -96,7 +96,7 @@ def chat_join_request(time, bot): return cjr -@pytest.fixture() +@pytest.fixture def chat_join_request_update(bot, chat_join_request): return Update(0, chat_join_request=chat_join_request) diff --git a/tests/ext/test_chatmemberhandler.py b/tests/ext/test_chatmemberhandler.py index 45f16eb35d8..addaeabacc4 100644 --- a/tests/ext/test_chatmemberhandler.py +++ b/tests/ext/test_chatmemberhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -81,7 +81,7 @@ def chat_member_updated(): ) -@pytest.fixture() +@pytest.fixture def chat_member(bot, chat_member_updated): update = Update(0, my_chat_member=chat_member_updated) update._unfreeze() @@ -115,7 +115,7 @@ async def callback(self, update, context): ) @pytest.mark.parametrize( - argnames=["allowed_types", "expected"], + argnames=("allowed_types", "expected"), argvalues=[ (ChatMemberHandler.MY_CHAT_MEMBER, (True, False)), (ChatMemberHandler.CHAT_MEMBER, (False, True)), @@ -144,6 +144,66 @@ async def test_chat_member_types( await app.process_update(chat_member) assert self.test_flag == result_2 + @pytest.mark.parametrize( + argnames=("allowed_types", "chat_id", "expected"), + argvalues=[ + (ChatMemberHandler.MY_CHAT_MEMBER, None, (True, False)), + (ChatMemberHandler.CHAT_MEMBER, None, (False, True)), + (ChatMemberHandler.ANY_CHAT_MEMBER, None, (True, True)), + (ChatMemberHandler.MY_CHAT_MEMBER, 1, (True, False)), + (ChatMemberHandler.CHAT_MEMBER, 1, (False, True)), + (ChatMemberHandler.ANY_CHAT_MEMBER, 1, (True, True)), + (ChatMemberHandler.MY_CHAT_MEMBER, [1], (True, False)), + (ChatMemberHandler.CHAT_MEMBER, [1], (False, True)), + (ChatMemberHandler.ANY_CHAT_MEMBER, [1], (True, True)), + (ChatMemberHandler.MY_CHAT_MEMBER, 2, (False, False)), + (ChatMemberHandler.CHAT_MEMBER, 2, (False, False)), + (ChatMemberHandler.ANY_CHAT_MEMBER, 2, (False, False)), + (ChatMemberHandler.MY_CHAT_MEMBER, [2], (False, False)), + (ChatMemberHandler.CHAT_MEMBER, [2], (False, False)), + (ChatMemberHandler.ANY_CHAT_MEMBER, [2], (False, False)), + ], + ids=[ + "MY_CHAT_MEMBER", + "CHAT_MEMBER", + "ANY_CHAT_MEMBER", + "MY_CHAT_MEMBER, CHAT=1 ", + "CHAT_MEMBER, CHAT=1", + "ANY_CHAT_MEMBER, CHAT=1", + "MY_CHAT_MEMBER, CHAT=[1] ", + "CHAT_MEMBER, CHAT=[1]", + "ANY_CHAT_MEMBER, CHAT=[1]", + "MY_CHAT_MEMBER, CHAT=2 ", + "CHAT_MEMBER, CHAT=2", + "ANY_CHAT_MEMBER, CHAT=2", + "MY_CHAT_MEMBER, CHAT=[2] ", + "CHAT_MEMBER, CHAT=[2]", + "ANY_CHAT_MEMBER, CHAT=[2]", + ], + ) + async def test_chat_member_types_with_chat_id( + self, app, chat_member_updated, chat_member, expected, allowed_types, chat_id + ): + result_1, result_2 = expected + + handler = ChatMemberHandler( + self.callback, chat_member_types=allowed_types, chat_id=chat_id + ) + app.add_handler(handler) + + async with app: + assert handler.check_update(chat_member) == result_1 + await app.process_update(chat_member) + assert self.test_flag == result_1 + + self.test_flag = False + chat_member.my_chat_member = None + chat_member.chat_member = chat_member_updated + + assert handler.check_update(chat_member) == result_2 + await app.process_update(chat_member) + assert self.test_flag == result_2 + def test_other_update_types(self, false_update): handler = ChatMemberHandler(self.callback) assert not handler.check_update(false_update) diff --git a/tests/ext/test_choseninlineresulthandler.py b/tests/ext/test_choseninlineresulthandler.py index 83c98667187..24984e380e5 100644 --- a/tests/ext/test_choseninlineresulthandler.py +++ b/tests/ext/test_choseninlineresulthandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_commandhandler.py b/tests/ext/test_commandhandler.py index c925a494fde..154298f853c 100644 --- a/tests/ext/test_commandhandler.py +++ b/tests/ext/test_commandhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -259,3 +259,32 @@ async def test_context_multiple_regex(self, app, command): self.callback_regex2, filters=filters.Regex("one") & filters.Regex("two") ) await self._test_context_args_or_regex(app, handler, command) + + def test_command_has_args(self, bot): + """Test CHs with optional has_args specified.""" + handler_true = CommandHandler(["test"], self.callback_basic, has_args=True) + handler_false = CommandHandler(["test"], self.callback_basic, has_args=False) + handler_int_one = CommandHandler(["test"], self.callback_basic, has_args=1) + handler_int_two = CommandHandler(["test"], self.callback_basic, has_args=2) + + assert is_match(handler_true, make_command_update("/test helloworld", bot=bot)) + assert not is_match(handler_true, make_command_update("/test", bot=bot)) + + assert is_match(handler_false, make_command_update("/test", bot=bot)) + assert not is_match(handler_false, make_command_update("/test helloworld", bot=bot)) + + assert is_match(handler_int_one, make_command_update("/test helloworld", bot=bot)) + assert not is_match(handler_int_one, make_command_update("/test hello world", bot=bot)) + assert not is_match(handler_int_one, make_command_update("/test", bot=bot)) + + assert is_match(handler_int_two, make_command_update("/test hello world", bot=bot)) + assert not is_match(handler_int_two, make_command_update("/test helloworld", bot=bot)) + assert not is_match(handler_int_two, make_command_update("/test", bot=bot)) + + def test_command_has_negative_args(self, bot): + """Test CHs with optional has_args specified with negative int""" + # Assert that CommandHandler cannot be instantiated. + with pytest.raises( + ValueError, match="CommandHandler argument has_args cannot be a negative integer" + ): + is_match(CommandHandler(["test"], self.callback_basic, has_args=-1)) diff --git a/tests/ext/test_contexttypes.py b/tests/ext/test_contexttypes.py index 907209b3c1b..91240952344 100644 --- a/tests/ext/test_contexttypes.py +++ b/tests/ext/test_contexttypes.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -40,7 +40,7 @@ def test_data_init(self): assert ct.chat_data is float assert ct.user_data is bool - with pytest.raises(ValueError, match="subclass of CallbackContext"): + with pytest.raises(TypeError, match="subclass of CallbackContext"): ContextTypes(context=bool) def test_data_assignment(self): diff --git a/tests/ext/test_conversationhandler.py b/tests/ext/test_conversationhandler.py index 484e30ce743..ea0dc580754 100644 --- a/tests/ext/test_conversationhandler.py +++ b/tests/ext/test_conversationhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,8 +17,11 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Persistence of conversations is tested in test_basepersistence.py""" + import asyncio +import functools import logging +from copy import copy from pathlib import Path from warnings import filterwarnings @@ -59,7 +62,7 @@ ) from telegram.warnings import PTBUserWarning from tests.auxil.build_messages import make_command_message -from tests.auxil.files import PROJECT_ROOT_PATH +from tests.auxil.files import SOURCE_ROOT_PATH from tests.auxil.pytest_classes import PytestBot, make_bot from tests.auxil.slots import mro_slots @@ -75,6 +78,7 @@ def user2(): def raise_ahs(func): + @functools.wraps(func) # for checking __repr__ async def decorator(self, *args, **kwargs): result = await func(self, *args, **kwargs) if self.raise_app_handler_stop: @@ -289,6 +293,39 @@ def test_init_persistent_no_name(self): self.entry_points, states=self.states, fallbacks=[], persistent=True ) + def test_repr_no_truncation(self): + # ConversationHandler's __repr__ is not inherited from BaseHandler. + ch = ConversationHandler( + name="test_handler", + entry_points=[], + states=self.drinking_states, + fallbacks=[], + ) + assert repr(ch) == ( + "ConversationHandler[name=test_handler, " + "states={'a': [CommandHandler[callback=TestConversationHandler.sip]], " + "'b': [CommandHandler[callback=TestConversationHandler.swallow]], " + "'c': [CommandHandler[callback=TestConversationHandler.hold]]}]" + ) + + def test_repr_with_truncation(self): + states = copy(self.drinking_states) + # there are exactly 3 drinking states. adding one more to make sure it's truncated + states["extra_to_be_truncated"] = [CommandHandler("foo", self.start)] + + ch = ConversationHandler( + name="test_handler", + entry_points=[], + states=states, + fallbacks=[], + ) + assert repr(ch) == ( + "ConversationHandler[name=test_handler, " + "states={'a': [CommandHandler[callback=TestConversationHandler.sip]], " + "'b': [CommandHandler[callback=TestConversationHandler.swallow]], " + "'c': [CommandHandler[callback=TestConversationHandler.hold]], ...}]" + ) + async def test_check_update_returns_non(self, app, user1): """checks some cases where updates should not be handled""" conv_handler = ConversationHandler([], {}, [], per_message=True, per_chat=True) @@ -394,21 +431,25 @@ class NotUpdate: assert len(recwarn) == 13 # now we test the messages, they are raised in the order they are inserted # into the conversation handler - assert str(recwarn[0].message) == ( - "The `ConversationHandler` only handles updates of type `telegram.Update`. " + assert ( + str(recwarn[0].message) + == "The `ConversationHandler` only handles updates of type `telegram.Update`. " "StringCommandHandler handles updates of type `str`." ) - assert str(recwarn[1].message) == ( - "The `ConversationHandler` only handles updates of type `telegram.Update`. " + assert ( + str(recwarn[1].message) + == "The `ConversationHandler` only handles updates of type `telegram.Update`. " "StringRegexHandler handles updates of type `str`." ) - assert str(recwarn[2].message) == ( - "PollHandler will never trigger in a conversation since it has no information " + assert ( + str(recwarn[2].message) + == "PollHandler will never trigger in a conversation since it has no information " "about the chat or the user who voted in it. Do you mean the " "`PollAnswerHandler`?" ) - assert str(recwarn[3].message) == ( - "The `ConversationHandler` only handles updates of type `telegram.Update`. " + assert ( + str(recwarn[3].message) + == "The `ConversationHandler` only handles updates of type `telegram.Update`. " "The TypeHandler is set to handle NotUpdate." ) @@ -444,17 +485,19 @@ class NotUpdate: + per_faq_link ) assert str(recwarn[10].message) == ( - "If 'per_message=False', 'CallbackQueryHandler' will not be tracked for " - "every message." + per_faq_link + "If 'per_message=False', 'CallbackQueryHandler' will not be tracked for every message." + + per_faq_link ) - assert str(recwarn[11].message) == ( - "Using `conversation_timeout` with nested conversations is currently not " + assert ( + str(recwarn[11].message) + == "Using `conversation_timeout` with nested conversations is currently not " "supported. You can still try to use it, but it will likely behave differently" " from what you expect." ) - assert str(recwarn[12].message) == ( - "If 'per_message=True' is used, 'per_chat=True' should also be used, " + assert ( + str(recwarn[12].message) + == "If 'per_message=True' is used, 'per_chat=True' should also be used, " "since message IDs are not globally unique." ) @@ -682,10 +725,11 @@ async def callback(_, __): assert recwarn[0].category is PTBUserWarning assert ( Path(recwarn[0].filename) - == PROJECT_ROOT_PATH / "telegram" / "ext" / "_conversationhandler.py" + == SOURCE_ROOT_PATH / "ext" / "_handlers" / "conversationhandler.py" ), "wrong stacklevel!" - assert str(recwarn[0].message) == ( - "'callback' returned state 69 which is unknown to the ConversationHandler xyz." + assert ( + str(recwarn[0].message) + == "'callback' returned state 69 which is unknown to the ConversationHandler xyz." ) async def test_conversation_handler_per_chat(self, app, bot, user1, user2): @@ -1061,7 +1105,7 @@ async def test_no_running_job_queue_warning(self, app, bot, user1, recwarn, jq): assert warning.category is PTBUserWarning assert ( Path(warning.filename) - == PROJECT_ROOT_PATH / "telegram" / "ext" / "_conversationhandler.py" + == SOURCE_ROOT_PATH / "ext" / "_handlers" / "conversationhandler.py" ), "wrong stacklevel!" # now set app.job_queue back to it's original value @@ -1379,10 +1423,9 @@ def timeout(*args, **kwargs): assert len(recwarn) == 1 assert str(recwarn[0].message).startswith("ApplicationHandlerStop in TIMEOUT") assert recwarn[0].category is PTBUserWarning - assert ( - Path(recwarn[0].filename) - == PROJECT_ROOT_PATH / "telegram" / "ext" / "_jobqueue.py" - ), "wrong stacklevel!" + assert Path(recwarn[0].filename) == SOURCE_ROOT_PATH / "ext" / "_jobqueue.py", ( + "wrong stacklevel!" + ) await app.stop() @@ -1390,7 +1433,7 @@ async def test_conversation_handler_timeout_update_and_context(self, app, bot, u context = None async def start_callback(u, c): - nonlocal context, self + nonlocal context context = c return await self.start(u, c) @@ -1411,7 +1454,6 @@ async def start_callback(u, c): update = Update(update_id=0, message=message) async def timeout_callback(u, c): - nonlocal update, context assert u is update assert c is context @@ -2068,6 +2110,9 @@ async def callback(_, __): assert conv_handler.check_update(Update(0, message=message)) await app.process_update(Update(0, message=message)) await asyncio.sleep(0.7) + tasks = asyncio.all_tasks() + assert any(":handle_update:non_blocking_cb" in t.get_name() for t in tasks) + assert any(":handle_update:timeout_job" in t.get_name() for t in tasks) assert not self.is_timeout event.set() await asyncio.sleep(0.7) diff --git a/tests/_utils/test_defaults.py b/tests/ext/test_defaults.py similarity index 66% rename from tests/_utils/test_defaults.py rename to tests/ext/test_defaults.py index 45e2849f793..31d9d9e126c 100644 --- a/tests/_utils/test_defaults.py +++ b/tests/ext/test_defaults.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -24,23 +24,31 @@ from telegram import User from telegram.ext import Defaults +from telegram.warnings import PTBDeprecationWarning from tests.auxil.envvars import TEST_WITH_OPT_DEPS from tests.auxil.slots import mro_slots -class TestDefault: +class TestDefaults: def test_slot_behaviour(self): - a = Defaults(parse_mode="HTML", quote=True) + a = Defaults(parse_mode="HTML", do_quote=True) for attr in a.__slots__: assert getattr(a, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(a)) == len(set(mro_slots(a))), "duplicate slot" def test_utc(self): defaults = Defaults() - if not TEST_WITH_OPT_DEPS: - assert defaults.tzinfo is dtm.timezone.utc - else: - assert defaults.tzinfo is not dtm.timezone.utc + assert defaults.tzinfo is dtm.timezone.utc + + @pytest.mark.skipif(not TEST_WITH_OPT_DEPS, reason="pytz not installed") + def test_pytz_deprecation(self, recwarn): + import pytz # noqa: PLC0415 + + with pytest.warns(PTBDeprecationWarning, match="pytz") as record: + Defaults(tzinfo=pytz.timezone("Europe/Berlin")) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" def test_data_assignment(self): defaults = Defaults() @@ -50,11 +58,13 @@ def test_data_assignment(self): setattr(defaults, name, True) def test_equality(self): - a = Defaults(parse_mode="HTML", quote=True) - b = Defaults(parse_mode="HTML", quote=True) - c = Defaults(parse_mode="HTML", quote=True, protect_content=True) + a = Defaults(parse_mode="HTML", do_quote=True) + b = Defaults(parse_mode="HTML", do_quote=True) + c = Defaults(parse_mode="HTML", do_quote=True, protect_content=True) d = Defaults(parse_mode="HTML", protect_content=True) e = User(123, "test_user", False) + f = Defaults(parse_mode="HTML", block=True) + g = Defaults(parse_mode="HTML", block=True) assert a == b assert hash(a) == hash(b) @@ -68,3 +78,6 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) + + assert f == g + assert hash(f) == hash(g) diff --git a/tests/ext/test_dictpersistence.py b/tests/ext/test_dictpersistence.py index b74db2d55f0..a6a4fb4494b 100644 --- a/tests/ext/test_dictpersistence.py +++ b/tests/ext/test_dictpersistence.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -31,27 +31,27 @@ def _reset_callback_data_cache(cdc_bot): cdc_bot.callback_data_cache.clear_callback_queries() -@pytest.fixture() +@pytest.fixture def bot_data(): return {"test1": "test2", "test3": {"test4": "test5"}} -@pytest.fixture() +@pytest.fixture def chat_data(): return {-12345: {"test1": "test2", "test3": {"test4": "test5"}}, -67890: {3: "test4"}} -@pytest.fixture() +@pytest.fixture def user_data(): return {12345: {"test1": "test2", "test3": {"test4": "test5"}}, 67890: {3: "test4"}} -@pytest.fixture() +@pytest.fixture def callback_data(): return [("test1", 1000, {"button1": "test0", "button2": "test1"})], {"test1": "test2"} -@pytest.fixture() +@pytest.fixture def conversations(): return { "name1": {(123, 123): 3, (456, 654): 4}, @@ -60,27 +60,27 @@ def conversations(): } -@pytest.fixture() +@pytest.fixture def user_data_json(user_data): return json.dumps(user_data) -@pytest.fixture() +@pytest.fixture def chat_data_json(chat_data): return json.dumps(chat_data) -@pytest.fixture() +@pytest.fixture def bot_data_json(bot_data): return json.dumps(bot_data) -@pytest.fixture() +@pytest.fixture def callback_data_json(callback_data): return json.dumps(callback_data) -@pytest.fixture() +@pytest.fixture def conversations_json(conversations): return """{"name1": {"[123, 123]": 3, "[456, 654]": 4}, "name2": {"[123, 321]": 1, "[890, 890]": 2}, "name3": diff --git a/tests/ext/test_filters.py b/tests/ext/test_filters.py index 80737f9ee54..b01670806a1 100644 --- a/tests/ext/test_filters.py +++ b/tests/ext/test_filters.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,8 +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 datetime +import datetime as dtm import inspect +import platform import re import pytest @@ -30,7 +31,12 @@ File, Message, MessageEntity, + MessageOriginChannel, + MessageOriginChat, + MessageOriginHiddenUser, + MessageOriginUser, Sticker, + SuccessfulPayment, Update, User, ) @@ -38,19 +44,18 @@ from tests.auxil.slots import mro_slots -@pytest.fixture() +@pytest.fixture def update(): update = Update( 0, Message( 0, - datetime.datetime.utcnow(), + dtm.datetime.utcnow(), Chat(0, "private"), from_user=User(0, "Testuser", False), via_bot=User(0, "Testbot", True), sender_chat=Chat(0, "Channel"), - forward_from=User(0, "HAL9000", False), - forward_from_chat=Chat(0, "Channel"), + forward_origin=MessageOriginUser(dtm.datetime.utcnow(), User(0, "Testuser", False)), ), ) update._unfreeze() @@ -59,8 +64,8 @@ def update(): update.message.from_user._unfreeze() update.message.via_bot._unfreeze() update.message.sender_chat._unfreeze() - update.message.forward_from._unfreeze() - update.message.forward_from_chat._unfreeze() + update.message.forward_origin._unfreeze() + update.message.forward_origin.sender_user._unfreeze() return update @@ -78,6 +83,11 @@ def base_class(request): return request.param["class"] +@pytest.fixture(scope="class") +def message_origin_user(): + return MessageOriginUser(dtm.datetime.utcnow(), User(1, "TestOther", False)) + + class TestFilters: def test_all_filters_slot_behaviour(self): """ @@ -92,7 +102,7 @@ def filter_class(obj): # The total no. of filters is about 72 as of 31/10/21. # Gather all the filters to test using DFS- visited = [] - classes = inspect.getmembers(filters, predicate=filter_class) # List[Tuple[str, type]] + classes = inspect.getmembers(filters, predicate=filter_class) # list[tuple[str, type]] stack = classes.copy() while stack: cls = stack[-1][-1] # get last element and its class @@ -144,13 +154,13 @@ def test__all__(self): not key.startswith("_") # exclude imported stuff and getattr(member, "__module__", "unknown module") == "telegram.ext.filters" - and key != "sys" + and key not in ("sys", "dtm") ) } actual = set(filters.__all__) - assert ( - actual == expected - ), f"Members {expected - actual} were not listed in constants.__all__" + assert actual == expected, ( + f"Members {expected - actual} were not listed in constants.__all__" + ) def test_filters_all(self, update): assert filters.ALL.check_update(update) @@ -266,11 +276,11 @@ def test_filters_merged_with_regex(self, update): result = (filters.COMMAND | filters.Regex(r"linked param")).check_update(update) assert result is True - def test_regex_complex_merges(self, update): + def test_regex_complex_merges(self, update, message_origin_user): sre_type = type(re.match("", "")) update.message.text = "test it out" test_filter = filters.Regex("test") & ( - (filters.StatusUpdate.ALL | filters.FORWARDED) | filters.Regex("out") + (filters.StatusUpdate.ALL | filters.AUDIO) | filters.Regex("out") ) result = test_filter.check_update(update) assert result @@ -279,7 +289,7 @@ def test_regex_complex_merges(self, update): assert isinstance(matches, list) assert len(matches) == 2 assert all(type(res) is sre_type for res in matches) - update.message.forward_date = datetime.datetime.utcnow() + update.message.audio = "test" result = test_filter.check_update(update) assert result assert isinstance(result, dict) @@ -293,7 +303,7 @@ def test_regex_complex_merges(self, update): matches = result["matches"] assert isinstance(matches, list) assert all(type(res) is sre_type for res in matches) - update.message.forward_date = None + update.message.audio = None result = test_filter.check_update(update) assert not result update.message.text = "test it out" @@ -315,7 +325,7 @@ def test_regex_complex_merges(self, update): assert not result update.message.text = "test it out" - update.message.forward_date = None + update.message.forward_origin = None update.message.pinned_message = None test_filter = (filters.Regex("test") | filters.COMMAND) & ( filters.Regex("it") | filters.StatusUpdate.ALL @@ -473,11 +483,11 @@ def test_filters_merged_with_caption_regex(self, update): result = (filters.COMMAND | filters.CaptionRegex(r"linked param")).check_update(update) assert result is True - def test_caption_regex_complex_merges(self, update): + def test_caption_regex_complex_merges(self, update, message_origin_user): sre_type = type(re.match("", "")) update.message.caption = "test it out" test_filter = filters.CaptionRegex("test") & ( - (filters.StatusUpdate.ALL | filters.FORWARDED) | filters.CaptionRegex("out") + (filters.StatusUpdate.ALL | filters.AUDIO) | filters.CaptionRegex("out") ) result = test_filter.check_update(update) assert result @@ -486,7 +496,7 @@ def test_caption_regex_complex_merges(self, update): assert isinstance(matches, list) assert len(matches) == 2 assert all(type(res) is sre_type for res in matches) - update.message.forward_date = datetime.datetime.utcnow() + update.message.audio = "test" result = test_filter.check_update(update) assert result assert isinstance(result, dict) @@ -500,7 +510,7 @@ def test_caption_regex_complex_merges(self, update): matches = result["matches"] assert isinstance(matches, list) assert all(type(res) is sre_type for res in matches) - update.message.forward_date = None + update.message.audio = None result = test_filter.check_update(update) assert not result update.message.caption = "test it out" @@ -522,7 +532,7 @@ def test_caption_regex_complex_merges(self, update): assert not result update.message.caption = "test it out" - update.message.forward_date = None + update.message.forward_origin = None update.message.pinned_message = None test_filter = (filters.CaptionRegex("test") | filters.COMMAND) & ( filters.CaptionRegex("it") | filters.StatusUpdate.ALL @@ -605,7 +615,7 @@ def test_caption_regex_inverted(self, update): def test_filters_reply(self, update): another_message = Message( 1, - datetime.datetime.utcnow(), + dtm.datetime.utcnow(), Chat(0, "private"), from_user=User(1, "TestOther", False), ) @@ -702,7 +712,9 @@ def test_filters_document_type(self, update): assert not filters.Document.WAV.check_update(update) assert not filters.Document.AUDIO.check_update(update) - update.message.document.mime_type = "audio/x-wav" + update.message.document.mime_type = ( + "audio/x-wav" if int(platform.python_version_tuple()[1]) < 14 else "audio/vnd.wave" + ) assert filters.Document.WAV.check_update(update) assert filters.Document.AUDIO.check_update(update) assert not filters.Document.XML.check_update(update) @@ -829,20 +841,22 @@ def test_filters_file_extension_case_sensitivity(self, update): ) def test_filters_file_extension_name(self): - assert filters.Document.FileExtension("jpg").name == ( - "filters.Document.FileExtension('jpg')" + assert ( + filters.Document.FileExtension("jpg").name == "filters.Document.FileExtension('jpg')" ) - assert filters.Document.FileExtension("JPG").name == ( - "filters.Document.FileExtension('jpg')" + assert ( + filters.Document.FileExtension("JPG").name == "filters.Document.FileExtension('jpg')" ) - assert filters.Document.FileExtension("jpg", case_sensitive=True).name == ( - "filters.Document.FileExtension('jpg', case_sensitive=True)" + assert ( + filters.Document.FileExtension("jpg", case_sensitive=True).name + == "filters.Document.FileExtension('jpg', case_sensitive=True)" ) - assert filters.Document.FileExtension("JPG", case_sensitive=True).name == ( - "filters.Document.FileExtension('JPG', case_sensitive=True)" + assert ( + filters.Document.FileExtension("JPG", case_sensitive=True).name + == "filters.Document.FileExtension('JPG', case_sensitive=True)" ) - assert filters.Document.FileExtension(".jpg").name == ( - "filters.Document.FileExtension('.jpg')" + assert ( + filters.Document.FileExtension(".jpg").name == "filters.Document.FileExtension('.jpg')" ) assert filters.Document.FileExtension("").name == "filters.Document.FileExtension('')" assert filters.Document.FileExtension(None).name == "filters.Document.FileExtension(None)" @@ -884,6 +898,16 @@ def test_filters_sticker(self, update): assert filters.Sticker.VIDEO.check_update(update) assert filters.Sticker.PREMIUM.check_update(update) + def test_filters_story(self, update): + assert not filters.STORY.check_update(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" @@ -1047,26 +1071,113 @@ def test_filters_status_update(self, update): assert filters.StatusUpdate.WRITE_ACCESS_ALLOWED.check_update(update) update.message.write_access_allowed = None - update.message.user_shared = "user_shared" + update.message.users_shared = "users_shared" assert filters.StatusUpdate.ALL.check_update(update) - assert filters.StatusUpdate.USER_SHARED.check_update(update) - update.message.user_shared = None + assert filters.StatusUpdate.USERS_SHARED.check_update(update) + update.message.users_shared = None update.message.chat_shared = "user_shared" assert filters.StatusUpdate.ALL.check_update(update) assert filters.StatusUpdate.CHAT_SHARED.check_update(update) update.message.chat_shared = None + update.message.giveaway_created = "giveaway_created" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.GIVEAWAY_CREATED.check_update(update) + update.message.giveaway_created = None + + update.message.giveaway_completed = "giveaway_completed" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.GIVEAWAY_COMPLETED.check_update(update) + update.message.giveaway_completed = None + + update.message.chat_background_set = "test_background" + assert filters.StatusUpdate.ALL.check_update(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 + + update.message.suggested_post_approval_failed = "suggested_post_approval_failed" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.SUGGESTED_POST_APPROVAL_FAILED.check_update(update) + update.message.suggested_post_approval_failed = None + + update.message.suggested_post_approved = "suggested_post_approved" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.SUGGESTED_POST_APPROVED.check_update(update) + update.message.suggested_post_approved = None + + update.message.suggested_post_declined = "suggested_post_declined" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.SUGGESTED_POST_DECLINED.check_update(update) + update.message.suggested_post_declined = None + + update.message.suggested_post_paid = "suggested_post_paid" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.SUGGESTED_POST_PAID.check_update(update) + update.message.suggested_post_paid = None + + update.message.suggested_post_refunded = "suggested_post_refunded" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.SUGGESTED_POST_REFUNDED.check_update(update) + update.message.suggested_post_refunded = None + + update.message.gift = "gift" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.GIFT.check_update(update) + update.message.gift = None + + update.message.unique_gift = "unique_gift" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.UNIQUE_GIFT.check_update(update) + update.message.unique_gift = None + + update.message.gift_upgrade_sent = "gift_upgrade_sent" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.GIFT_UPGRADE_SENT.check_update(update) + update.message.gift_upgrade_sent = None + + update.message.paid_message_price_changed = "paid_message_price_changed" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.PAID_MESSAGE_PRICE_CHANGED.check_update(update) + update.message.paid_message_price_changed = None + + update.message.direct_message_price_changed = "direct_message_price_changed" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.DIRECT_MESSAGE_PRICE_CHANGED.check_update(update) + update.message.direct_message_price_changed = None + + update.message.checklist_tasks_added = "checklist_tasks_added" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.CHECKLIST_TASKS_ADDED.check_update(update) + update.message.checklist_tasks_added = None + + update.message.checklist_tasks_done = "checklist_tasks_done" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.CHECKLIST_TASKS_DONE.check_update(update) + update.message.checklist_tasks_done = None + def test_filters_forwarded(self, update): - assert not filters.FORWARDED.check_update(update) - update.message.forward_date = datetime.datetime.utcnow() assert filters.FORWARDED.check_update(update) + update.message.forward_origin = MessageOriginHiddenUser(dtm.datetime.utcnow(), 1) + assert filters.FORWARDED.check_update(update) + update.message.forward_origin = None + assert not filters.FORWARDED.check_update(update) def test_filters_game(self, update): assert not filters.GAME.check_update(update) update.message.game = "test" assert filters.GAME.check_update(update) + def test_filters_effect_id(self, update): + assert not filters.EFFECT_ID.check_update(update) + update.message.effect_id = "test" + assert filters.EFFECT_ID.check_update(update) + def test_entities_filter(self, update, message_entity): update.message.entities = [message_entity] assert filters.Entity(message_entity.type).check_update(update) @@ -1281,15 +1392,12 @@ def test_filters_chat_allow_empty(self, update): def test_filters_chat_id(self, update): assert not filters.Chat(chat_id=1).check_update(update) - assert filters.CHAT.check_update(update) update.message.chat.id = 1 assert filters.Chat(chat_id=1).check_update(update) - assert filters.CHAT.check_update(update) update.message.chat.id = 2 assert filters.Chat(chat_id=[1, 2]).check_update(update) assert not filters.Chat(chat_id=[3, 4]).check_update(update) update.message.chat = None - assert not filters.CHAT.check_update(update) assert not filters.Chat(chat_id=[3, 4]).check_update(update) def test_filters_chat_username(self, update): @@ -1418,6 +1526,11 @@ def test_filters_chat_repr(self): with pytest.raises(RuntimeError, match="Cannot set name"): f.name = "foo" + def test_filters_forum(self, update): + assert not filters.FORUM.check_update(update) + update.message.chat.is_forum = True + assert filters.FORUM.check_update(update) + def test_filters_forwarded_from_init(self): with pytest.raises(RuntimeError, match="in conjunction with"): filters.ForwardedFrom(chat_id=1, username="chat") @@ -1426,57 +1539,88 @@ def test_filters_forwarded_from_allow_empty(self, update): assert not filters.ForwardedFrom().check_update(update) assert filters.ForwardedFrom(allow_empty=True).check_update(update) + update.message.forward_origin = MessageOriginHiddenUser(date=1, sender_user_name="test") + assert not filters.ForwardedFrom(allow_empty=True).check_update(update) + def test_filters_forwarded_from_id(self, update): # Test with User id- assert not filters.ForwardedFrom(chat_id=1).check_update(update) - update.message.forward_from.id = 1 + update.message.forward_origin.sender_user.id = 1 assert filters.ForwardedFrom(chat_id=1).check_update(update) - update.message.forward_from.id = 2 + update.message.forward_origin.sender_user.id = 2 assert filters.ForwardedFrom(chat_id=[1, 2]).check_update(update) assert not filters.ForwardedFrom(chat_id=[3, 4]).check_update(update) - update.message.forward_from = None + update.message.forward_origin = None assert not filters.ForwardedFrom(chat_id=[3, 4]).check_update(update) # Test with Chat id- - update.message.forward_from_chat.id = 4 + update.message.forward_origin = MessageOriginChat(date=1, sender_chat=Chat(4, "test")) + assert filters.ForwardedFrom(chat_id=[4]).check_update(update) + assert filters.ForwardedFrom(chat_id=[3, 4]).check_update(update) + + update.message.forward_origin = MessageOriginChat(date=1, sender_chat=Chat(2, "test")) + assert not filters.ForwardedFrom(chat_id=[3, 4]).check_update(update) + assert filters.ForwardedFrom(chat_id=2).check_update(update) + + # Test with Channel id- + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(4, "test"), message_id=1 + ) assert filters.ForwardedFrom(chat_id=[4]).check_update(update) assert filters.ForwardedFrom(chat_id=[3, 4]).check_update(update) - update.message.forward_from_chat.id = 2 + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(2, "test"), message_id=1 + ) assert not filters.ForwardedFrom(chat_id=[3, 4]).check_update(update) assert filters.ForwardedFrom(chat_id=2).check_update(update) - update.message.forward_from_chat = None + update.message.forward_origin = None def test_filters_forwarded_from_username(self, update): # For User username assert not filters.ForwardedFrom(username="chat").check_update(update) assert not filters.ForwardedFrom(username="Testchat").check_update(update) - update.message.forward_from.username = "chat@" + update.message.forward_origin.sender_user.username = "chat@" assert filters.ForwardedFrom(username="@chat@").check_update(update) assert filters.ForwardedFrom(username="chat@").check_update(update) assert filters.ForwardedFrom(username=["chat1", "chat@", "chat2"]).check_update(update) assert not filters.ForwardedFrom(username=["@username", "@chat_2"]).check_update(update) - update.message.forward_from = None + update.message.forward_origin = None assert not filters.ForwardedFrom(username=["@username", "@chat_2"]).check_update(update) # For Chat username assert not filters.ForwardedFrom(username="chat").check_update(update) assert not filters.ForwardedFrom(username="Testchat").check_update(update) - update.message.forward_from_chat.username = "chat@" + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(4, username="chat@", type=Chat.SUPERGROUP) + ) assert filters.ForwardedFrom(username="@chat@").check_update(update) assert filters.ForwardedFrom(username="chat@").check_update(update) assert filters.ForwardedFrom(username=["chat1", "chat@", "chat2"]).check_update(update) assert not filters.ForwardedFrom(username=["@username", "@chat_2"]).check_update(update) - update.message.forward_from_chat = None + update.message.forward_origin = None + assert not filters.ForwardedFrom(username=["@username", "@chat_2"]).check_update(update) + + # For Channel username + assert not filters.ForwardedFrom(username="chat").check_update(update) + assert not filters.ForwardedFrom(username="Testchat").check_update(update) + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(4, username="chat@", type=Chat.SUPERGROUP), message_id=1 + ) + assert filters.ForwardedFrom(username="@chat@").check_update(update) + assert filters.ForwardedFrom(username="chat@").check_update(update) + assert filters.ForwardedFrom(username=["chat1", "chat@", "chat2"]).check_update(update) + assert not filters.ForwardedFrom(username=["@username", "@chat_2"]).check_update(update) + update.message.forward_origin = None assert not filters.ForwardedFrom(username=["@username", "@chat_2"]).check_update(update) def test_filters_forwarded_from_change_id(self, update): f = filters.ForwardedFrom(chat_id=1) # For User ids- assert f.chat_ids == {1} - update.message.forward_from.id = 1 + update.message.forward_origin.sender_user.id = 1 assert f.check_update(update) - update.message.forward_from.id = 2 + update.message.forward_origin.sender_user.id = 2 assert not f.check_update(update) f.chat_ids = 2 assert f.chat_ids == {2} @@ -1484,11 +1628,29 @@ def test_filters_forwarded_from_change_id(self, update): # For Chat ids- f = filters.ForwardedFrom(chat_id=1) # reset this - update.message.forward_from = None # and change this to None, only one of them can be True + # and change this to None, only one of them can be True + update.message.forward_origin = None assert f.chat_ids == {1} - update.message.forward_from_chat.id = 1 + update.message.forward_origin = MessageOriginChat(date=1, sender_chat=Chat(1, "test")) assert f.check_update(update) - update.message.forward_from_chat.id = 2 + update.message.forward_origin = MessageOriginChat(date=1, sender_chat=Chat(2, "test")) + assert not f.check_update(update) + f.chat_ids = 2 + assert f.chat_ids == {2} + assert f.check_update(update) + + # For Channel ids- + f = filters.ForwardedFrom(chat_id=1) # reset this + # and change this to None, only one of them can be True + update.message.forward_origin = None + assert f.chat_ids == {1} + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(1, "test"), message_id=1 + ) + assert f.check_update(update) + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(2, "test"), message_id=1 + ) assert not f.check_update(update) f.chat_ids = 2 assert f.chat_ids == {2} @@ -1500,19 +1662,37 @@ def test_filters_forwarded_from_change_id(self, update): def test_filters_forwarded_from_change_username(self, update): # For User usernames f = filters.ForwardedFrom(username="chat") - update.message.forward_from.username = "chat" + update.message.forward_origin.sender_user.username = "chat" assert f.check_update(update) - update.message.forward_from.username = "User" + update.message.forward_origin.sender_user.username = "User" assert not f.check_update(update) f.usernames = "User" assert f.check_update(update) # For Chat usernames - update.message.forward_from = None + update.message.forward_origin = None + f = filters.ForwardedFrom(username="chat") + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(1, username="chat", type=Chat.SUPERGROUP) + ) + assert f.check_update(update) + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(2, username="User", type=Chat.SUPERGROUP) + ) + assert not f.check_update(update) + f.usernames = "User" + assert f.check_update(update) + + # For Channel usernames + update.message.forward_origin = None f = filters.ForwardedFrom(username="chat") - update.message.forward_from_chat.username = "chat" + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(1, username="chat", type=Chat.SUPERGROUP), message_id=1 + ) assert f.check_update(update) - update.message.forward_from_chat.username = "User" + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(2, username="User", type=Chat.SUPERGROUP), message_id=1 + ) assert not f.check_update(update) f.usernames = "User" assert f.check_update(update) @@ -1526,28 +1706,50 @@ def test_filters_forwarded_from_add_chat_by_name(self, update): # For User usernames for chat in chats: - update.message.forward_from.username = chat + update.message.forward_origin.sender_user.username = chat assert not f.check_update(update) f.add_usernames("chat_a") f.add_usernames(["chat_b", "chat_c"]) for chat in chats: - update.message.forward_from.username = chat + update.message.forward_origin.sender_user.username = chat assert f.check_update(update) # For Chat usernames - update.message.forward_from = None + update.message.forward_origin = None + f = filters.ForwardedFrom() + for chat in chats: + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(1, username=chat, type=Chat.SUPERGROUP) + ) + assert not f.check_update(update) + + f.add_usernames("chat_a") + f.add_usernames(["chat_b", "chat_c"]) + + for chat in chats: + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(1, username=chat, type=Chat.SUPERGROUP) + ) + assert f.check_update(update) + + # For Channel usernames + update.message.forward_origin = None f = filters.ForwardedFrom() for chat in chats: - update.message.forward_from_chat.username = chat + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(1, username=chat, type=Chat.SUPERGROUP), message_id=1 + ) assert not f.check_update(update) f.add_usernames("chat_a") f.add_usernames(["chat_b", "chat_c"]) for chat in chats: - update.message.forward_from_chat.username = chat + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(1, username=chat, type=Chat.SUPERGROUP), message_id=1 + ) assert f.check_update(update) with pytest.raises(RuntimeError, match="chat_id in conjunction"): @@ -1559,28 +1761,50 @@ def test_filters_forwarded_from_add_chat_by_id(self, update): # For User ids for chat in chats: - update.message.forward_from.id = chat + update.message.forward_origin.sender_user.id = chat assert not f.check_update(update) f.add_chat_ids(1) f.add_chat_ids([2, 3]) for chat in chats: - update.message.forward_from.username = chat + update.message.forward_origin.sender_user.username = chat assert f.check_update(update) # For Chat ids- - update.message.forward_from = None + update.message.forward_origin = None f = filters.ForwardedFrom() for chat in chats: - update.message.forward_from_chat.id = chat + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(chat, "test") + ) assert not f.check_update(update) f.add_chat_ids(1) f.add_chat_ids([2, 3]) for chat in chats: - update.message.forward_from_chat.username = chat + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(chat, "test") + ) + assert f.check_update(update) + + # For Channel ids- + update.message.forward_origin = None + f = filters.ForwardedFrom() + for chat in chats: + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(chat, "test"), message_id=1 + ) + assert not f.check_update(update) + + f.add_chat_ids(1) + f.add_chat_ids([2, 3]) + + for chat in chats: + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(chat, "test"), message_id=1 + ) assert f.check_update(update) with pytest.raises(RuntimeError, match="username in conjunction"): @@ -1595,28 +1819,50 @@ def test_filters_forwarded_from_remove_chat_by_name(self, update): # For User usernames for chat in chats: - update.message.forward_from.username = chat + update.message.forward_origin.sender_user.username = chat assert f.check_update(update) f.remove_usernames("chat_a") f.remove_usernames(["chat_b", "chat_c"]) for chat in chats: - update.message.forward_from.username = chat + update.message.forward_origin.sender_user.username = chat assert not f.check_update(update) # For Chat usernames - update.message.forward_from = None + update.message.forward_origin = None + f = filters.ForwardedFrom(username=chats) + for chat in chats: + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(1, username=chat, type=Chat.SUPERGROUP) + ) + assert f.check_update(update) + + f.remove_usernames("chat_a") + f.remove_usernames(["chat_b", "chat_c"]) + + for chat in chats: + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(1, username=chat, type=Chat.SUPERGROUP) + ) + assert not f.check_update(update) + + # For Channel usernames + update.message.forward_origin = None f = filters.ForwardedFrom(username=chats) for chat in chats: - update.message.forward_from_chat.username = chat + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(1, username=chat, type=Chat.SUPERGROUP), message_id=1 + ) assert f.check_update(update) f.remove_usernames("chat_a") f.remove_usernames(["chat_b", "chat_c"]) for chat in chats: - update.message.forward_from_chat.username = chat + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(1, username=chat, type=Chat.SUPERGROUP), message_id=1 + ) assert not f.check_update(update) def test_filters_forwarded_from_remove_chat_by_id(self, update): @@ -1628,28 +1874,50 @@ def test_filters_forwarded_from_remove_chat_by_id(self, update): # For User ids for chat in chats: - update.message.forward_from.id = chat + update.message.forward_origin.sender_user.id = chat assert f.check_update(update) f.remove_chat_ids(1) f.remove_chat_ids([2, 3]) for chat in chats: - update.message.forward_from.username = chat + update.message.forward_origin.sender_user.username = chat assert not f.check_update(update) # For Chat ids - update.message.forward_from = None + update.message.forward_origin = None + f = filters.ForwardedFrom(chat_id=chats) + for chat in chats: + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(chat, "test") + ) + assert f.check_update(update) + + f.remove_chat_ids(1) + f.remove_chat_ids([2, 3]) + + for chat in chats: + update.message.forward_origin = MessageOriginChat( + date=1, sender_chat=Chat(chat, "test") + ) + assert not f.check_update(update) + + # For Channel ids + update.message.forward_origin = None f = filters.ForwardedFrom(chat_id=chats) for chat in chats: - update.message.forward_from_chat.id = chat + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(chat, "test"), message_id=1 + ) assert f.check_update(update) f.remove_chat_ids(1) f.remove_chat_ids([2, 3]) for chat in chats: - update.message.forward_from_chat.username = chat + update.message.forward_origin = MessageOriginChannel( + date=1, chat=Chat(chat, "test"), message_id=1 + ) assert not f.check_update(update) def test_filters_forwarded_from_repr(self): @@ -1845,6 +2113,11 @@ def test_filters_is_automatic_forward(self, update): update.message.is_automatic_forward = True assert filters.IS_AUTOMATIC_FORWARD.check_update(update) + def test_filters_is_from_offline(self, update): + assert not filters.IS_FROM_OFFLINE.check_update(update) + update.message.is_from_offline = True + assert filters.IS_FROM_OFFLINE.check_update(update) + def test_filters_is_topic_message(self, update): assert not filters.IS_TOPIC_MESSAGE.check_update(update) update.message.is_topic_message = True @@ -1870,6 +2143,29 @@ def test_filters_successful_payment(self, update): update.message.successful_payment = "test" assert filters.SUCCESSFUL_PAYMENT.check_update(update) + def test_filters_suggested_post_info(self, update): + assert not filters.SUGGESTED_POST_INFO.check_update(update) + update.message.suggested_post_info = "test" + assert filters.SUGGESTED_POST_INFO.check_update(update) + + def test_filters_successful_payment_payloads(self, update): + assert not filters.SuccessfulPayment(("custom-payload",)).check_update(update) + assert not filters.SuccessfulPayment().check_update(update) + + update.message.successful_payment = SuccessfulPayment( + "USD", 100, "custom-payload", "123", "123" + ) + assert filters.SuccessfulPayment(("custom-payload",)).check_update(update) + assert filters.SuccessfulPayment().check_update(update) + assert not filters.SuccessfulPayment(["test1"]).check_update(update) + + def test_filters_successful_payment_repr(self): + f = filters.SuccessfulPayment() + assert str(f) == "filters.SUCCESSFUL_PAYMENT" + + f = filters.SuccessfulPayment(["payload1", "payload2"]) + assert str(f) == "filters.SuccessfulPayment(['payload1', 'payload2'])" + def test_filters_passport_data(self, update): assert not filters.PASSPORT_DATA.check_update(update) update.message.passport_data = "test" @@ -1973,18 +2269,18 @@ def test_language_filter_multiple(self, update): update.message.from_user.language_code = "da" assert f.check_update(update) - def test_and_filters(self, update): + def test_and_filters(self, update, message_origin_user): update.message.text = "test" - update.message.forward_date = datetime.datetime.utcnow() + update.message.forward_origin = message_origin_user assert (filters.TEXT & filters.FORWARDED).check_update(update) update.message.text = "/test" assert (filters.TEXT & filters.FORWARDED).check_update(update) update.message.text = "test" - update.message.forward_date = None + update.message.forward_origin = None assert not (filters.TEXT & filters.FORWARDED).check_update(update) update.message.text = "test" - update.message.forward_date = datetime.datetime.utcnow() + update.message.forward_origin = message_origin_user assert (filters.TEXT & filters.FORWARDED & filters.ChatType.PRIVATE).check_update(update) def test_or_filters(self, update): @@ -1999,9 +2295,9 @@ def test_or_filters(self, update): def test_and_or_filters(self, update): update.message.text = "test" - update.message.forward_date = datetime.datetime.utcnow() + update.message.forward_origin = message_origin_user assert (filters.TEXT & (filters.StatusUpdate.ALL | filters.FORWARDED)).check_update(update) - update.message.forward_date = None + update.message.forward_origin = None assert not (filters.TEXT & (filters.FORWARDED | filters.StatusUpdate.ALL)).check_update( update ) @@ -2010,8 +2306,7 @@ def test_and_or_filters(self, update): assert ( str(filters.TEXT & (filters.FORWARDED | filters.Entity(MessageEntity.MENTION))) - == ">" + == ">" ) def test_xor_filters(self, update): @@ -2032,16 +2327,16 @@ def test_xor_filters_repr(self, update): with pytest.raises(RuntimeError, match="Cannot set name"): (filters.TEXT ^ filters.User(123)).name = "foo" - def test_and_xor_filters(self, update): + def test_and_xor_filters(self, update, message_origin_user): update.message.text = "test" - update.message.forward_date = datetime.datetime.utcnow() + update.message.forward_origin = message_origin_user assert (filters.FORWARDED & (filters.TEXT ^ filters.User(123))).check_update(update) update.message.text = None update.effective_user.id = 123 assert (filters.FORWARDED & (filters.TEXT ^ filters.User(123))).check_update(update) update.message.text = "test" assert not (filters.FORWARDED & (filters.TEXT ^ filters.User(123))).check_update(update) - update.message.forward_date = None + update.message.forward_origin = None update.message.text = None update.effective_user.id = 123 assert not (filters.FORWARDED & (filters.TEXT ^ filters.User(123))).check_update(update) @@ -2051,23 +2346,22 @@ def test_and_xor_filters(self, update): assert ( str(filters.FORWARDED & (filters.TEXT ^ filters.User(123))) - == ">" + == ">" ) - def test_xor_regex_filters(self, update): + def test_xor_regex_filters(self, update, message_origin_user): sre_type = type(re.match("", "")) update.message.text = "test" - update.message.forward_date = datetime.datetime.utcnow() + update.message.forward_origin = message_origin_user assert not (filters.FORWARDED ^ filters.Regex("^test$")).check_update(update) - update.message.forward_date = None + update.message.forward_origin = None result = (filters.FORWARDED ^ filters.Regex("^test$")).check_update(update) assert result assert isinstance(result, dict) matches = result["matches"] assert isinstance(matches, list) assert type(matches[0]) is sre_type - update.message.forward_date = datetime.datetime.utcnow() + update.message.forward_origin = message_origin_user update.message.text = None assert (filters.FORWARDED ^ filters.Regex("^test$")).check_update(update) is True @@ -2086,15 +2380,15 @@ def test_inverted_filters_repr(self, update): with pytest.raises(RuntimeError, match="Cannot set name"): (~filters.TEXT).name = "foo" - def test_inverted_and_filters(self, update): + def test_inverted_and_filters(self, update, message_origin_user): update.message.text = "/test" update.message.entities = [MessageEntity(MessageEntity.BOT_COMMAND, 0, 5)] - update.message.forward_date = 1 + update.message.forward_origin = message_origin_user assert (filters.FORWARDED & filters.COMMAND).check_update(update) assert not (~filters.FORWARDED & filters.COMMAND).check_update(update) assert not (filters.FORWARDED & ~filters.COMMAND).check_update(update) assert not (~(filters.FORWARDED & filters.COMMAND)).check_update(update) - update.message.forward_date = None + update.message.forward_origin = None assert not (filters.FORWARDED & filters.COMMAND).check_update(update) assert (~filters.FORWARDED & filters.COMMAND).check_update(update) assert not (filters.FORWARDED & ~filters.COMMAND).check_update(update) @@ -2137,6 +2431,9 @@ def test_update_type_message(self, update): assert not filters.UpdateType.EDITED_CHANNEL_POST.check_update(update) assert not filters.UpdateType.CHANNEL_POSTS.check_update(update) assert not filters.UpdateType.EDITED.check_update(update) + assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) + assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) def test_update_type_edited_message(self, update): update.edited_message, update.message = update.message, update.edited_message @@ -2147,6 +2444,9 @@ def test_update_type_edited_message(self, update): assert not filters.UpdateType.EDITED_CHANNEL_POST.check_update(update) assert not filters.UpdateType.CHANNEL_POSTS.check_update(update) assert filters.UpdateType.EDITED.check_update(update) + assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) + assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) def test_update_type_channel_post(self, update): update.channel_post, update.message = update.message, update.edited_message @@ -2157,6 +2457,9 @@ def test_update_type_channel_post(self, update): assert not filters.UpdateType.EDITED_CHANNEL_POST.check_update(update) assert filters.UpdateType.CHANNEL_POSTS.check_update(update) assert not filters.UpdateType.EDITED.check_update(update) + assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) + assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) def test_update_type_edited_channel_post(self, update): update.edited_channel_post, update.message = update.message, update.edited_message @@ -2167,6 +2470,35 @@ def test_update_type_edited_channel_post(self, update): assert filters.UpdateType.EDITED_CHANNEL_POST.check_update(update) assert filters.UpdateType.CHANNEL_POSTS.check_update(update) assert filters.UpdateType.EDITED.check_update(update) + assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) + assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + + def test_update_type_business_message(self, update): + update.business_message, update.message = update.message, update.edited_message + assert not filters.UpdateType.MESSAGE.check_update(update) + assert not filters.UpdateType.EDITED_MESSAGE.check_update(update) + assert not filters.UpdateType.MESSAGES.check_update(update) + assert not filters.UpdateType.CHANNEL_POST.check_update(update) + assert not filters.UpdateType.EDITED_CHANNEL_POST.check_update(update) + assert not filters.UpdateType.CHANNEL_POSTS.check_update(update) + assert not filters.UpdateType.EDITED.check_update(update) + assert filters.UpdateType.BUSINESS_MESSAGES.check_update(update) + assert filters.UpdateType.BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + + def test_update_type_edited_business_message(self, update): + update.edited_business_message, update.message = update.message, update.edited_message + assert not filters.UpdateType.MESSAGE.check_update(update) + assert not filters.UpdateType.EDITED_MESSAGE.check_update(update) + assert not filters.UpdateType.MESSAGES.check_update(update) + assert not filters.UpdateType.CHANNEL_POST.check_update(update) + assert not filters.UpdateType.EDITED_CHANNEL_POST.check_update(update) + assert not filters.UpdateType.CHANNEL_POSTS.check_update(update) + assert filters.UpdateType.EDITED.check_update(update) + assert filters.UpdateType.BUSINESS_MESSAGES.check_update(update) + assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) + assert filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) def test_merged_short_circuit_and(self, update, base_class): update.message.text = "/test" @@ -2412,9 +2744,120 @@ def test_filters_attachment(self, update): 0, Message( 0, - datetime.datetime.utcnow(), + dtm.datetime.utcnow(), Chat(0, "private"), document=Document("str", "other_str"), ), ) assert filters.ATTACHMENT.check_update(up) + + def test_filters_mention_no_entities(self, update): + update.message.text = "test" + assert not filters.Mention("@test").check_update(update) + assert not filters.Mention(123456).check_update(update) + assert not filters.Mention("123456").check_update(update) + assert not filters.Mention(User(1, "first_name", False)).check_update(update) + assert not filters.Mention( + ["@test", 123456, "123456", User(1, "first_name", False)] + ).check_update(update) + + def test_filters_mention_type_mention(self, update): + update.message.text = "@test1 @test2 user" + update.message.entities = [ + MessageEntity(MessageEntity.MENTION, 0, 6), + MessageEntity(MessageEntity.MENTION, 7, 6), + ] + + user_no_username = User(123456, "first_name", False) + user_wrong_username = User(123456, "first_name", False, username="wrong") + user_1 = User(111, "first_name", False, username="test1") + user_2 = User(222, "first_name", False, username="test2") + + for username in ("@test1", "@test2"): + assert filters.Mention(username).check_update(update) + assert filters.Mention({username}).check_update(update) + + for user in (user_1, user_2): + assert filters.Mention(user).check_update(update) + assert filters.Mention({user}).check_update(update) + + assert not filters.Mention( + ["@test3", 123, user_no_username, user_wrong_username] + ).check_update(update) + + def test_filters_mention_type_text_mention(self, update): + user_1 = User(111, "first_name", False, username="test1") + user_2 = User(222, "first_name", False, username="test2") + user_no_username = User(123456, "first_name", False) + user_wrong_username = User(123456, "first_name", False, username="wrong") + + update.message.text = "test1 test2 user" + update.message.entities = [ + MessageEntity(MessageEntity.TEXT_MENTION, 0, 5, user=user_1), + MessageEntity(MessageEntity.TEXT_MENTION, 6, 5, user=user_2), + ] + + for username in ("@test1", "@test2"): + assert filters.Mention(username).check_update(update) + assert filters.Mention({username}).check_update(update) + + for user in (user_1, user_2): + assert filters.Mention(user).check_update(update) + assert filters.Mention({user}).check_update(update) + + for user_id in (111, 222): + assert filters.Mention(user_id).check_update(update) + assert filters.Mention({user_id}).check_update(update) + + assert not filters.Mention( + ["@test3", 123, user_no_username, user_wrong_username] + ).check_update(update) + + def test_filters_giveaway(self, update): + assert not filters.GIVEAWAY.check_update(update) + + update.message.giveaway = "test" + assert filters.GIVEAWAY.check_update(update) + assert str(filters.GIVEAWAY) == "filters.GIVEAWAY" + + def test_filters_giveaway_winners(self, update): + assert not filters.GIVEAWAY_WINNERS.check_update(update) + + update.message.giveaway_winners = "test" + assert filters.GIVEAWAY_WINNERS.check_update(update) + assert str(filters.GIVEAWAY_WINNERS) == "filters.GIVEAWAY_WINNERS" + + def test_filters_reply_to_story(self, update): + assert not filters.REPLY_TO_STORY.check_update(update) + + update.message.reply_to_story = "test" + assert filters.REPLY_TO_STORY.check_update(update) + assert str(filters.REPLY_TO_STORY) == "filters.REPLY_TO_STORY" + + def test_filters_boost_added(self, update): + assert not filters.BOOST_ADDED.check_update(update) + + update.message.boost_added = "test" + assert filters.BOOST_ADDED.check_update(update) + assert str(filters.BOOST_ADDED) == "filters.BOOST_ADDED" + + def test_filters_sender_boost_count(self, update): + assert not filters.SENDER_BOOST_COUNT.check_update(update) + + update.message.sender_boost_count = "test" + assert filters.SENDER_BOOST_COUNT.check_update(update) + assert str(filters.SENDER_BOOST_COUNT) == "filters.SENDER_BOOST_COUNT" + + def test_filters_checklist(self, update): + assert not filters.CHECKLIST.check_update(update) + + update.message.checklist = "test" + assert filters.CHECKLIST.check_update(update) + assert str(filters.CHECKLIST) == "filters.CHECKLIST" + + def test_filters_direct_messages(self, update): + assert not filters.DIRECT_MESSAGES.check_update(update) + + update.message.chat.is_direct_messages = True + assert filters.DIRECT_MESSAGES.check_update(update) + assert str(filters.DIRECT_MESSAGES) == "filters.DIRECT_MESSAGES" diff --git a/tests/_inline/test_inlinequeryhandler.py b/tests/ext/test_inlinequeryhandler.py similarity index 88% rename from tests/_inline/test_inlinequeryhandler.py rename to tests/ext/test_inlinequeryhandler.py index 0e50666a477..a1dd87fdc35 100644 --- a/tests/_inline/test_inlinequeryhandler.py +++ b/tests/ext/test_inlinequeryhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -68,7 +68,7 @@ def false_update(request): return Update(update_id=2, **request.param) -@pytest.fixture() +@pytest.fixture def inline_query(bot): update = Update( 0, @@ -152,6 +152,28 @@ async def test_context_pattern(self, app, inline_query): update.inline_query.query = "not_a_match" assert not handler.check_update(update) + @pytest.mark.parametrize( + ("query", "expected_result"), + [ + pytest.param("", True, id="empty string"), + pytest.param("not empty", False, id="non_empty_string"), + ], + ) + async def test_empty_inline_query_pattern(self, app, query, expected_result): + handler = InlineQueryHandler(self.callback, pattern=r"^$") + app.add_handler(handler) + + update = Update( + update_id=0, + inline_query=InlineQuery( + id="id", from_user=User(1, "test", False), query=query, offset="" + ), + ) + + async with app: + await app.process_update(update) + assert self.test_flag == expected_result + @pytest.mark.parametrize("chat_types", [[Chat.SENDER], [Chat.SENDER, Chat.SUPERGROUP], []]) @pytest.mark.parametrize( ("chat_type", "result"), [(Chat.SENDER, True), (Chat.CHANNEL, False), (None, False)] diff --git a/tests/ext/test_jobqueue.py b/tests/ext/test_jobqueue.py index e95feaf3286..50a78f5f3dc 100644 --- a/tests/ext/test_jobqueue.py +++ b/tests/ext/test_jobqueue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,16 +18,24 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. import asyncio import calendar +import contextlib import datetime as dtm import logging import platform +import re import time import pytest -from telegram.ext import ApplicationBuilder, CallbackContext, ContextTypes, Job, JobQueue -from telegram.warnings import PTBUserWarning -from tests.auxil.envvars import GITHUB_ACTION, TEST_WITH_OPT_DEPS +from telegram.ext import ( + ApplicationBuilder, + CallbackContext, + ContextTypes, + Defaults, + Job, + JobQueue, +) +from tests.auxil.envvars import GITHUB_ACTIONS, TEST_WITH_OPT_DEPS from tests.auxil.pytest_classes import make_bot from tests.auxil.slots import mro_slots @@ -36,16 +44,14 @@ UTC = pytz.utc else: - import datetime - - UTC = datetime.timezone.utc + UTC = dtm.timezone.utc class CustomContext(CallbackContext): pass -@pytest.fixture() +@pytest.fixture async def job_queue(app): jq = JobQueue() jq.set_application(app) @@ -71,7 +77,7 @@ def test_init_job(self): not TEST_WITH_OPT_DEPS, reason="Only relevant if the optional dependency is installed" ) @pytest.mark.skipif( - bool(GITHUB_ACTION and platform.system() in ["Windows", "Darwin"]), + GITHUB_ACTIONS and platform.system() in ["Windows", "Darwin"], reason="On Windows & MacOS precise timings are not accurate.", ) @pytest.mark.flaky(10, 1) # Timings aren't quite perfect @@ -80,10 +86,27 @@ class TestJobQueue: job_time = 0 received_error = None - expected_warning = ( - "Prior to v20.0 the `days` parameter was not aligned to that of cron's weekday scheme." - " We recommend double checking if the passed value is correct." - ) + @staticmethod + def normalize(datetime: dtm.datetime, timezone: dtm.tzinfo) -> dtm.datetime: + with contextlib.suppress(AttributeError): + return timezone.normalize(datetime) + + return datetime + + async def test_repr(self, app): + jq = JobQueue() + jq.set_application(app) + assert repr(jq) == f"JobQueue[application={app!r}]" + + when = dtm.datetime.utcnow() + dtm.timedelta(days=1) + callback = self.job_run_once + job = jq.run_once(callback, when, name="name2") + assert repr(job) == ( + f"Job[id={job.job.id}, name={job.name}, callback=job_run_once, " + f"trigger=date[" + f"{when.strftime('%Y-%m-%d %H:%M:%S UTC')}" + f"]]" + ) @pytest.fixture(autouse=True) def _reset(self): @@ -91,6 +114,15 @@ def _reset(self): self.job_time = 0 self.received_error = None + def test_scheduler_configuration(self, job_queue, timezone, bot): + # Unfortunately, we can't really test the executor setting explicitly without relying + # on protected attributes. However, this should be tested enough implicitly via all the + # other tests in here + assert job_queue.scheduler_configuration["timezone"] is dtm.timezone.utc + + tz_app = ApplicationBuilder().defaults(Defaults(tzinfo=timezone)).token(bot.token).build() + assert tz_app.job_queue.scheduler_configuration["timezone"] is timezone + async def job_run_once(self, context): if ( isinstance(context, CallbackContext) @@ -162,6 +194,49 @@ async def test_job_with_data(self, job_queue): await asyncio.sleep(0.2) assert self.result == 5 + def test_callback_name_without_name_attribute(self, app): + """Test that callable class instances work as job callbacks (issue #4992)""" + + class CallableJob: + async def __call__(self, context: ContextTypes.DEFAULT_TYPE): + pass + + jq = JobQueue() + jq.set_application(app) + + # This should not raise AttributeError + job_instance = CallableJob() + + # Test with run_once + job = jq.run_once(job_instance, 10) + # The job name should be the class name, not raise AttributeError + assert job.name == "CallableJob", f"Expected 'CallableJob', got '{job.name}'" + + # Test with run_repeating (the method from the issue) + job2 = jq.run_repeating(job_instance, 0.5) + assert job2.name == "CallableJob", f"Expected 'CallableJob', got '{job2.name}'" + + # Test with run_monthly + when = dtm.time(12, 0, 0) + job3 = jq.run_monthly(job_instance, when, 1) + assert job3.name == "CallableJob", f"Expected 'CallableJob', got '{job3.name}'" + + # Test with run_daily + job4 = jq.run_daily(job_instance, when) + assert job4.name == "CallableJob", f"Expected 'CallableJob', got '{job4.name}'" + + # Test with run_custom + job5 = jq.run_custom( + job_instance, + {"trigger": "date", "run_date": dtm.datetime.now() + dtm.timedelta(seconds=10)}, + ) + assert job5.name == "CallableJob", f"Expected 'CallableJob', got '{job5.name}'" + + # Test Job.__repr__ uses the correct name + assert "callback=CallableJob" in repr(job), ( + f"repr should contain 'callback=CallableJob', got: {job!r}" + ) + async def test_run_repeating(self, job_queue): job_queue.run_repeating(self.job_run_once, 0.1) await asyncio.sleep(0.25) @@ -340,6 +415,17 @@ async def test_time_unit_dt_time_tomorrow(self, job_queue): scheduled_time = job_queue.jobs()[0].next_t.timestamp() assert scheduled_time == pytest.approx(expected_time) + async def test_time_unit_dt_aware_time(self, job_queue, timezone): + # Testing running at a specific tz-aware time today + delta, now = 0.5, dtm.datetime.now(timezone) + expected_time = now + dtm.timedelta(seconds=delta) + when = expected_time.timetz() + expected_time = expected_time.timestamp() + + job_queue.run_once(self.job_datetime_tests, when) + await asyncio.sleep(0.6) + assert self.job_time == pytest.approx(expected_time) + async def test_run_daily(self, job_queue): delta, now = 1, dtm.datetime.now(UTC) time_of_day = (now + dtm.timedelta(seconds=delta)).time() @@ -351,20 +437,8 @@ async def test_run_daily(self, job_queue): scheduled_time = job_queue.jobs()[0].next_t.timestamp() assert scheduled_time == pytest.approx(expected_reschedule_time) - async def test_run_daily_warning(self, job_queue, recwarn): - delta, now = 1, dtm.datetime.now(UTC) - time_of_day = (now + dtm.timedelta(seconds=delta)).time() - - job_queue.run_daily(self.job_run_once, time_of_day) - assert len(recwarn) == 0 - job_queue.run_daily(self.job_run_once, time_of_day, days=(0, 1, 2, 3)) - assert len(recwarn) == 1 - assert str(recwarn[0].message) == self.expected_warning - assert recwarn[0].category is PTBUserWarning - assert recwarn[0].filename == __file__, "wrong stacklevel" - @pytest.mark.parametrize("weekday", [0, 1, 2, 3, 4, 5, 6]) - async def test_run_daily_days_of_week(self, job_queue, recwarn, weekday): + async def test_run_daily_days_of_week(self, job_queue, weekday): delta, now = 1, dtm.datetime.now(UTC) time_of_day = (now + dtm.timedelta(seconds=delta)).time() # offset in days until next weekday @@ -376,10 +450,6 @@ async def test_run_daily_days_of_week(self, job_queue, recwarn, weekday): await asyncio.sleep(delta + 0.1) scheduled_time = job_queue.jobs()[0].next_t.timestamp() assert scheduled_time == pytest.approx(expected_reschedule_time) - assert len(recwarn) == 1 - assert str(recwarn[0].message) == self.expected_warning - assert recwarn[0].category is PTBUserWarning - assert recwarn[0].filename == __file__, "wrong stacklevel" async def test_run_monthly(self, job_queue, timezone): delta, now = 1, dtm.datetime.now(timezone) @@ -397,7 +467,7 @@ async def test_run_monthly(self, job_queue, timezone): if day > next_months_days: expected_reschedule_time += dtm.timedelta(next_months_days) - expected_reschedule_time = timezone.normalize(expected_reschedule_time) + expected_reschedule_time = self.normalize(expected_reschedule_time, timezone) # Adjust the hour for the special case that between now and next month a DST switch happens expected_reschedule_time += dtm.timedelta( hours=time_of_day.hour - expected_reschedule_time.hour @@ -419,7 +489,7 @@ async def test_run_monthly_non_strict_day(self, job_queue, timezone): calendar.monthrange(now.year, now.month)[1] ) - dtm.timedelta(days=now.day) # Adjust the hour for the special case that between now & end of month a DST switch happens - expected_reschedule_time = timezone.normalize(expected_reschedule_time) + expected_reschedule_time = self.normalize(expected_reschedule_time, timezone) expected_reschedule_time += dtm.timedelta( hours=time_of_day.hour - expected_reschedule_time.hour ) @@ -443,19 +513,34 @@ async def test_default_tzinfo(self, tz_bot): await jq.stop() - async def test_get_jobs(self, job_queue): + @pytest.mark.parametrize( + "pattern", [None, "not", re.compile("not")], ids=["None", "str", "re.Pattern"] + ) + async def test_get_jobs(self, job_queue, pattern): callback = self.job_run_once - job1 = job_queue.run_once(callback, 10, name="name1") + job1 = job_queue.run_once(callback, 10, name="is|a|match") await asyncio.sleep(0.03) # To stablize tests on windows - job2 = job_queue.run_once(callback, 10, name="name1") + job2 = job_queue.run_once(callback, 10, name="is|a|match") + await asyncio.sleep(0.03) + job3 = job_queue.run_once(callback, 10, name="not|is|a|match") + await asyncio.sleep(0.03) + job4 = job_queue.run_once(callback, 10, name="is|a|match|not") await asyncio.sleep(0.03) - job3 = job_queue.run_once(callback, 10, name="name2") + job5 = job_queue.run_once(callback, 10, name="something_else") await asyncio.sleep(0.03) + # name-less job + job6 = job_queue.run_once(callback, 10) + await asyncio.sleep(0.03) + + if pattern is None: + assert job_queue.jobs(pattern) == (job1, job2, job3, job4, job5, job6) + else: + assert job_queue.jobs(pattern) == (job3, job4) - assert job_queue.jobs() == (job1, job2, job3) - assert job_queue.get_jobs_by_name("name1") == (job1, job2) - assert job_queue.get_jobs_by_name("name2") == (job3,) + assert job_queue.jobs() == (job1, job2, job3, job4, job5, job6) + assert job_queue.get_jobs_by_name("is|a|match") == (job1, job2) + assert job_queue.get_jobs_by_name("something_else") == (job5,) async def test_job_run(self, app): job = app.job_queue.run_repeating(self.job_run_once, 0.02) @@ -492,7 +577,7 @@ async def test_equality(self, job_queue): job_2 = job_queue.run_repeating(self.job_run_once, 0.2) job_3 = Job(self.job_run_once, 0.2) job_3._job = job.job - assert job == job + assert job == job # noqa: PLR0124 assert job != job_queue assert job != job_2 assert job == job_3 @@ -563,7 +648,7 @@ async def test_process_error_that_raises_errors(self, job_queue, app, caplog): assert rec.name == "telegram.ext.Application" assert "No error handlers are registered" in rec.getMessage() - async def test_custom_context(self, bot, job_queue): + async def test_custom_context(self, bot): application = ( ApplicationBuilder() .token(bot.token) @@ -574,6 +659,7 @@ async def test_custom_context(self, bot, job_queue): ) .build() ) + job_queue = JobQueue() job_queue.set_application(application) async def callback(context): @@ -584,14 +670,16 @@ async def callback(context): type(context.bot_data), ) + await job_queue.start() job_queue.run_once(callback, 0.1) await asyncio.sleep(0.15) assert self.result == (CustomContext, None, None, int) + await job_queue.stop() async def test_attribute_error(self): job = Job(self.job_run_once) with pytest.raises( - AttributeError, match="nor 'apscheduler.job.Job' has attribute 'error'" + AttributeError, match="nor 'apscheduler\\.job\\.Job' has attribute 'error'" ): job.error @@ -641,3 +729,44 @@ async def test_from_aps_job_missing_reference(self, job_queue): tg_job = Job.from_aps_job(aps_job) assert tg_job is job assert tg_job.job is aps_job + + async def test_run_job_exception_in_building_context( + self, monkeypatch, job_queue, caplog, app + ): + # Makes sure that exceptions in building the context don't stop the application + exception = ValueError("TestException") + original_from_job = CallbackContext.from_job + + def raise_exception(job, application): + if job.data == 1: + raise exception + return original_from_job(job, application) + + monkeypatch.setattr(CallbackContext, "from_job", raise_exception) + + received_jobs = set() + + async def job_callback(context): + received_jobs.add(context.job.data) + + with caplog.at_level(logging.CRITICAL): + job_queue.run_once(job_callback, 0.1, data=1) + await asyncio.sleep(0.2) + + assert received_jobs == set() + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.name == "telegram.ext.JobQueue" + assert record.getMessage().startswith( + "Error while building CallbackContext for job job_callback" + ) + assert record.levelno == logging.CRITICAL + + # Let's also check that no critical log is produced when the exception is not raised + caplog.clear() + with caplog.at_level(logging.CRITICAL): + job_queue.run_once(job_callback, 0.1, data=2) + await asyncio.sleep(0.2) + + assert received_jobs == {2} + assert len(caplog.records) == 0 diff --git a/tests/ext/test_messagehandler.py b/tests/ext/test_messagehandler.py index e521c6ac86e..aeb2e1b0391 100644 --- a/tests/ext/test_messagehandler.py +++ b/tests/ext/test_messagehandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_messagereactionhandler.py b/tests/ext/test_messagereactionhandler.py new file mode 100644 index 00000000000..7de7f135ea6 --- /dev/null +++ b/tests/ext/test_messagereactionhandler.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 asyncio +import datetime as dtm + +import pytest + +from telegram import ( + Bot, + CallbackQuery, + Chat, + ChosenInlineResult, + Message, + MessageReactionCountUpdated, + MessageReactionUpdated, + PreCheckoutQuery, + ReactionCount, + ReactionTypeEmoji, + ShippingQuery, + Update, + User, +) +from telegram._utils.datetime import UTC +from telegram.ext import CallbackContext, JobQueue, MessageReactionHandler +from tests.auxil.slots import mro_slots + +message = Message(1, None, Chat(1, ""), from_user=User(1, "", False), text="Text") + +params = [ + {"message": message}, + {"edited_message": message}, + {"callback_query": CallbackQuery(1, User(1, "", False), "chat", message=message)}, + {"channel_post": message}, + {"edited_channel_post": message}, + {"chosen_inline_result": ChosenInlineResult("id", User(1, "", False), "")}, + {"shipping_query": ShippingQuery("id", User(1, "", False), "", None)}, + {"pre_checkout_query": PreCheckoutQuery("id", User(1, "", False), "", 0, "")}, + {"callback_query": CallbackQuery(1, User(1, "", False), "chat")}, +] + +ids = ( + "message", + "edited_message", + "callback_query", + "channel_post", + "edited_channel_post", + "chosen_inline_result", + "shipping_query", + "pre_checkout_query", + "callback_query_without_message", +) + + +@pytest.fixture(scope="class", params=params, ids=ids) +def false_update(request): + return Update(update_id=2, **request.param) + + +@pytest.fixture(scope="class") +def time(): + return dtm.datetime.now(tz=UTC) + + +@pytest.fixture(scope="class") +def message_reaction_updated(time, bot): + mr = MessageReactionUpdated( + chat=Chat(1, Chat.SUPERGROUP), + message_id=1, + date=time, + old_reaction=[ReactionTypeEmoji("👍")], + new_reaction=[ReactionTypeEmoji("👎")], + user=User(1, "user_a", False), + actor_chat=Chat(2, Chat.SUPERGROUP), + ) + mr.set_bot(bot) + mr._unfreeze() + mr.chat._unfreeze() + mr.user._unfreeze() + return mr + + +@pytest.fixture(scope="class") +def message_reaction_count_updated(time, bot): + mr = MessageReactionCountUpdated( + chat=Chat(1, Chat.SUPERGROUP), + message_id=1, + date=time, + reactions=[ + ReactionCount(ReactionTypeEmoji("👍"), 1), + ReactionCount(ReactionTypeEmoji("👎"), 1), + ], + ) + mr.set_bot(bot) + mr._unfreeze() + mr.chat._unfreeze() + return mr + + +@pytest.fixture +def message_reaction_update(bot, message_reaction_updated): + return Update(0, message_reaction=message_reaction_updated) + + +@pytest.fixture +def message_reaction_count_update(bot, message_reaction_count_updated): + return Update(0, message_reaction_count=message_reaction_count_updated) + + +class TestMessageReactionHandler: + test_flag = False + + def test_slot_behaviour(self): + action = MessageReactionHandler(self.callback) + for attr in action.__slots__: + assert getattr(action, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" + + @pytest.fixture(autouse=True) + def _reset(self): + self.test_flag = False + + async def callback(self, update: Update, context): + self.test_flag = ( + isinstance(context, CallbackContext) + and isinstance(context.bot, Bot) + and isinstance(update, Update) + and isinstance(context.update_queue, asyncio.Queue) + and isinstance(context.job_queue, JobQueue) + and isinstance(context.user_data, dict if update.effective_user else type(None)) + and isinstance(context.chat_data, dict) + and isinstance(context.bot_data, dict) + and ( + isinstance( + update.message_reaction, + MessageReactionUpdated, + ) + or isinstance(update.message_reaction_count, MessageReactionCountUpdated) + ) + ) + + def test_other_update_types(self, false_update): + handler = MessageReactionHandler(self.callback) + assert not handler.check_update(false_update) + assert not handler.check_update(True) + + async def test_context(self, app, message_reaction_update, message_reaction_count_update): + handler = MessageReactionHandler(callback=self.callback) + app.add_handler(handler) + + async with app: + assert handler.check_update(message_reaction_update) + await app.process_update(message_reaction_update) + assert self.test_flag + + self.test_flag = False + await app.process_update(message_reaction_count_update) + assert self.test_flag + + @pytest.mark.parametrize( + argnames=("allowed_types", "expected"), + argvalues=[ + (MessageReactionHandler.MESSAGE_REACTION_UPDATED, (True, False)), + (MessageReactionHandler.MESSAGE_REACTION_COUNT_UPDATED, (False, True)), + (MessageReactionHandler.MESSAGE_REACTION, (True, True)), + ], + ids=["MESSAGE_REACTION_UPDATED", "MESSAGE_REACTION_COUNT_UPDATED", "MESSAGE_REACTION"], + ) + async def test_message_reaction_types( + self, app, message_reaction_update, message_reaction_count_update, expected, allowed_types + ): + result_1, result_2 = expected + + handler = MessageReactionHandler(self.callback, message_reaction_types=allowed_types) + app.add_handler(handler) + + async with app: + assert handler.check_update(message_reaction_update) == result_1 + await app.process_update(message_reaction_update) + assert self.test_flag == result_1 + + self.test_flag = False + + assert handler.check_update(message_reaction_count_update) == result_2 + await app.process_update(message_reaction_count_update) + assert self.test_flag == result_2 + + @pytest.mark.parametrize( + argnames=("allowed_types", "kwargs"), + argvalues=[ + (MessageReactionHandler.MESSAGE_REACTION_COUNT_UPDATED, {"user_username": "user"}), + (MessageReactionHandler.MESSAGE_REACTION, {"user_id": 123}), + ], + ids=["MESSAGE_REACTION_COUNT_UPDATED", "MESSAGE_REACTION"], + ) + async def test_username_with_anonymous_reaction(self, app, allowed_types, kwargs): + with pytest.raises( + ValueError, match="You can not filter for users and include anonymous reactions\\." + ): + MessageReactionHandler(self.callback, message_reaction_types=allowed_types, **kwargs) + + @pytest.mark.parametrize( + argnames=("chat_id", "expected"), + argvalues=[(1, True), ([1], True), (2, False), ([2], False)], + ) + async def test_with_chat_ids( + self, chat_id, expected, message_reaction_update, message_reaction_count_update + ): + handler = MessageReactionHandler(self.callback, chat_id=chat_id) + assert handler.check_update(message_reaction_update) == expected + assert handler.check_update(message_reaction_count_update) == expected + + @pytest.mark.parametrize( + argnames="chat_username", + argvalues=["group_a", "@group_a", ["group_a"], ["@group_a"]], + ids=["group_a", "@group_a", "['group_a']", "['@group_a']"], + ) + async def test_with_chat_usernames( + self, chat_username, message_reaction_update, message_reaction_count_update + ): + handler = MessageReactionHandler(self.callback, chat_username=chat_username) + assert not handler.check_update(message_reaction_update) + assert not handler.check_update(message_reaction_count_update) + + message_reaction_update.message_reaction.chat.username = "group_a" + message_reaction_count_update.message_reaction_count.chat.username = "group_a" + + assert handler.check_update(message_reaction_update) + assert handler.check_update(message_reaction_count_update) + + message_reaction_update.message_reaction.chat.username = None + message_reaction_count_update.message_reaction_count.chat.username = None + + @pytest.mark.parametrize( + argnames=("user_id", "expected"), + argvalues=[(1, True), ([1], True), (2, False), ([2], False)], + ) + async def test_with_user_ids( + self, user_id, expected, message_reaction_update, message_reaction_count_update + ): + handler = MessageReactionHandler( + self.callback, + user_id=user_id, + message_reaction_types=MessageReactionHandler.MESSAGE_REACTION_UPDATED, + ) + assert handler.check_update(message_reaction_update) == expected + assert not handler.check_update(message_reaction_count_update) + + @pytest.mark.parametrize( + argnames="user_username", + argvalues=["user_a", "@user_a", ["user_a"], ["@user_a"]], + ids=["user_a", "@user_a", "['user_a']", "['@user_a']"], + ) + async def test_with_user_usernames( + self, user_username, message_reaction_update, message_reaction_count_update + ): + handler = MessageReactionHandler( + self.callback, + user_username=user_username, + message_reaction_types=MessageReactionHandler.MESSAGE_REACTION_UPDATED, + ) + assert not handler.check_update(message_reaction_update) + assert not handler.check_update(message_reaction_count_update) + + message_reaction_update.message_reaction.user.username = "user_a" + + assert handler.check_update(message_reaction_update) + assert not handler.check_update(message_reaction_count_update) + + message_reaction_update.message_reaction.user.username = None + + async def test_message_reaction_count_with_combination( + self, message_reaction_count_update, message_reaction_update + ): + handler = MessageReactionHandler( + self.callback, + chat_id=2, + chat_username="group_a", + message_reaction_types=MessageReactionHandler.MESSAGE_REACTION, + ) + assert not handler.check_update(message_reaction_count_update) + + message_reaction_count_update.message_reaction_count.chat.id = 2 + message_reaction_update.message_reaction.chat.id = 2 + assert handler.check_update(message_reaction_count_update) + assert handler.check_update(message_reaction_update) + message_reaction_count_update.message_reaction_count.chat.id = 1 + message_reaction_update.message_reaction.chat.id = 1 + + message_reaction_count_update.message_reaction_count.chat.username = "group_a" + message_reaction_update.message_reaction.chat.username = "group_a" + assert handler.check_update(message_reaction_count_update) + assert handler.check_update(message_reaction_update) + message_reaction_count_update.message_reaction_count.chat.username = None + message_reaction_update.message_reaction.chat.username = None + + async def test_message_reaction_with_combination(self, message_reaction_update): + handler = MessageReactionHandler( + self.callback, + chat_id=2, + chat_username="group_a", + user_id=2, + user_username="user_a", + message_reaction_types=MessageReactionHandler.MESSAGE_REACTION_UPDATED, + ) + assert not handler.check_update(message_reaction_update) + + message_reaction_update.message_reaction.chat.id = 2 + assert handler.check_update(message_reaction_update) + message_reaction_update.message_reaction.chat.id = 1 + + message_reaction_update.message_reaction.chat.username = "group_a" + assert handler.check_update(message_reaction_update) + message_reaction_update.message_reaction.chat.username = None + + message_reaction_update.message_reaction.user.id = 2 + assert handler.check_update(message_reaction_update) + message_reaction_update.message_reaction.user.id = 1 + + message_reaction_update.message_reaction.user.username = "user_a" + assert handler.check_update(message_reaction_update) + message_reaction_update.message_reaction.user.username = None diff --git a/tests/ext/test_paidmediapurchasedhandler.py b/tests/ext/test_paidmediapurchasedhandler.py new file mode 100644 index 00000000000..059c8e264ca --- /dev/null +++ b/tests/ext/test_paidmediapurchasedhandler.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 asyncio +import datetime as dtm + +import pytest + +from telegram import ( + Bot, + CallbackQuery, + Chat, + ChosenInlineResult, + Message, + PaidMediaPurchased, + PreCheckoutQuery, + ShippingQuery, + Update, + User, +) +from telegram._utils.datetime import UTC +from telegram.ext import CallbackContext, JobQueue, PaidMediaPurchasedHandler +from tests.auxil.slots import mro_slots + +message = Message(1, None, Chat(1, ""), from_user=User(1, "", False), text="Text") + +params = [ + {"message": message}, + {"edited_message": message}, + {"callback_query": CallbackQuery(1, User(1, "", False), "chat", message=message)}, + {"channel_post": message}, + {"edited_channel_post": message}, + {"chosen_inline_result": ChosenInlineResult("id", User(1, "", False), "")}, + {"shipping_query": ShippingQuery("id", User(1, "", False), "", None)}, + {"pre_checkout_query": PreCheckoutQuery("id", User(1, "", False), "", 0, "")}, + {"callback_query": CallbackQuery(1, User(1, "", False), "chat")}, +] + +ids = ( + "message", + "edited_message", + "callback_query", + "channel_post", + "edited_channel_post", + "chosen_inline_result", + "shipping_query", + "pre_checkout_query", + "callback_query_without_message", +) + + +@pytest.fixture(scope="class", params=params, ids=ids) +def false_update(request): + return Update(update_id=2, **request.param) + + +@pytest.fixture(scope="class") +def time(): + return dtm.datetime.now(tz=UTC) + + +@pytest.fixture(scope="class") +def purchased_paid_media(bot): + bc = PaidMediaPurchased( + from_user=User(1, "name", username="user_a", is_bot=False), + paid_media_payload="payload", + ) + bc.set_bot(bot) + return bc + + +@pytest.fixture +def purchased_paid_media_update(bot, purchased_paid_media): + return Update(0, purchased_paid_media=purchased_paid_media) + + +class TestPaidMediaPurchasedHandler: + test_flag = False + + def test_slot_behaviour(self): + action = PaidMediaPurchasedHandler(self.callback) + for attr in action.__slots__: + assert getattr(action, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" + + @pytest.fixture(autouse=True) + def _reset(self): + self.test_flag = False + + async def callback(self, update, context): + self.test_flag = ( + isinstance(context, CallbackContext) + and isinstance(context.bot, Bot) + and isinstance(update, Update) + and isinstance(context.update_queue, asyncio.Queue) + and isinstance(context.job_queue, JobQueue) + and isinstance(context.user_data, dict) + and isinstance(context.bot_data, dict) + and isinstance( + update.purchased_paid_media, + PaidMediaPurchased, + ) + ) + + def test_with_user_id(self, purchased_paid_media_update): + handler = PaidMediaPurchasedHandler(self.callback, user_id=1) + assert handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, user_id=[1]) + assert handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, user_id=2, username="@user_a") + assert handler.check_update(purchased_paid_media_update) + + handler = PaidMediaPurchasedHandler(self.callback, user_id=2) + assert not handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, user_id=[2]) + assert not handler.check_update(purchased_paid_media_update) + + def test_with_username(self, purchased_paid_media_update): + handler = PaidMediaPurchasedHandler(self.callback, username="user_a") + assert handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, username="@user_a") + assert handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, username=["user_a"]) + assert handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, username=["@user_a"]) + assert handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, user_id=1, username="@user_b") + assert handler.check_update(purchased_paid_media_update) + + handler = PaidMediaPurchasedHandler(self.callback, username="user_b") + assert not handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, username="@user_b") + assert not handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, username=["user_b"]) + assert not handler.check_update(purchased_paid_media_update) + handler = PaidMediaPurchasedHandler(self.callback, username=["@user_b"]) + assert not handler.check_update(purchased_paid_media_update) + + purchased_paid_media_update.purchased_paid_media.from_user._unfreeze() + purchased_paid_media_update.purchased_paid_media.from_user.username = None + assert not handler.check_update(purchased_paid_media_update) + + def test_other_update_types(self, false_update): + handler = PaidMediaPurchasedHandler(self.callback) + assert not handler.check_update(false_update) + assert not handler.check_update(True) + + async def test_context(self, app, purchased_paid_media_update): + handler = PaidMediaPurchasedHandler(callback=self.callback) + app.add_handler(handler) + + async with app: + await app.process_update(purchased_paid_media_update) + assert self.test_flag diff --git a/tests/ext/test_picklepersistence.py b/tests/ext/test_picklepersistence.py index c557cb21636..655d2047ae8 100644 --- a/tests/ext/test_picklepersistence.py +++ b/tests/ext/test_picklepersistence.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,10 +16,11 @@ # # 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 +import datetime as dtm import gzip import os import pickle +import sys from pathlib import Path import pytest @@ -27,7 +28,7 @@ from telegram import Chat, Message, TelegramObject, Update, User from telegram.ext import ContextTypes, PersistenceInput, PicklePersistence from telegram.warnings import PTBUserWarning -from tests.auxil.files import PROJECT_ROOT_PATH +from tests.auxil.files import SOURCE_ROOT_PATH from tests.auxil.pytest_classes import make_bot from tests.auxil.slots import mro_slots @@ -49,27 +50,27 @@ def _reset_callback_data_cache(cdc_bot): cdc_bot.callback_data_cache.clear_callback_queries() -@pytest.fixture() +@pytest.fixture def bot_data(): return {"test1": "test2", "test3": {"test4": "test5"}} -@pytest.fixture() +@pytest.fixture def chat_data(): return {-12345: {"test1": "test2", "test3": {"test4": "test5"}}, -67890: {3: "test4"}} -@pytest.fixture() +@pytest.fixture def user_data(): return {12345: {"test1": "test2", "test3": {"test4": "test5"}}, 67890: {3: "test4"}} -@pytest.fixture() +@pytest.fixture def callback_data(): return [("test1", 1000, {"button1": "test0", "button2": "test1"})], {"test1": "test2"} -@pytest.fixture() +@pytest.fixture def conversations(): return { "name1": {(123, 123): 3, (456, 654): 4}, @@ -78,7 +79,7 @@ def conversations(): } -@pytest.fixture() +@pytest.fixture def pickle_persistence(): return PicklePersistence( filepath="pickletest", @@ -87,7 +88,7 @@ def pickle_persistence(): ) -@pytest.fixture() +@pytest.fixture def pickle_persistence_only_bot(): return PicklePersistence( filepath="pickletest", @@ -97,7 +98,7 @@ def pickle_persistence_only_bot(): ) -@pytest.fixture() +@pytest.fixture def pickle_persistence_only_chat(): return PicklePersistence( filepath="pickletest", @@ -107,7 +108,7 @@ def pickle_persistence_only_chat(): ) -@pytest.fixture() +@pytest.fixture def pickle_persistence_only_user(): return PicklePersistence( filepath="pickletest", @@ -117,7 +118,7 @@ def pickle_persistence_only_user(): ) -@pytest.fixture() +@pytest.fixture def pickle_persistence_only_callback(): return PicklePersistence( filepath="pickletest", @@ -127,7 +128,7 @@ def pickle_persistence_only_callback(): ) -@pytest.fixture() +@pytest.fixture def bad_pickle_files(): for name in [ "pickletest_user_data", @@ -141,7 +142,7 @@ def bad_pickle_files(): return True -@pytest.fixture() +@pytest.fixture def invalid_pickle_files(): for name in [ "pickletest_user_data", @@ -158,7 +159,7 @@ def invalid_pickle_files(): return True -@pytest.fixture() +@pytest.fixture def good_pickle_files(user_data, chat_data, bot_data, callback_data, conversations): data = { "user_data": user_data, @@ -182,7 +183,7 @@ def good_pickle_files(user_data, chat_data, bot_data, callback_data, conversatio return True -@pytest.fixture() +@pytest.fixture def pickle_files_wo_bot_data(user_data, chat_data, callback_data, conversations): data = { "user_data": user_data, @@ -203,7 +204,7 @@ def pickle_files_wo_bot_data(user_data, chat_data, callback_data, conversations) return True -@pytest.fixture() +@pytest.fixture def pickle_files_wo_callback_data(user_data, chat_data, bot_data, conversations): data = { "user_data": user_data, @@ -224,11 +225,11 @@ def pickle_files_wo_callback_data(user_data, chat_data, bot_data, conversations) return True -@pytest.fixture() +@pytest.fixture def update(bot): user = User(id=321, first_name="test_user", is_bot=False) chat = Chat(id=123, type="group") - message = Message(1, datetime.datetime.now(), chat, from_user=user, text="Hi there") + message = Message(1, dtm.datetime.now(), chat, from_user=user, text="Hi there") message.set_bot(bot) return Update(0, message=message) @@ -245,7 +246,7 @@ def __init__(self, private, normal, b): self._bot = b class SlotsSub(TelegramObject): - __slots__ = ("new_var", "_private") + __slots__ = ("_private", "new_var") def __init__(self, new_var, private): super().__init__() @@ -288,7 +289,7 @@ async def test_on_flush(self, pickle_persistence, on_flush): async def test_pickle_behaviour_with_slots(self, pickle_persistence): bot_data = await pickle_persistence.get_bot_data() - bot_data["message"] = Message(3, datetime.datetime.now(), Chat(2, type="supergroup")) + bot_data["message"] = Message(3, dtm.datetime.now(), Chat(2, type="supergroup")) await pickle_persistence.update_bot_data(bot_data) retrieved = await pickle_persistence.get_bot_data() assert retrieved == bot_data @@ -871,8 +872,12 @@ async def test_custom_pickler_unpickler_simple( "A load persistent id instruction was encountered,\nbut no persistent_load " "function was specified." ) - with Path("pickletest_chat_data").open("rb") as f, pytest.raises( - pickle.UnpicklingError, match=err_msg + with ( + Path("pickletest_chat_data").open("rb") as f, + pytest.raises( + pickle.UnpicklingError, + match=err_msg if sys.version_info < (3, 12) else err_msg.replace("\n", " "), + ), ): pickle.load(f) @@ -893,10 +898,9 @@ async def test_custom_pickler_unpickler_simple( assert len(recwarn) == 1 assert recwarn[-1].category is PTBUserWarning assert str(recwarn[-1].message).startswith("Unknown bot instance found.") - assert ( - Path(recwarn[-1].filename) - == PROJECT_ROOT_PATH / "telegram" / "ext" / "_picklepersistence.py" - ), "wrong stacklevel!" + assert Path(recwarn[-1].filename) == SOURCE_ROOT_PATH / "ext" / "_picklepersistence.py", ( + "wrong stacklevel!" + ) pp = PicklePersistence("pickletest", single_file=False, on_flush=False) pp.set_bot(bot) assert (await pp.get_chat_data())[12345]["unknown_bot_in_user"]._bot is None @@ -991,7 +995,7 @@ async def test_no_write_if_data_did_not_change( await pickle_persistence.update_callback_data(callback_data) assert pickle_persistence.filepath.is_file() - pickle_persistence.filepath.unlink() + pickle_persistence.filepath.unlink(missing_ok=True) assert not pickle_persistence.filepath.is_file() await pickle_persistence.update_bot_data(bot_data) diff --git a/tests/ext/test_pollanswerhandler.py b/tests/ext/test_pollanswerhandler.py index 724e22f400f..8900f466486 100644 --- a/tests/ext/test_pollanswerhandler.py +++ b/tests/ext/test_pollanswerhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -67,9 +67,9 @@ def false_update(request): return Update(update_id=2, **request.param) -@pytest.fixture() +@pytest.fixture def poll_answer(bot): - return Update(0, poll_answer=PollAnswer(1, User(2, "test user", False), [0, 1])) + return Update(0, poll_answer=PollAnswer(1, [0, 1], User(2, "test user", False), Chat(1, ""))) class TestPollAnswerHandler: diff --git a/tests/test_pollhandler.py b/tests/ext/test_pollhandler.py similarity index 98% rename from tests/test_pollhandler.py rename to tests/ext/test_pollhandler.py index c83a0d41871..cb2864822fe 100644 --- a/tests/test_pollhandler.py +++ b/tests/ext/test_pollhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -68,7 +68,7 @@ def false_update(request): return Update(update_id=2, **request.param) -@pytest.fixture() +@pytest.fixture def poll(bot): return Update( 0, diff --git a/tests/ext/test_precheckoutqueryhandler.py b/tests/ext/test_precheckoutqueryhandler.py index c22e058ff54..ab558c499be 100644 --- a/tests/ext/test_precheckoutqueryhandler.py +++ b/tests/ext/test_precheckoutqueryhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -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/]. import asyncio +import re import pytest @@ -69,12 +70,15 @@ def false_update(request): @pytest.fixture(scope="class") def pre_checkout_query(): - return Update( + update = Update( 1, pre_checkout_query=PreCheckoutQuery( "id", User(1, "test user", False), "EUR", 223, "invoice_payload" ), ) + update._unfreeze() + update.pre_checkout_query._unfreeze() + return update class TestPreCheckoutQueryHandler: @@ -103,6 +107,23 @@ async def callback(self, update, context): and isinstance(update.pre_checkout_query, PreCheckoutQuery) ) + def test_with_pattern(self, pre_checkout_query): + handler = PreCheckoutQueryHandler(self.callback, pattern=".*voice.*") + + assert handler.check_update(pre_checkout_query) + + pre_checkout_query.pre_checkout_query.invoice_payload = "nothing here" + assert not handler.check_update(pre_checkout_query) + + def test_with_compiled_pattern(self, pre_checkout_query): + handler = PreCheckoutQueryHandler(self.callback, pattern=re.compile(r".*payload")) + + pre_checkout_query.pre_checkout_query.invoice_payload = "invoice_payload" + assert handler.check_update(pre_checkout_query) + + pre_checkout_query.pre_checkout_query.invoice_payload = "nothing here" + assert not handler.check_update(pre_checkout_query) + def test_other_update_types(self, false_update): handler = PreCheckoutQueryHandler(self.callback) assert not handler.check_update(false_update) diff --git a/tests/ext/test_prefixhandler.py b/tests/ext/test_prefixhandler.py index 65c8a412c24..f12592fa98f 100644 --- a/tests/ext/test_prefixhandler.py +++ b/tests/ext/test_prefixhandler.py @@ -1,6 +1,6 @@ # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,7 +25,7 @@ def combinations(prefixes, commands): - return (prefix + command for prefix in prefixes for command in commands) + return [prefix + command for prefix in prefixes for command in commands] class TestPrefixHandler(BaseTest): @@ -40,31 +40,31 @@ def test_slot_behaviour(self): assert getattr(handler, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(handler)) == len(set(mro_slots(handler))), "duplicate slot" - @pytest.fixture(scope="class", params=PREFIXES) + @pytest.fixture(params=PREFIXES) def prefix(self, request): return request.param - @pytest.fixture(scope="class", params=[1, 2], ids=["single prefix", "multiple prefixes"]) + @pytest.fixture(params=[1, 2], ids=["single prefix", "multiple prefixes"]) def prefixes(self, request): return TestPrefixHandler.PREFIXES[: request.param] - @pytest.fixture(scope="class", params=COMMANDS) + @pytest.fixture(params=COMMANDS) def command(self, request): return request.param - @pytest.fixture(scope="class", params=[1, 2], ids=["single command", "multiple commands"]) + @pytest.fixture(params=[1, 2], ids=["single command", "multiple commands"]) def commands(self, request): return TestPrefixHandler.COMMANDS[: request.param] - @pytest.fixture(scope="class") + @pytest.fixture def prefix_message_text(self, prefix, command): return prefix + command - @pytest.fixture(scope="class") + @pytest.fixture def prefix_message(self, prefix_message_text): return make_message(prefix_message_text) - @pytest.fixture(scope="class") + @pytest.fixture def prefix_message_update(self, prefix_message): return make_message_update(prefix_message) @@ -94,12 +94,12 @@ async def test_basic(self, app, prefix, command): assert isinstance(handler.commands, frozenset) assert handler.commands == {"#cmd", "#bmd"} - def test_single_multi_prefixes_commands(self, prefixes, commands, prefix_message_update): + def test_single_multi_prefixes_commands(self, prefix_message_update): """Test various combinations of prefixes and commands""" handler = self.make_default_handler() result = is_match(handler, prefix_message_update) - expected = prefix_message_update.message.text in combinations(prefixes, commands) - return result == expected + expected = prefix_message_update.message.text in self.COMBINATIONS + assert result == expected def test_edited(self, prefix_message): handler_edited = self.make_default_handler() diff --git a/tests/ext/test_ratelimiter.py b/tests/ext/test_ratelimiter.py index e2c949ce143..084ffb6f5ab 100644 --- a/tests/ext/test_ratelimiter.py +++ b/tests/ext/test_ratelimiter.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -21,11 +21,14 @@ We mostly test on directly on AIORateLimiter here, b/c BaseRateLimiter doesn't contain anything notable """ + import asyncio +import datetime as dtm +import itertools import json import platform import time -from datetime import datetime +from collections import Counter from http import HTTPStatus import pytest @@ -35,7 +38,7 @@ from telegram.error import RetryAfter from telegram.ext import AIORateLimiter, BaseRateLimiter, Defaults, ExtBot from telegram.request import BaseRequest, RequestData -from tests.auxil.envvars import GITHUB_ACTION, TEST_WITH_OPT_DEPS +from tests.auxil.envvars import GITHUB_ACTIONS, TEST_WITH_OPT_DEPS @pytest.mark.skipif( @@ -52,7 +55,7 @@ class TestBaseRateLimiter: request_received = None async def test_no_rate_limiter(self, bot): - with pytest.raises(ValueError, match="if a `ExtBot.rate_limiter` is set"): + with pytest.raises(ValueError, match="if a `ExtBot\\.rate_limiter` is set"): await bot.send_message(chat_id=42, text="test", rate_limit_args="something") async def test_argument_passing(self, bot_info, monkeypatch, bot): @@ -84,6 +87,10 @@ async def initialize(self) -> None: async def shutdown(self) -> None: pass + @property + def read_timeout(self): + return 1 + async def do_request(self, *args, **kwargs): if TestBaseRateLimiter.request_received is None: TestBaseRateLimiter.request_received = [] @@ -142,13 +149,15 @@ async def do_request(self, *args, **kwargs): not TEST_WITH_OPT_DEPS, reason="Only relevant if the optional dependency is installed" ) @pytest.mark.skipif( - bool(GITHUB_ACTION and platform.system() == "Darwin"), + GITHUB_ACTIONS and platform.system() == "Darwin", reason="The timings are apparently rather inaccurate on MacOS.", ) @pytest.mark.flaky(10, 1) # Timings aren't quite perfect class TestAIORateLimiter: count = 0 + apb_count = 0 call_times = [] + apb_call_times = [] class CountRequest(BaseRequest): def __init__(self, retry_after=None): @@ -160,9 +169,21 @@ async def initialize(self) -> None: async def shutdown(self) -> None: pass + @property + def read_timeout(self): + return 1 + async def do_request(self, *args, **kwargs): - TestAIORateLimiter.count += 1 - TestAIORateLimiter.call_times.append(time.time()) + request_data = kwargs.get("request_data") + allow_paid_broadcast = request_data.parameters.get("allow_paid_broadcast", False) + + if allow_paid_broadcast: + TestAIORateLimiter.apb_count += 1 + TestAIORateLimiter.apb_call_times.append(time.time()) + else: + TestAIORateLimiter.count += 1 + TestAIORateLimiter.call_times.append(time.time()) + if self.retry_after: raise RetryAfter(retry_after=1) @@ -181,7 +202,7 @@ async def do_request(self, *args, **kwargs): { "ok": True, "result": Message( - message_id=1, date=datetime.now(), chat=Chat(1, "chat") + message_id=1, date=dtm.datetime.now(), chat=Chat(1, "chat") ).to_dict(), } ).encode(), @@ -190,10 +211,10 @@ async def do_request(self, *args, **kwargs): @pytest.fixture(autouse=True) def _reset(self): - self.count = 0 TestAIORateLimiter.count = 0 - self.call_times = [] TestAIORateLimiter.call_times = [] + TestAIORateLimiter.apb_count = 0 + TestAIORateLimiter.apb_call_times = [] @pytest.mark.parametrize("max_retries", [0, 1, 4]) async def test_max_retries(self, bot, max_retries): @@ -214,7 +235,7 @@ async def test_max_retries(self, bot, max_retries): times = TestAIORateLimiter.call_times if len(times) <= 1: return - delays = [j - i for i, j in zip(times[:-1], times[1:])] + delays = [j - i for i, j in itertools.pairwise(times)] assert delays == pytest.approx([1.1 for _ in range(max_retries)], rel=0.05) async def test_delay_all_pending_on_retry(self, bot): @@ -358,3 +379,58 @@ async def test_group_caching(self, bot, intermediate): finally: TestAIORateLimiter.count = 0 TestAIORateLimiter.call_times = [] + + async def test_allow_paid_broadcast(self, bot): + try: + rl_bot = ExtBot( + token=bot.token, + request=self.CountRequest(retry_after=None), + rate_limiter=AIORateLimiter(), + ) + + async with rl_bot: + apb_tasks = {} + non_apb_tasks = {} + for i in range(3000): + apb_tasks[i] = asyncio.create_task( + rl_bot.send_message(chat_id=-1, text="test", allow_paid_broadcast=True) + ) + + number = 2 + for i in range(number): + non_apb_tasks[i] = asyncio.create_task( + rl_bot.send_message(chat_id=-1, text="test") + ) + non_apb_tasks[i + number] = asyncio.create_task( + rl_bot.send_message(chat_id=-1, text="test", allow_paid_broadcast=False) + ) + + await asyncio.sleep(0.1) + # We expect 5 non-apb requests: + # 1: `get_me` from `async with rl_bot` + # 2-5: `send_message` + assert TestAIORateLimiter.count == 5 + assert sum(1 for task in non_apb_tasks.values() if task.done()) == 4 + + # ~2 second after start + # We do the checks once all apb_tasks are done as apparently getting the timings + # right to check after 1 second is hard + await asyncio.sleep(2.1 - 0.1) + assert all(task.done() for task in apb_tasks.values()) + + apb_call_times = [ + ct - TestAIORateLimiter.apb_call_times[0] + for ct in TestAIORateLimiter.apb_call_times + ] + apb_call_times_dict = Counter(map(int, apb_call_times)) + + # We expect ~2000 apb requests after the first second + # 2000 (>>1000), since we have a floating window logic such that an initial + # burst is allowed that is hard to measure in the tests + assert apb_call_times_dict[0] <= 2000 + assert apb_call_times_dict[0] + apb_call_times_dict[1] < 3000 + assert sum(apb_call_times_dict.values()) == 3000 + + finally: + # cleanup + await asyncio.gather(*apb_tasks.values(), *non_apb_tasks.values()) diff --git a/tests/ext/test_shippingqueryhandler.py b/tests/ext/test_shippingqueryhandler.py index 2d8b3bb801e..90e51c68a91 100644 --- a/tests/ext/test_shippingqueryhandler.py +++ b/tests/ext/test_shippingqueryhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_stringcommandhandler.py b/tests/ext/test_stringcommandhandler.py index 6d8f9d77bdb..14bd8e4e126 100644 --- a/tests/ext/test_stringcommandhandler.py +++ b/tests/ext/test_stringcommandhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_stringregexhandler.py b/tests/ext/test_stringregexhandler.py index 208a63cf49b..421038f7a45 100644 --- a/tests/ext/test_stringregexhandler.py +++ b/tests/ext/test_stringregexhandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_typehandler.py b/tests/ext/test_typehandler.py index 8b3e6407168..952a72d075c 100644 --- a/tests/ext/test_typehandler.py +++ b/tests/ext/test_typehandler.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/ext/test_updater.py b/tests/ext/test_updater.py index 57b84e18454..bbe1d3dc320 100644 --- a/tests/ext/test_updater.py +++ b/tests/ext/test_updater.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,7 +17,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 asyncio +import datetime as dtm import logging +import platform from collections import defaultdict from http import HTTPStatus from pathlib import Path @@ -26,18 +28,26 @@ import pytest from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup, Update -from telegram._utils.defaultvalue import DEFAULT_NONE from telegram.error import InvalidToken, RetryAfter, TelegramError, TimedOut from telegram.ext import ExtBot, InvalidCallbackData, Updater -from telegram.request import HTTPXRequest from tests.auxil.build_messages import make_message, make_message_update from tests.auxil.envvars import TEST_WITH_OPT_DEPS -from tests.auxil.files import data_file +from tests.auxil.files import TEST_DATA_PATH, data_file +from tests.auxil.monkeypatch import empty_get_updates, return_true from tests.auxil.networking import send_webhook_message -from tests.auxil.pytest_classes import PytestBot, make_bot +from tests.auxil.pytest_classes import make_bot from tests.auxil.slots import mro_slots +UNIX_AVAILABLE = False + if TEST_WITH_OPT_DEPS: + try: + from tornado.netutil import bind_unix_socket + + UNIX_AVAILABLE = True + except ImportError: + UNIX_AVAILABLE = False + from telegram.ext._utils.webhookhandler import WebhookServer @@ -63,6 +73,7 @@ class TestUpdater: cb_handler_called = None offset = 0 test_flag = False + response_text = "{1}: {0}{1}: {0}" @pytest.fixture(autouse=True) def _reset(self): @@ -73,6 +84,14 @@ def _reset(self): self.cb_handler_called = None self.test_flag = False + # This is needed instead of pytest's temp_path because the file path gets too long on macOS + # otherwise + @pytest.fixture + def file_path(self) -> str: + path = TEST_DATA_PATH / "test.sock" + yield str(path) + path.unlink(missing_ok=True) + def error_callback(self, error): self.received = error self.err_handler_called.set() @@ -94,6 +113,11 @@ def test_init(self, bot): assert updater.bot is bot assert updater.update_queue is queue + def test_repr(self, bot): + queue = asyncio.Queue() + updater = Updater(bot=bot, update_queue=queue) + assert repr(updater) == f"Updater[bot={updater.bot!r}]" + async def test_initialize(self, bot, monkeypatch): async def initialize_bot(*args, **kwargs): self.test_flag = True @@ -155,14 +179,11 @@ async def test_start_without_initialize(self, updater, method): @pytest.mark.parametrize("method", ["start_polling", "start_webhook"]) async def test_shutdown_while_running(self, updater, method, monkeypatch): - async def set_webhook(*args, **kwargs): - return True - - monkeypatch.setattr(updater.bot, "set_webhook", set_webhook) - ip = "127.0.0.1" port = randrange(1024, 49152) # Select random port + monkeypatch.setattr(updater.bot, "get_updates", empty_get_updates) + async with updater: if "webhook" in method: await getattr(updater, method)( @@ -214,17 +235,20 @@ async def test_polling_basic(self, monkeypatch, updater, drop_pending_updates): await updates.put(Update(update_id=2)) async def get_updates(*args, **kwargs): - next_update = await updates.get() - updates.task_done() - return [next_update] + if not updates.empty(): + next_update = await updates.get() + updates.task_done() + return [next_update] - orig_del_webhook = updater.bot.delete_webhook + await asyncio.sleep(0.1) + return [] async def delete_webhook(*args, **kwargs): # Dropping pending updates is done by passing the parameter to delete_webhook if kwargs.get("drop_pending_updates"): self.message_count += 1 - return await orig_del_webhook(*args, **kwargs) + await asyncio.sleep(0) + return True monkeypatch.setattr(updater.bot, "get_updates", get_updates) monkeypatch.setattr(updater.bot, "delete_webhook", delete_webhook) @@ -236,7 +260,6 @@ async def delete_webhook(*args, **kwargs): await updates.join() await updater.stop() assert not updater.running - assert not (await updater.bot.get_webhook_info()).url if drop_pending_updates: assert self.message_count == 1 else: @@ -248,10 +271,11 @@ async def delete_webhook(*args, **kwargs): # We call the same logic twice to make sure that restarting the updater works as well await updater.start_polling(drop_pending_updates=drop_pending_updates) assert updater.running + tasks = asyncio.all_tasks() + assert any("Updater:start_polling:polling_task" in t.get_name() for t in tasks) await updates.join() await updater.stop() assert not updater.running - assert not (await updater.bot.get_webhook_info()).url self.received = [] self.message_count = 0 @@ -263,7 +287,119 @@ async def delete_webhook(*args, **kwargs): assert self.message_count == 4 assert self.received == [1, 2, 3, 4] - async def test_start_polling_already_running(self, updater): + async def test_polling_mark_updates_as_read(self, monkeypatch, updater, caplog): + updates = asyncio.Queue() + max_update_id = 3 + for i in range(1, max_update_id + 1): + await updates.put(Update(update_id=i)) + tracking_flag = False + received_kwargs = {} + expected_kwargs = { + "timeout": dtm.timedelta(seconds=0), + "allowed_updates": "allowed_updates", + } + + async def get_updates(*args, **kwargs): + if tracking_flag: + received_kwargs.update(kwargs) + if not updates.empty(): + next_update = await updates.get() + updates.task_done() + return [next_update] + await asyncio.sleep(0) + return [] + + monkeypatch.setattr(updater.bot, "get_updates", get_updates) + + async with updater: + await updater.start_polling(**expected_kwargs) + await updates.join() + assert not received_kwargs + # Set the flag only now since we want to make sure that the get_updates + # is called one last time by updater.stop() + tracking_flag = True + with caplog.at_level(logging.DEBUG): + await updater.stop() + + # ensure that the last fetched update was still marked as read + assert received_kwargs["offset"] == max_update_id + 1 + # ensure that the correct arguments where passed to the last `get_updates` call + for name, value in expected_kwargs.items(): + assert received_kwargs[name] == value + + assert len(caplog.records) >= 1 + log_found = False + for record in caplog.records: + if not record.getMessage().startswith("Calling `get_updates` one more time"): + continue + + assert record.name == "telegram.ext.Updater" + assert record.levelno == logging.DEBUG + log_found = True + break + + assert log_found + + async def test_polling_mark_updates_as_read_timeout(self, monkeypatch, updater, caplog): + timeout_event = asyncio.Event() + + async def get_updates(*args, **kwargs): + await asyncio.sleep(0) + if timeout_event.is_set(): + raise TimedOut("TestMessage") + return [] + + monkeypatch.setattr(updater.bot, "get_updates", get_updates) + + async with updater: + await updater.start_polling() + with caplog.at_level(logging.ERROR): + timeout_event.set() + await updater.stop() + + assert len(caplog.records) >= 1 + log_found = False + for record in caplog.records: + if not record.getMessage().startswith( + "Error while calling `get_updates` one more time" + ): + continue + + assert record.name == "telegram.ext.Updater" + assert record.exc_info[0] is TimedOut + assert str(record.exc_info[1]) == "TestMessage" + log_found = True + break + + assert log_found + + async def test_polling_mark_updates_as_read_failure(self, monkeypatch, updater, caplog): + monkeypatch.setattr(updater.bot, "get_updates", empty_get_updates) + + async with updater: + await updater.start_polling() + # Unfortunately, there is no clean way to test this scenario as it should in fact + # never happen + updater._Updater__polling_cleanup_cb = None + with caplog.at_level(logging.DEBUG): + await updater.stop() + + assert len(caplog.records) >= 1 + log_found = False + for record in caplog.records: + if not record.getMessage().startswith("No polling cleanup callback defined"): + continue + + assert record.name == "telegram.ext.Updater" + assert record.levelno == logging.WARNING + log_found = True + break + + assert log_found + + async def test_start_polling_already_running(self, updater, monkeypatch): + monkeypatch.setattr(updater.bot, "get_updates", empty_get_updates) + async with updater: await updater.start_polling() task = asyncio.create_task(updater.start_polling()) @@ -276,18 +412,20 @@ async def test_start_polling_already_running(self, updater): async def test_start_polling_get_updates_parameters(self, updater, monkeypatch): update_queue = asyncio.Queue() await update_queue.put(Update(update_id=1)) + on_stop_flag = False expected = { - "timeout": 10, - "read_timeout": 2, - "write_timeout": DEFAULT_NONE, - "connect_timeout": DEFAULT_NONE, - "pool_timeout": DEFAULT_NONE, + "timeout": dtm.timedelta(seconds=10), "allowed_updates": None, "api_kwargs": None, } async def get_updates(*args, **kwargs): + if on_stop_flag: + # This is tested in test_polling_mark_updates_as_read + await asyncio.sleep(0) + return [] + for key, value in expected.items(): assert kwargs.pop(key, None) == value @@ -298,38 +436,37 @@ async def get_updates(*args, **kwargs): if offset is not None and self.message_count != 0: assert offset == self.message_count + 1, "get_updates got wrong `offset` parameter" - update = await update_queue.get() - self.message_count = update.update_id - update_queue.task_done() - return [update] + if not update_queue.empty(): + update = await update_queue.get() + self.message_count = update.update_id + update_queue.task_done() + return [update] + + await asyncio.sleep(0) + return [] monkeypatch.setattr(updater.bot, "get_updates", get_updates) async with updater: await updater.start_polling() await update_queue.join() + on_stop_flag = True await updater.stop() + on_stop_flag = False expected = { - "timeout": 42, - "read_timeout": 43, - "write_timeout": 44, - "connect_timeout": 45, - "pool_timeout": 46, + "timeout": dtm.timedelta(seconds=42), "allowed_updates": ["message"], "api_kwargs": None, } await update_queue.put(Update(update_id=2)) await updater.start_polling( - timeout=42, - read_timeout=43, - write_timeout=44, - connect_timeout=45, - pool_timeout=46, + timeout=dtm.timedelta(seconds=42), allowed_updates=["message"], ) await update_queue.join() + on_stop_flag = True await updater.stop() @pytest.mark.parametrize("exception_class", [InvalidToken, TelegramError]) @@ -337,15 +474,13 @@ async def get_updates(*args, **kwargs): async def test_start_polling_bootstrap_retries( self, updater, monkeypatch, exception_class, retries ): - async def do_request(*args, **kwargs): + async def delete_webhook(*args, **kwargs): self.message_count += 1 raise exception_class(str(self.message_count)) - async with updater: - # Patch within the context so that updater.bot.initialize can still be called - # by the context manager - monkeypatch.setattr(HTTPXRequest, "do_request", do_request) + monkeypatch.setattr(updater.bot, "delete_webhook", delete_webhook) + async with updater: if exception_class == InvalidToken: with pytest.raises(InvalidToken, match="1"): await updater.start_polling(bootstrap_retries=retries) @@ -366,11 +501,18 @@ async def do_request(*args, **kwargs): async def test_start_polling_exceptions_and_error_callback( self, monkeypatch, updater, error, callback_should_be_called, custom_error_callback, caplog ): + raise_exception = True get_updates_event = asyncio.Event() + second_get_updates_event = asyncio.Event() async def get_updates(*args, **kwargs): # So that the main task has a chance to be called await asyncio.sleep(0) + if get_updates_event.is_set(): + second_get_updates_event.set() + + if not raise_exception: + return [] get_updates_event.set() raise error @@ -392,6 +534,9 @@ async def get_updates(*args, **kwargs): # Also makes sure that the error handler was called await get_updates_event.wait() + # wait for get_updates to be called a second time - only now we can expect that + # all error handling for the previous call has finished + await second_get_updates_event.wait() if callback_should_be_called: # Make sure that the error handler was called @@ -400,7 +545,7 @@ async def get_updates(*args, **kwargs): else: assert len(caplog.records) > 0 assert any( - "Error while getting Updates: TestMessage" in record.getMessage() + "Exception happened while polling for updates." in record.getMessage() and record.name == "telegram.ext.Updater" for record in caplog.records ) @@ -422,26 +567,24 @@ async def get_updates(*args, **kwargs): else: assert len(caplog.records) > 0 assert any( - "Error while getting Updates: TestMessage" in record.getMessage() + "Exception happened while polling for updates." in record.getMessage() and record.name == "telegram.ext.Updater" for record in caplog.records ) + raise_exception = False await updater.stop() async def test_start_polling_unexpected_shutdown(self, updater, monkeypatch, caplog): update_queue = asyncio.Queue() await update_queue.put(Update(update_id=1)) - await update_queue.put(Update(update_id=2)) first_update_event = asyncio.Event() second_update_event = asyncio.Event() async def get_updates(*args, **kwargs): self.message_count = kwargs.get("offset") update = await update_queue.get() - if update.update_id == 1: - first_update_event.set() - else: - await second_update_event.wait() + first_update_event.set() + await second_update_event.wait() return [update] monkeypatch.setattr(updater.bot, "get_updates", get_updates) @@ -454,8 +597,8 @@ async def get_updates(*args, **kwargs): # Unfortunately we need to use the private attribute here to produce the problem updater._running = False second_update_event.set() + await asyncio.sleep(1) - await asyncio.sleep(0.1) assert caplog.records assert any( "Updater stopped unexpectedly." in record.getMessage() @@ -464,7 +607,7 @@ async def get_updates(*args, **kwargs): ) # Make sure that the update_id offset wasn't increased - assert self.message_count == 2 + assert self.message_count < 1 async def test_start_polling_not_running_after_failure(self, updater, monkeypatch): # Unfortunately we have to use some internal logic to trigger an exception @@ -488,9 +631,13 @@ async def get_updates(*args, **kwargs): await asyncio.sleep(0.01) raise TypeError("Invalid Data") - next_update = await updates.get() - updates.task_done() - return [next_update] + if not updates.empty(): + next_update = await updates.get() + updates.task_done() + return [next_update] + + await asyncio.sleep(0) + return [] orig_del_webhook = updater.bot.delete_webhook @@ -528,15 +675,27 @@ async def delete_webhook(*args, **kwargs): @pytest.mark.parametrize("ext_bot", [True, False]) @pytest.mark.parametrize("drop_pending_updates", [True, False]) @pytest.mark.parametrize("secret_token", ["SecretToken", None]) + @pytest.mark.parametrize( + "unix", [None, "file_path", "socket_object"] if UNIX_AVAILABLE else [None] + ) async def test_webhook_basic( - self, monkeypatch, updater, drop_pending_updates, ext_bot, secret_token + self, + monkeypatch, + updater, + drop_pending_updates, + ext_bot, + secret_token, + unix, + file_path, + one_time_bot, + one_time_raw_bot, ): # Testing with both ExtBot and Bot to make sure any logic in WebhookHandler # that depends on this distinction works if ext_bot and not isinstance(updater.bot, ExtBot): - updater.bot = ExtBot(updater.bot.token) + updater.bot = one_time_bot if not ext_bot and type(updater.bot) is not Bot: - updater.bot = PytestBot(updater.bot.token) + updater.bot = one_time_raw_bot async def delete_webhook(*args, **kwargs): # Dropping pending updates is done by passing the parameter to delete_webhook @@ -544,56 +703,96 @@ async def delete_webhook(*args, **kwargs): self.message_count += 1 return True - async def set_webhook(*args, **kwargs): - return True - - monkeypatch.setattr(updater.bot, "set_webhook", set_webhook) + monkeypatch.setattr(updater.bot, "set_webhook", return_true) monkeypatch.setattr(updater.bot, "delete_webhook", delete_webhook) ip = "127.0.0.1" port = randrange(1024, 49152) # Select random port async with updater: - return_value = await updater.start_webhook( - drop_pending_updates=drop_pending_updates, - ip_address=ip, - port=port, - url_path="TOKEN", - secret_token=secret_token, - ) + if unix: + socket = file_path if unix == "file_path" else bind_unix_socket(file_path) + return_value = await updater.start_webhook( + drop_pending_updates=drop_pending_updates, + secret_token=secret_token, + url_path="TOKEN", + unix=socket, + webhook_url="string", + ) + else: + return_value = await updater.start_webhook( + drop_pending_updates=drop_pending_updates, + ip_address=ip, + port=port, + url_path="TOKEN", + secret_token=secret_token, + webhook_url="string", + ) assert return_value is updater.update_queue assert updater.running # Now, we send an update to the server update = make_message_update("Webhook") await send_webhook_message( - ip, port, update.to_json(), "TOKEN", secret_token=secret_token + ip, + port, + update.to_json(), + "TOKEN", + secret_token=secret_token, + unix=file_path if unix else None, ) assert (await updater.update_queue.get()).to_dict() == update.to_dict() # Returns Not Found if path is incorrect - response = await send_webhook_message(ip, port, "123456", "webhook_handler.py") + response = await send_webhook_message( + ip, + port, + "123456", + "webhook_handler.py", + unix=file_path if unix else None, + ) assert response.status_code == HTTPStatus.NOT_FOUND # Returns METHOD_NOT_ALLOWED if method is not allowed - response = await send_webhook_message(ip, port, None, "TOKEN", get_method="HEAD") + response = await send_webhook_message( + ip, + port, + None, + "TOKEN", + get_method="HEAD", + unix=file_path if unix else None, + ) assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED if secret_token: # Returns Forbidden if no secret token is set - response_text = "403: {0}403: {0}" - response = await send_webhook_message(ip, port, update.to_json(), "TOKEN") + + response = await send_webhook_message( + ip, + port, + update.to_json(), + "TOKEN", + unix=file_path if unix else None, + ) + assert response.status_code == HTTPStatus.FORBIDDEN - assert response.text == response_text.format( - "Request did not include the secret token" + assert response.text == self.response_text.format( + "Request did not include the secret token", HTTPStatus.FORBIDDEN ) # Returns Forbidden if the secret token is wrong response = await send_webhook_message( - ip, port, update.to_json(), "TOKEN", secret_token="NotTheSecretToken" + ip, + port, + update.to_json(), + "TOKEN", + secret_token="NotTheSecretToken", + unix=file_path if unix else None, ) assert response.status_code == HTTPStatus.FORBIDDEN - assert response.text == response_text.format("Request had the wrong secret token") + assert response.text == self.response_text.format( + "Request had the wrong secret token", HTTPStatus.FORBIDDEN + ) await updater.stop() assert not updater.running @@ -604,26 +803,56 @@ async def set_webhook(*args, **kwargs): assert self.message_count == 0 # We call the same logic twice to make sure that restarting the updater works as well - await updater.start_webhook( - drop_pending_updates=drop_pending_updates, - ip_address=ip, - port=port, - url_path="TOKEN", - ) + if unix: + socket = file_path if unix == "file_path" else bind_unix_socket(file_path) + await updater.start_webhook( + drop_pending_updates=drop_pending_updates, + secret_token=secret_token, + unix=socket, + webhook_url="string", + ) + else: + await updater.start_webhook( + drop_pending_updates=drop_pending_updates, + ip_address=ip, + port=port, + url_path="TOKEN", + secret_token=secret_token, + webhook_url="string", + ) assert updater.running update = make_message_update("Webhook") - await send_webhook_message(ip, port, update.to_json(), "TOKEN") + await send_webhook_message( + ip, + port, + update.to_json(), + "" if unix else "TOKEN", + secret_token=secret_token, + unix=file_path if unix else None, + ) assert (await updater.update_queue.get()).to_dict() == update.to_dict() await updater.stop() assert not updater.running - async def test_start_webhook_already_running(self, updater, monkeypatch): - async def return_true(*args, **kwargs): - return True - - monkeypatch.setattr(updater.bot, "set_webhook", return_true) - monkeypatch.setattr(updater.bot, "delete_webhook", return_true) + async def test_unix_webhook_mutually_exclusive_params(self, updater): + async with updater: + with pytest.raises(RuntimeError, match="You can not pass unix and listen"): + await updater.start_webhook(listen="127.0.0.1", unix="DoesntMatter") + with pytest.raises(RuntimeError, match="You can not pass unix and port"): + await updater.start_webhook(port=20, unix="DoesntMatter") + with pytest.raises(RuntimeError, match="you set unix, you also need to set the URL"): + await updater.start_webhook(unix="DoesntMatter") + + @pytest.mark.skipif( + platform.system() != "Windows", + reason="Windows is the only platform without unix", + ) + async def test_no_unix(self, updater): + async with updater: + with pytest.raises(RuntimeError, match="binding unix sockets\\."): + await updater.start_webhook(unix="DoesntMatter", webhook_url="TOKEN") + async def test_start_webhook_already_running(self, updater, monkeypatch): ip = "127.0.0.1" port = randrange(1024, 49152) # Select random port async with updater: @@ -722,13 +951,9 @@ async def test_webhook_arbitrary_callback_data( extensively in test_bot.py in conjunction with get_updates.""" updater = Updater(bot=cdc_bot, update_queue=asyncio.Queue()) - async def return_true(*args, **kwargs): - return True + monkeypatch.setattr(updater.bot, "set_webhook", return_true) try: - monkeypatch.setattr(updater.bot, "set_webhook", return_true) - monkeypatch.setattr(updater.bot, "delete_webhook", return_true) - ip = "127.0.0.1" port = randrange(1024, 49152) # Select random port @@ -770,12 +995,6 @@ async def return_true(*args, **kwargs): updater.bot.callback_data_cache.clear_callback_queries() async def test_webhook_invalid_ssl(self, monkeypatch, updater): - async def return_true(*args, **kwargs): - return True - - monkeypatch.setattr(updater.bot, "set_webhook", return_true) - monkeypatch.setattr(updater.bot, "delete_webhook", return_true) - ip = "127.0.0.1" port = randrange(1024, 49152) # Select random port async with updater: @@ -794,16 +1013,13 @@ async def return_true(*args, **kwargs): assert updater.running is False async def test_webhook_ssl_just_for_telegram(self, monkeypatch, updater): - """Here we just test that the SSL info is pased to Telegram, but __not__ to the the + """Here we just test that the SSL info is pased to Telegram, but __not__ to the webhook server""" async def set_webhook(**kwargs): self.test_flag.append(bool(kwargs.get("certificate"))) return True - async def return_true(*args, **kwargs): - return True - orig_wh_server_init = WebhookServer.__init__ def webhook_server_init(*args, **kwargs): @@ -811,7 +1027,7 @@ def webhook_server_init(*args, **kwargs): orig_wh_server_init(*args, **kwargs) monkeypatch.setattr(updater.bot, "set_webhook", set_webhook) - monkeypatch.setattr(updater.bot, "delete_webhook", return_true) + monkeypatch.setattr( "telegram.ext._utils.webhookhandler.WebhookServer.__init__", webhook_server_init ) @@ -833,15 +1049,13 @@ def webhook_server_init(*args, **kwargs): async def test_start_webhook_bootstrap_retries( self, updater, monkeypatch, exception_class, retries ): - async def do_request(*args, **kwargs): + async def set_webhook(*args, **kwargs): self.message_count += 1 raise exception_class(str(self.message_count)) - async with updater: - # Patch within the context so that updater.bot.initialize can still be called - # by the context manager - monkeypatch.setattr(HTTPXRequest, "do_request", do_request) + monkeypatch.setattr(updater.bot, "set_webhook", set_webhook) + async with updater: if exception_class == InvalidToken: with pytest.raises(InvalidToken, match="1"): await updater.start_webhook(bootstrap_retries=retries) @@ -852,12 +1066,6 @@ async def do_request(*args, **kwargs): ) async def test_webhook_invalid_posts(self, updater, monkeypatch): - async def return_true(*args, **kwargs): - return True - - monkeypatch.setattr(updater.bot, "set_webhook", return_true) - monkeypatch.setattr(updater.bot, "delete_webhook", return_true) - ip = "127.0.0.1" port = randrange(1024, 49152) @@ -891,17 +1099,9 @@ async def return_true(*args, **kwargs): await updater.stop() async def test_webhook_update_de_json_fails(self, monkeypatch, updater, caplog): - async def delete_webhook(*args, **kwargs): - return True - - async def set_webhook(*args, **kwargs): - return True - def de_json_fails(*args, **kwargs): raise TypeError("Invalid input") - monkeypatch.setattr(updater.bot, "set_webhook", set_webhook) - monkeypatch.setattr(updater.bot, "delete_webhook", delete_webhook) orig_de_json = Update.de_json monkeypatch.setattr(Update, "de_json", de_json_fails) @@ -920,11 +1120,16 @@ def de_json_fails(*args, **kwargs): # Now, we send an update to the server update = make_message_update("Webhook") with caplog.at_level(logging.CRITICAL): - await send_webhook_message(ip, port, update.to_json(), "TOKEN") + response = await send_webhook_message(ip, port, update.to_json(), "TOKEN") 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( + "Update could not be processed", HTTPStatus.BAD_REQUEST + ) # Make sure that everything works fine again when receiving proper updates caplog.clear() @@ -936,3 +1141,52 @@ def de_json_fails(*args, **kwargs): await updater.stop() assert not updater.running + + @pytest.mark.parametrize("method_name", ["start_polling", "start_webhook"]) + async def test_infinite_bootstrap_retries(self, updater, monkeypatch, method_name): + """Here we simply test that setting `bootstrap_retries=-1` does not lead to the wrong + infinite-loop behavior reported in #4966. Raising an exception on the first call to + `set/delete_webhook` ensures that a retry actually happens. + """ + + original_delete_webhook = updater.bot.delete_webhook + original_set_webhook = updater.bot.set_webhook + counts = {"delete": 0, "set": 0} + + def patch_builder(func, name): + async def wrapped(*args, **kwargs): + if counts[name] >= 3: + pytest.fail("Should be called only once. Test failed.") + counts[name] += 1 + if counts[name] == 1: + raise TelegramError("1") + return await func(*args, **kwargs) + + return wrapped + + async def get_updates(*args, **kwargs): + return [] + + monkeypatch.setattr( + updater.bot, "delete_webhook", patch_builder(original_delete_webhook, "delete") + ) + monkeypatch.setattr(updater.bot, "set_webhook", patch_builder(original_set_webhook, "set")) + monkeypatch.setattr(updater.bot, "get_updates", get_updates) + + kwargs = {"bootstrap_retries": -1} + if method_name == "start_webhook": + kwargs.update( + { + "listen": "127.0.0.1", + "port": randrange(1024, 49152), + } + ) + + async with updater: + task = asyncio.create_task(getattr(updater, method_name)(**kwargs)) + try: + await asyncio.wait_for(task, timeout=10) + except TimeoutError: + pytest.fail(f"{method_name} did not succeed within the timeout. Aborting.") + finally: + await updater.stop() diff --git a/tests/request/__init__.py b/tests/request/__init__.py index 1eaba12c869..c95cb3c9741 100644 --- a/tests/request/__init__.py +++ b/tests/request/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/request/test_request.py b/tests/request/test_request.py index 9203e4336d6..a0d71544aa3 100644 --- a/tests/request/test_request.py +++ b/tests/request/test_request.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -18,18 +18,24 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """Here we run tests directly with HTTPXRequest because that's easier than providing dummy implementations for BaseRequest and we want to test HTTPXRequest anyway.""" + import asyncio +import datetime as dtm import json import logging from collections import defaultdict +from collections.abc import Callable, Coroutine from dataclasses import dataclass from http import HTTPStatus -from typing import Any, Callable, Coroutine, Tuple +from typing import Any import httpx import pytest +from httpx import AsyncHTTPTransport +from telegram import InputFile from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.strings import TextEncoding from telegram.error import ( BadRequest, ChatMigrated, @@ -41,8 +47,12 @@ TelegramError, TimedOut, ) +from telegram.request import RequestData from telegram.request._httpxrequest import HTTPXRequest +from telegram.request._requestparameter import RequestParameter from tests.auxil.envvars import TEST_WITH_OPT_DEPS +from tests.auxil.files import data_file +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 @@ -59,16 +69,16 @@ def mocker_factory( response: bytes, return_code: int = HTTPStatus.OK -) -> Callable[[Tuple[Any]], Coroutine[Any, Any, Tuple[int, bytes]]]: +) -> Callable[[tuple[Any]], Coroutine[Any, Any, tuple[int, bytes]]]: async def make_assertion(*args, **kwargs): return return_code, response return make_assertion -@pytest.fixture() +@pytest.fixture async def httpx_request(): - async with HTTPXRequest() as rq: + async with NonchalantHttpxRequest() as rq: yield rq @@ -76,17 +86,18 @@ async def httpx_request(): TEST_WITH_OPT_DEPS, reason="Only relevant if the optional dependency is not installed" ) class TestNoSocksHTTP2WithoutRequest: - async def test_init(self, bot): + async def test_init(self, offline_bot): with pytest.raises(RuntimeError, match=r"python-telegram-bot\[socks\]"): - HTTPXRequest(proxy_url="socks5://foo") + HTTPXRequest(proxy="socks5://foo") with pytest.raises(RuntimeError, match=r"python-telegram-bot\[http2\]"): HTTPXRequest(http_version="2") @pytest.mark.skipif(not TEST_WITH_OPT_DEPS, reason="Optional dependencies not installed") class TestHTTP2WithRequest: - async def test_http_2_response(self): - httpx_request = HTTPXRequest(http_version="2") + @pytest.mark.parametrize("http_version", ["2", "2.0"]) + async def test_http_2_response(self, http_version): + httpx_request = HTTPXRequest(http_version=http_version) async with httpx_request: resp = await httpx_request._client.request( url="https://python-telegram-bot.org", @@ -116,7 +127,7 @@ def __init__(self, *args, **kwargs): # Make sure that other exceptions are forwarded with pytest.raises(ImportError, match=r"Other Error Message"): - HTTPXRequest(proxy_url="socks5://foo") + HTTPXRequest(proxy="socks5://foo") def test_slot_behaviour(self): inst = HTTPXRequest() @@ -125,6 +136,37 @@ def test_slot_behaviour(self): assert getattr(inst, at, "err") != "err", f"got extra slot '{at}'" assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + def test_httpx_kwargs(self, monkeypatch): + self.test_flag = {} + + orig_init = httpx.AsyncClient.__init__ + + class Client(httpx.AsyncClient): + def __init__(*args, **kwargs): + orig_init(*args, **kwargs) + self.test_flag["args"] = args + self.test_flag["kwargs"] = kwargs + + monkeypatch.setattr(httpx, "AsyncClient", Client) + + HTTPXRequest( + connect_timeout=1, + connection_pool_size=42, + http_version="2", + httpx_kwargs={ + "timeout": httpx.Timeout(7), + "limits": httpx.Limits(max_connections=7), + "http1": True, + "verify": False, + }, + ) + kwargs = self.test_flag["kwargs"] + + assert kwargs["timeout"].connect == 7 + assert kwargs["limits"].max_connections == 7 + assert kwargs["http1"] is True + assert kwargs["verify"] is False + async def test_context_manager(self, monkeypatch): async def initialize(): self.test_flag = ["initialize"] @@ -132,7 +174,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) @@ -149,7 +191,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) @@ -179,8 +221,9 @@ async def test_illegal_json_response(self, monkeypatch, httpx_request: HTTPXRequ monkeypatch.setattr(httpx_request, "do_request", mocker_factory(response=server_response)) - with pytest.raises(TelegramError, match="Invalid server response"), caplog.at_level( - logging.ERROR + with ( + pytest.raises(TelegramError, match="Invalid server response"), + caplog.at_level(logging.ERROR), ): await httpx_request.post(None, None, None) @@ -203,7 +246,7 @@ async def test_chat_migrated(self, monkeypatch, httpx_request: HTTPXRequest): assert exc_info.value.new_chat_id == 123 - async def test_retry_after(self, monkeypatch, httpx_request: HTTPXRequest): + async def test_retry_after(self, monkeypatch, httpx_request: HTTPXRequest, PTB_TIMEDELTA): server_response = b'{"ok": "False", "parameters": {"retry_after": 42}}' monkeypatch.setattr( @@ -212,10 +255,12 @@ async def test_retry_after(self, monkeypatch, httpx_request: HTTPXRequest): mocker_factory(response=server_response, return_code=HTTPStatus.BAD_REQUEST), ) - with pytest.raises(RetryAfter, match="Retry in 42") as exc_info: + with pytest.raises( + RetryAfter, match="Retry in " + "0:00:42" if PTB_TIMEDELTA else "42" + ) as exc_info: await httpx_request.post(None, None, None) - assert exc_info.value.retry_after == 42 + assert exc_info.value.retry_after == (dtm.timdelta(seconds=42) if PTB_TIMEDELTA else 42) async def test_unknown_request_params(self, monkeypatch, httpx_request: HTTPXRequest): server_response = b'{"ok": "False", "parameters": {"unknown": "42"}}' @@ -228,7 +273,7 @@ async def test_unknown_request_params(self, monkeypatch, httpx_request: HTTPXReq with pytest.raises( BadRequest, - match="{'unknown': '42'}", + match="\\{'unknown': '42'\\}", ): await httpx_request.post(None, None, None) @@ -241,7 +286,7 @@ async def test_error_description(self, monkeypatch, httpx_request: HTTPXRequest, else: match = "Unknown HTTPError" - server_response = json.dumps(response_data).encode("utf-8") + server_response = json.dumps(response_data).encode(TextEncoding.UTF_8) monkeypatch.setattr( httpx_request, @@ -275,10 +320,14 @@ async def test_error_description(self, monkeypatch, httpx_request: HTTPXRequest, (-1, NetworkError), ], ) + @pytest.mark.parametrize("description", ["Test Message", None]) async def test_special_errors( - self, monkeypatch, httpx_request: HTTPXRequest, code, exception_class + self, monkeypatch, httpx_request: HTTPXRequest, code, exception_class, description ): - server_response = b'{"ok": "False", "description": "Test Message"}' + server_response_json = {"ok": False} + if description: + server_response_json["description"] = description + server_response = json.dumps(server_response_json).encode(TextEncoding.UTF_8) monkeypatch.setattr( httpx_request, @@ -286,7 +335,25 @@ async def test_special_errors( mocker_factory(response=server_response, return_code=code), ) - with pytest.raises(exception_class, match="Test Message"): + if not description and code not in list(HTTPStatus): + match = f"Unknown HTTPError.*{code}" + else: + match = description or str(code.value) + + with pytest.raises(exception_class, match=match): + await httpx_request.post("", None, None) + + async def test_error_parsing_payload(self, monkeypatch, httpx_request: HTTPXRequest): + """Test that we raise an error if the payload is not a valid JSON.""" + server_response = b"invalid_json" + + monkeypatch.setattr( + httpx_request, + "do_request", + mocker_factory(response=server_response, return_code=HTTPStatus.BAD_GATEWAY), + ) + + with pytest.raises(TelegramError, match=r"502.*\. Parsing.*b'invalid_json' failed"): await httpx_request.post("", None, None) @pytest.mark.parametrize( @@ -295,7 +362,7 @@ async def test_special_errors( (TelegramError("TelegramError"), TelegramError, "TelegramError"), ( RuntimeError("CustomError"), - Exception, + NetworkError, r"HTTP implementation: RuntimeError\('CustomError'\)", ), ], @@ -312,9 +379,12 @@ async def do_request(*args, **kwargs): do_request, ) - with pytest.raises(catch_class, match=match): + with pytest.raises(catch_class, match=match) as exc_info: await httpx_request.post(None, None, None) + if catch_class is NetworkError: + assert exc_info.value.__cause__ is exception + async def test_retrieve(self, monkeypatch, httpx_request): """Here we just test that retrieve gives us the raw bytes instead of trying to parse them as json @@ -325,7 +395,7 @@ async def test_retrieve(self, monkeypatch, httpx_request): assert await httpx_request.retrieve(None, None) == server_response - async def test_timeout_propagation(self, monkeypatch, httpx_request): + async def test_timeout_propagation_to_do_request(self, monkeypatch, httpx_request): async def make_assertion(*args, **kwargs): self.test_flag = ( kwargs.get("read_timeout"), @@ -337,7 +407,7 @@ async def make_assertion(*args, **kwargs): monkeypatch.setattr(httpx_request, "do_request", make_assertion) - await httpx_request.post("url", "method") + await httpx_request.post("url", None) assert self.test_flag == (DEFAULT_NONE, DEFAULT_NONE, DEFAULT_NONE, DEFAULT_NONE) await httpx_request.post( @@ -358,34 +428,31 @@ def test_init(self, monkeypatch): @dataclass class Client: timeout: object - proxies: object + proxy: object limits: object http1: object http2: object + transport: object = None monkeypatch.setattr(httpx, "AsyncClient", Client) request = HTTPXRequest() assert request._client.timeout == httpx.Timeout(connect=5.0, read=5.0, write=5.0, pool=1.0) - assert request._client.proxies is None - assert request._client.limits == httpx.Limits( - max_connections=1, max_keepalive_connections=1 - ) + assert request._client.proxy is None + assert request._client.limits == httpx.Limits(max_connections=256) assert request._client.http1 is True assert not request._client.http2 request = HTTPXRequest( connection_pool_size=42, - proxy_url="proxy_url", + proxy="proxy", connect_timeout=43, read_timeout=44, write_timeout=45, pool_timeout=46, ) - assert request._client.proxies == "proxy_url" - assert request._client.limits == httpx.Limits( - max_connections=42, max_keepalive_connections=42 - ) + assert request._client.proxy == "proxy" + assert request._client.limits == httpx.Limits(max_connections=42) assert request._client.timeout == httpx.Timeout(connect=43, read=44, write=45, pool=46) async def test_multiple_inits_and_shutdowns(self, monkeypatch): @@ -418,28 +485,10 @@ async def aclose(*args, **kwargs): assert self.test_flag["init"] == 1 assert self.test_flag["shutdown"] == 1 - async def test_multiple_init_cycles(self): - # nothing really to assert - this should just not fail - httpx_request = HTTPXRequest() - async with httpx_request: - await httpx_request.do_request(url="https://python-telegram-bot.org", method="GET") - async with httpx_request: - await httpx_request.do_request(url="https://python-telegram-bot.org", method="GET") - async def test_http_version_error(self): with pytest.raises(ValueError, match="`http_version` must be either"): HTTPXRequest(http_version="1.0") - async def test_http_1_response(self): - httpx_request = HTTPXRequest(http_version="1.1") - async with httpx_request: - resp = await httpx_request._client.request( - url="https://python-telegram-bot.org", - method="GET", - headers={"User-Agent": httpx_request.USER_AGENT}, - ) - assert resp.http_version == "HTTP/1.1" - async def test_do_request_after_shutdown(self, httpx_request): await httpx_request.shutdown() with pytest.raises(RuntimeError, match="not initialized"): @@ -452,7 +501,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) @@ -469,7 +518,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) @@ -511,9 +560,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, @@ -539,7 +588,10 @@ async def make_assertion(self, **kwargs): assert code == HTTPStatus.OK async def test_do_request_params_with_data( - self, monkeypatch, httpx_request, mixed_rqs # noqa: 9811 + self, + monkeypatch, + httpx_request, + mixed_rqs, # noqa: F811 ): async def make_assertion(self, **kwargs): method_assertion = kwargs.get("method") == "method" @@ -571,46 +623,160 @@ async def make_assertion(self, method, url, headers, timeout, files, data): assert content == b"content" @pytest.mark.parametrize( - ("raised_class", "expected_class", "expected_message"), + ("raised_exception", "expected_class", "expected_message"), [ - (httpx.TimeoutException, TimedOut, "Timed out"), - (httpx.ReadError, NetworkError, "httpx.ReadError: message"), + (httpx.TimeoutException("timeout"), TimedOut, "Timed out"), + (httpx.ReadError("read_error"), NetworkError, "httpx.ReadError: read_error"), ], ) async def test_do_request_exceptions( - self, monkeypatch, httpx_request, raised_class, expected_class, expected_message + self, monkeypatch, httpx_request, raised_exception, expected_class, expected_message ): async def make_assertion(self, method, url, headers, timeout, files, data): - raise raised_class("message") + raise raised_exception monkeypatch.setattr(httpx.AsyncClient, "request", make_assertion) - with pytest.raises(expected_class, match=expected_message): + with pytest.raises(expected_class, match=expected_message) as exc_info: await httpx_request.do_request( "method", "url", ) + assert exc_info.value.__cause__ is raised_exception + async def test_do_request_pool_timeout(self, monkeypatch): + pool_timeout = httpx.PoolTimeout("pool timeout") + async def request(_, **kwargs): if self.test_flag is None: self.test_flag = True else: - raise httpx.PoolTimeout("pool timeout") + raise pool_timeout return httpx.Response(HTTPStatus.OK) monkeypatch.setattr(httpx.AsyncClient, "request", request) async with HTTPXRequest(pool_timeout=0.02) as httpx_request: - with pytest.raises(TimedOut, match="Pool timeout"): + with pytest.raises(TimedOut, match="Pool timeout") as exc_info: await asyncio.gather( httpx_request.do_request(method="GET", url="URL"), httpx_request.do_request(method="GET", url="URL"), ) + assert exc_info.value.__cause__ is pool_timeout + + @pytest.mark.parametrize("media", [True, False]) + async def test_do_request_write_timeout( + self, + monkeypatch, + media, + httpx_request, + input_media_photo, # noqa: F811 + ): + async def request(_, **kwargs): + self.test_flag = kwargs.get("timeout") + return httpx.Response(HTTPStatus.OK, content=b'{"ok": "True", "result": {}}') + + monkeypatch.setattr(httpx.AsyncClient, "request", request) + + data = {"string": "string", "int": 1, "float": 1.0} + if media: + data["media"] = input_media_photo + request_data = RequestData( + parameters=[RequestParameter.from_input(key, value) for key, value in data.items()], + ) + + # First make sure that custom timeouts are always respected + await httpx_request.post( + "url", request_data, read_timeout=1, connect_timeout=2, write_timeout=3, pool_timeout=4 + ) + assert self.test_flag == httpx.Timeout(read=1, connect=2, write=3, pool=4) + + # Now also ensure that the default timeout for media requests is 20 seconds + await httpx_request.post("url", request_data) + assert self.test_flag == httpx.Timeout(read=5, connect=5, write=20 if media else 5, pool=1) + + @pytest.mark.parametrize("init", [True, False]) + async def test_setting_media_write_timeout( + self, + monkeypatch, + init, + input_media_photo, # noqa: F811 + recwarn, + ): + httpx_request = HTTPXRequest(media_write_timeout=42) if init else HTTPXRequest() + + async def request(_, **kwargs): + self.test_flag = kwargs["timeout"].write + return httpx.Response(HTTPStatus.OK, content=b'{"ok": "True", "result": {}}') + + monkeypatch.setattr(httpx.AsyncClient, "request", request) + + data = {"string": "string", "int": 1, "float": 1.0, "media": input_media_photo} + request_data = RequestData( + parameters=[RequestParameter.from_input(key, value) for key, value in data.items()], + ) + + # First make sure that custom timeouts are always respected + await httpx_request.post( + "url", + request_data, + write_timeout=43, + ) + assert self.test_flag == 43 + + # Now also ensure that the init value is respected + await httpx_request.post("url", request_data) + assert self.test_flag == 42 if init else 20 + + # Just for double-checking, since warnings are issued for implementations of BaseRequest + # other than HTTPXRequest + assert len(recwarn) == 0 + + async def test_socket_opts(self, monkeypatch): + transport_kwargs = {} + transport_init = AsyncHTTPTransport.__init__ + + def init_transport(*args, **kwargs): + nonlocal transport_kwargs + transport_kwargs = kwargs.copy() + transport_init(*args, **kwargs) + + monkeypatch.setattr(AsyncHTTPTransport, "__init__", init_transport) + + HTTPXRequest() + assert "socket_options" not in transport_kwargs + + transport_kwargs = {} + HTTPXRequest(socket_options=((1, 2, 3),)) + assert transport_kwargs["socket_options"] == ((1, 2, 3),) + + @pytest.mark.parametrize("read_timeout", [None, 1, 2, 3]) + async def test_read_timeout_property(self, read_timeout): + assert HTTPXRequest(read_timeout=read_timeout).read_timeout == read_timeout + @pytest.mark.skipif(not TEST_WITH_OPT_DEPS, reason="No need to run this twice") class TestHTTPXRequestWithRequest: + async def test_multiple_init_cycles(self): + # nothing really to assert - this should just not fail + httpx_request = HTTPXRequest() + async with httpx_request: + await httpx_request.do_request(url="https://python-telegram-bot.org", method="GET") + async with httpx_request: + await httpx_request.do_request(url="https://python-telegram-bot.org", method="GET") + + async def test_http_1_response(self): + httpx_request = HTTPXRequest(http_version="1.1") + async with httpx_request: + resp = await httpx_request._client.request( + url="https://python-telegram-bot.org", + method="GET", + headers={"User-Agent": httpx_request.USER_AGENT}, + ) + assert resp.http_version == "HTTP/1.1" + async def test_do_request_wait_for_pool(self, httpx_request): """The pool logic is buried rather deeply in httpxcore, so we make actual requests here instead of mocking""" @@ -634,3 +800,15 @@ async def test_do_request_wait_for_pool(self, httpx_request): task_2.exception() except (asyncio.CancelledError, asyncio.InvalidStateError): pass + + async def test_input_file_postponed_read(self, bot, chat_id): + """Here we test that `read_file_handle=False` is correctly handled by HTTPXRequest. + Since manually building the RequestData object has no real benefit, we simply use the Bot + for that. + """ + message = await bot.send_document( + document=InputFile(data_file("telegram.jpg").open("rb"), read_file_handle=False), + chat_id=chat_id, + ) + assert message.document + assert message.document.file_name == "telegram.jpg" diff --git a/tests/request/test_requestdata.py b/tests/request/test_requestdata.py index 733f71d379b..c66380f1c65 100644 --- a/tests/request/test_requestdata.py +++ b/tests/request/test_requestdata.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -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/]. import json -from typing import Any, Dict +from typing import Any from urllib.parse import quote import pytest @@ -30,7 +30,7 @@ @pytest.fixture(scope="module") -def inputfiles() -> Dict[bool, InputFile]: +def inputfiles() -> dict[bool, InputFile]: return {True: InputFile(obj="data", attach=True), False: InputFile(obj="data", attach=False)} @@ -52,7 +52,7 @@ def input_media_photo() -> InputMediaPhoto: @pytest.fixture(scope="module") -def simple_params() -> Dict[str, Any]: +def simple_params() -> dict[str, Any]: return { "string": "string", "integer": 1, @@ -62,7 +62,7 @@ def simple_params() -> Dict[str, Any]: @pytest.fixture(scope="module") -def simple_jsons() -> Dict[str, Any]: +def simple_jsons() -> dict[str, Any]: return { "string": "string", "integer": json.dumps(1), @@ -79,7 +79,7 @@ def simple_rqs(simple_params) -> RequestData: @pytest.fixture(scope="module") -def file_params(inputfiles, input_media_video, input_media_photo) -> Dict[str, Any]: +def file_params(inputfiles, input_media_video, input_media_photo) -> dict[str, Any]: return { "inputfile_attach": inputfiles[True], "inputfile_no_attach": inputfiles[False], @@ -89,7 +89,7 @@ def file_params(inputfiles, input_media_video, input_media_photo) -> Dict[str, A @pytest.fixture(scope="module") -def file_jsons(inputfiles, input_media_video, input_media_photo) -> Dict[str, Any]: +def file_jsons(inputfiles, input_media_video, input_media_photo) -> dict[str, Any]: input_media_video_dict = input_media_video.to_dict() input_media_video_dict["media"] = input_media_video.media.attach_uri input_media_video_dict["thumbnail"] = input_media_video.thumbnail.attach_uri @@ -110,14 +110,14 @@ def file_rqs(file_params) -> RequestData: @pytest.fixture(scope="module") -def mixed_params(file_params, simple_params) -> Dict[str, Any]: +def mixed_params(file_params, simple_params) -> dict[str, Any]: both = file_params.copy() both.update(simple_params) return both @pytest.fixture(scope="module") -def mixed_jsons(file_jsons, simple_jsons) -> Dict[str, Any]: +def mixed_jsons(file_jsons, simple_jsons) -> dict[str, Any]: both = file_jsons.copy() both.update(simple_jsons) return both diff --git a/tests/request/test_requestparameter.py b/tests/request/test_requestparameter.py index 2f78e483534..4870a48fc04 100644 --- a/tests/request/test_requestparameter.py +++ b/tests/request/test_requestparameter.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,12 +16,22 @@ # # 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 typing import Sequence +import datetime as dtm +from collections.abc import Sequence import pytest -from telegram import InputFile, InputMediaPhoto, InputMediaVideo, InputSticker, MessageEntity +from telegram import ( + InputFile, + InputMediaPhoto, + InputMediaVideo, + InputProfilePhotoAnimated, + InputProfilePhotoStatic, + InputSticker, + InputStoryContentPhoto, + InputStoryContentVideo, + MessageEntity, +) from telegram.constants import ChatType from telegram.request._requestparameter import RequestParameter from tests.auxil.files import data_file @@ -82,14 +92,15 @@ def test_multiple_multipart_data(self): ({1: 1.0}, {1: 1.0}), (ChatType.PRIVATE, "private"), (MessageEntity("type", 1, 1), {"type": "type", "offset": 1, "length": 1}), - (datetime.datetime(2019, 11, 11, 0, 26, 16, 10**5), 1573431976), + (dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5), 1573431976), + (dtm.timedelta(days=42), 42 * 24 * 60 * 60), ( [ True, "str", MessageEntity("type", 1, 1), ChatType.PRIVATE, - datetime.datetime(2019, 11, 11, 0, 26, 16, 10**5), + dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5), ], [True, "str", {"type": "type", "offset": 1, "length": 1}, "private", 1573431976], ), @@ -100,6 +111,19 @@ def test_from_input_no_media(self, value, expected_value): assert request_parameter.value == expected_value assert request_parameter.input_files is None + @pytest.mark.parametrize( + ("value", "expected_type", "expected_value"), + [ + (dtm.timedelta(seconds=1), int, 1), + (dtm.timedelta(milliseconds=1), float, 0.001), + ], + ) + def test_from_input_timedelta(self, value, expected_type, expected_value): + request_parameter = RequestParameter.from_input("key", value) + assert request_parameter.value == expected_value + assert request_parameter.input_files is None + assert isinstance(request_parameter.value, expected_type) + def test_from_input_inputfile(self): inputfile_1 = InputFile("data1", filename="inputfile_1", attach=True) inputfile_2 = InputFile("data2", filename="inputfile_2") @@ -162,14 +186,80 @@ def test_from_input_inputmedia_without_attach(self): assert request_parameter.value == {"type": "video"} assert request_parameter.input_files == [input_media.media, input_media.thumbnail] + def test_from_input_profile_photo_static(self): + input_profile_photo = InputProfilePhotoStatic(data_file("telegram.jpg").read_bytes()) + expected = input_profile_photo.to_dict() + expected.update({"photo": input_profile_photo.photo.attach_uri}) + request_parameter = RequestParameter.from_input("key", input_profile_photo) + assert request_parameter.value == expected + assert request_parameter.input_files == [input_profile_photo.photo] + + def test_from_input_profile_photo_animated(self): + input_profile_photo = InputProfilePhotoAnimated( + data_file("telegram2.mp4").read_bytes(), + main_frame_timestamp=dtm.timedelta(seconds=42, milliseconds=43), + ) + expected = input_profile_photo.to_dict() + expected.update({"animation": input_profile_photo.animation.attach_uri}) + request_parameter = RequestParameter.from_input("key", input_profile_photo) + assert request_parameter.value == expected + assert request_parameter.input_files == [input_profile_photo.animation] + + @pytest.mark.parametrize( + ("cls", "args"), + [ + (InputProfilePhotoStatic, (data_file("telegram.jpg"),)), + ( + InputProfilePhotoAnimated, + (data_file("telegram2.mp4"), dtm.timedelta(seconds=42, milliseconds=43)), + ), + ], + ) + def test_from_input_profile_photo_local_files(self, cls, args): + input_profile_photo = cls(*args) + expected = input_profile_photo.to_dict() + requested = RequestParameter.from_input("key", input_profile_photo) + assert requested.value == expected + assert requested.input_files is None + def test_from_input_inputsticker(self): - input_sticker = InputSticker(data_file("telegram.png").read_bytes(), ["emoji"]) + input_sticker = InputSticker(data_file("telegram.png").read_bytes(), ["emoji"], "static") expected = input_sticker.to_dict() expected.update({"sticker": input_sticker.sticker.attach_uri}) request_parameter = RequestParameter.from_input("key", input_sticker) assert request_parameter.value == expected assert request_parameter.input_files == [input_sticker.sticker] + def test_from_input_story_content_photo(self): + input_story_content_photo = InputStoryContentPhoto(data_file("telegram.jpg").read_bytes()) + expected = input_story_content_photo.to_dict() + expected.update({"photo": input_story_content_photo.photo.attach_uri}) + request_parameter = RequestParameter.from_input("key", input_story_content_photo) + assert request_parameter.value == expected + assert request_parameter.input_files == [input_story_content_photo.photo] + + def test_from_input_story_content_video(self): + input_story_content_video = InputStoryContentVideo(data_file("telegram2.mp4").read_bytes()) + expected = input_story_content_video.to_dict() + expected.update({"video": input_story_content_video.video.attach_uri}) + request_parameter = RequestParameter.from_input("key", input_story_content_video) + assert request_parameter.value == expected + assert request_parameter.input_files == [input_story_content_video.video] + + @pytest.mark.parametrize( + ("cls", "arg"), + [ + (InputStoryContentPhoto, data_file("telegram.jpg")), + (InputStoryContentVideo, data_file("telegram2.mp4")), + ], + ) + def test_from_input_story_content_local_files(self, cls, arg): + input_story_content = cls(arg) + expected = input_story_content.to_dict() + requested = RequestParameter.from_input("key", input_story_content) + assert requested.value == expected + assert requested.input_files is None + def test_from_input_str_and_bytes(self): input_str = "test_input" request_parameter = RequestParameter.from_input("input", input_str) diff --git a/tests/test_birthdate.py b/tests/test_birthdate.py new file mode 100644 index 00000000000..8a2d4d240ec --- /dev/null +++ b/tests/test_birthdate.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm + +import pytest + +from telegram import Birthdate +from tests.auxil.slots import mro_slots + + +class BirthdateTestBase: + day = 1 + month = 1 + year = 2022 + + +@pytest.fixture(scope="module") +def birthdate(): + return Birthdate(BirthdateTestBase.day, BirthdateTestBase.month, BirthdateTestBase.year) + + +class TestBirthdateWithoutRequest(BirthdateTestBase): + def test_slot_behaviour(self, birthdate): + for attr in birthdate.__slots__: + assert getattr(birthdate, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(birthdate)) == len(set(mro_slots(birthdate))), "duplicate slot" + + def test_to_dict(self, birthdate): + bd_dict = birthdate.to_dict() + assert isinstance(bd_dict, dict) + assert bd_dict["day"] == self.day + assert bd_dict["month"] == self.month + assert bd_dict["year"] == self.year + + def test_de_json(self, offline_bot): + json_dict = {"day": self.day, "month": self.month, "year": self.year} + bd = Birthdate.de_json(json_dict, offline_bot) + assert isinstance(bd, Birthdate) + assert bd.day == self.day + assert bd.month == self.month + assert bd.year == self.year + + def test_equality(self): + bd1 = Birthdate(1, 1, 2022) + bd2 = Birthdate(1, 1, 2022) + bd3 = Birthdate(1, 1, 2023) + bd4 = Birthdate(1, 2, 2022) + + assert bd1 == bd2 + assert hash(bd1) == hash(bd2) + + assert bd1 == bd3 + assert hash(bd1) == hash(bd3) + + assert bd1 != bd4 + assert hash(bd1) != hash(bd4) + + def test_to_date(self, birthdate): + assert isinstance(birthdate.to_date(), dtm.date) + assert birthdate.to_date() == dtm.date(self.year, self.month, self.day) + new_bd = birthdate.to_date(2023) + assert new_bd == dtm.date(2023, self.month, self.day) + + def test_to_date_no_year(self): + bd = Birthdate(1, 1) + with pytest.raises(ValueError, match="The `year` argument is required"): + bd.to_date() diff --git a/tests/test_bot.py b/tests/test_bot.py index 7bc003c4889..9d299faf6fa 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -22,11 +22,13 @@ import inspect import logging import pickle -import re import socket import time from collections import defaultdict +from http import HTTPStatus +from io import BytesIO +import httpx import pytest from telegram import ( @@ -39,6 +41,8 @@ CallbackQuery, Chat, ChatAdministratorRights, + ChatFullInfo, + ChatInviteLink, ChatPermissions, Dice, InlineKeyboardButton, @@ -48,65 +52,85 @@ InlineQueryResultsButton, InlineQueryResultVoice, InputFile, + InputMediaDocument, + InputMediaPhoto, InputMessageContent, + InputPollOption, InputTextMessageContent, LabeledPrice, + LinkPreviewOptions, MenuButton, MenuButtonCommands, MenuButtonDefault, MenuButtonWebApp, Message, MessageEntity, + OwnedGifts, Poll, PollOption, + PreparedInlineMessage, + ReactionTypeCustomEmoji, + ReactionTypeEmoji, + ReplyParameters, SentWebAppMessage, ShippingOption, + StarTransaction, + StarTransactions, + SuggestedPostParameters, + SuggestedPostPrice, Update, User, WebAppInfo, ) -from telegram._utils.datetime import UTC, from_timestamp, to_timestamp +from telegram._payment.stars.staramount import StarAmount +from telegram._utils.datetime import UTC, from_timestamp, localize, to_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.strings import to_camel_case from telegram.constants import ( ChatAction, InlineQueryLimit, InlineQueryResultType, MenuButtonType, ParseMode, + ReactionEmoji, ) -from telegram.error import BadRequest, InvalidToken, NetworkError +from telegram.error import BadRequest, EndPointNotFound, InvalidToken, TimedOut from telegram.ext import ExtBot, InvalidCallbackData from telegram.helpers import escape_markdown from telegram.request import BaseRequest, HTTPXRequest, RequestData -from telegram.warnings import PTBDeprecationWarning, PTBUserWarning +from telegram.warnings import PTBUserWarning from tests.auxil.bot_method_checks import check_defaults_handling from tests.auxil.ci_bots import FALLBACKS -from tests.auxil.envvars import GITHUB_ACTION, TEST_WITH_OPT_DEPS +from tests.auxil.envvars import GITHUB_ACTIONS from tests.auxil.files import data_file -from tests.auxil.networking import expect_bad_request +from tests.auxil.networking import OfflineRequest, expect_bad_request from tests.auxil.pytest_classes import PytestBot, PytestExtBot, make_bot from tests.auxil.slots import mro_slots +from .auxil.build_messages import make_message +from .auxil.dummy_objects import get_dummy_object -def to_camel_case(snake_str): - """https://stackoverflow.com/a/19053800""" - components = snake_str.split("_") - # We capitalize the first letter of each component except the first one - # with the 'title' method and join them together. - return components[0] + "".join(x.title() for x in components[1:]) - -@pytest.fixture() -async def message(bot, chat_id): # mostly used in tests for edit_message - out = await bot.send_message( +@pytest.fixture +async def one_time_message(bot, chat_id): + # mostly used in tests for edit_message and hence can't be reused + return await bot.send_message( chat_id, "Text", disable_web_page_preview=True, disable_notification=True ) - out._unfreeze() - return out @pytest.fixture(scope="module") +async def static_message(bot, chat_id): + # must not be edited to keep tests independent! We only use bot.send_message so that + # we have a valid message_id to e.g. reply to + return await bot.send_message( + chat_id, "Text", disable_web_page_preview=True, disable_notification=True + ) + + +@pytest.fixture async def media_message(bot, chat_id): + # mostly used in tests for edit_message and hence can't be reused with data_file("telegram.ogg").open("rb") as f: return await bot.send_voice(chat_id, voice=f, caption="my caption", read_timeout=10) @@ -135,13 +159,15 @@ def inline_results(): BASE_GAME_SCORE = 60 # Base game score for game tests xfail = pytest.mark.xfail( - bool(GITHUB_ACTION), # This condition is only relevant for github actions game tests. - reason="Can fail due to race conditions when multiple test suites " - "with the same bot token are run at the same time", + GITHUB_ACTIONS, # This condition is only relevant for github actions game tests. + reason=( + "Can fail due to race conditions when multiple test suites " + "with the same bot token are run at the same time" + ), ) -def bot_methods(ext_bot=True, include_camel_case=False): +def bot_methods(ext_bot=True, include_camel_case=False, include_do_api_request=False): arg_values = [] ids = [] non_api_methods = [ @@ -156,6 +182,9 @@ def bot_methods(ext_bot=True, include_camel_case=False): "shutdown", "insert_callback_data", ] + if not include_do_api_request: + non_api_methods.append("do_api_request") + classes = (Bot, ExtBot) if ext_bot else (Bot,) for cls in classes: for name, attribute in inspect.getmembers(cls, predicate=inspect.isfunction): @@ -167,30 +196,30 @@ def bot_methods(ext_bot=True, include_camel_case=False): ids.append(f"{cls.__name__}.{name}") return pytest.mark.parametrize( - argnames="bot_class, bot_method_name,bot_method", argvalues=arg_values, ids=ids + argnames=("bot_class", "bot_method_name", "bot_method"), argvalues=arg_values, ids=ids ) -class InputMessageContentDWPP(InputMessageContent): +class InputMessageContentLPO(InputMessageContent): """ This is here to cover the case of InputMediaContent classes in testing answer_ilq that have - `disable_web_page_preview` but not `parse_mode`. Unlikely to ever happen, but better be save + `link_preview_options` but not `parse_mode`. Unlikely to ever happen, but better be save than sorry … """ - __slots__ = ("disable_web_page_preview", "parse_mode", "entities", "message_text") + __slots__ = ("entities", "link_preview_options", "message_text", "parse_mode") def __init__( self, message_text: str, - disable_web_page_preview=DEFAULT_NONE, + link_preview_options=DEFAULT_NONE, *, api_kwargs=None, ): super().__init__(api_kwargs=api_kwargs) self._unfreeze() self.message_text = message_text - self.disable_web_page_preview = disable_web_page_preview + self.link_preview_options = link_preview_options class TestBotWithoutRequest: @@ -208,8 +237,10 @@ def _reset(self): self.test_flag = None @pytest.mark.parametrize("bot_class", [Bot, ExtBot]) - def test_slot_behaviour(self, bot_class, bot): - inst = bot_class(bot.token) + def test_slot_behaviour(self, bot_class, offline_bot): + inst = bot_class( + offline_bot.token, request=OfflineRequest(1), get_updates_request=OfflineRequest(1) + ) 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" @@ -218,24 +249,93 @@ async def test_no_token_passed(self): with pytest.raises(InvalidToken, match="You must pass the token"): Bot("") - async def test_to_dict(self, bot): - to_dict_bot = bot.to_dict() + def test_base_url_parsing_basic(self, caplog): + with caplog.at_level(logging.DEBUG): + bot = Bot( + token="!!Test String!!", + base_url="base/", + base_file_url="base/", + request=OfflineRequest(1), + get_updates_request=OfflineRequest(1), + ) + + assert bot.base_url == "base/!!Test String!!" + assert bot.base_file_url == "base/!!Test String!!" + + assert len(caplog.records) >= 2 + messages = [record.getMessage() for record in caplog.records] + assert "Set Bot API URL: base/!!Test String!!" in messages + assert "Set Bot API File URL: base/!!Test String!!" in messages + + @pytest.mark.parametrize( + "insert_key", ["token", "TOKEN", "bot_token", "BOT_TOKEN", "bot-token", "BOT-TOKEN"] + ) + def test_base_url_parsing_string_format(self, insert_key, caplog): + string = f"{{{insert_key}}}" + + with caplog.at_level(logging.DEBUG): + bot = Bot( + token="!!Test String!!", + base_url=string, + base_file_url=string, + request=OfflineRequest(1), + get_updates_request=OfflineRequest(1), + ) + + assert bot.base_url == "!!Test String!!" + assert bot.base_file_url == "!!Test String!!" + + assert len(caplog.records) >= 2 + messages = [record.getMessage() for record in caplog.records] + assert "Set Bot API URL: !!Test String!!" in messages + assert "Set Bot API File URL: !!Test String!!" in messages + + with pytest.raises(KeyError, match="unsupported insertion: unknown"): + Bot("token", base_url="{unknown}{token}") + + def test_base_url_parsing_callable(self, caplog): + def build_url(_: str) -> str: + return "!!Test String!!" + + with caplog.at_level(logging.DEBUG): + bot = Bot( + token="some-token", + base_url=build_url, + base_file_url=build_url, + request=OfflineRequest(1), + get_updates_request=OfflineRequest(1), + ) + + assert bot.base_url == "!!Test String!!" + assert bot.base_file_url == "!!Test String!!" + + assert len(caplog.records) >= 2 + messages = [record.getMessage() for record in caplog.records] + assert "Set Bot API URL: !!Test String!!" in messages + assert "Set Bot API File URL: !!Test String!!" in messages + + async def test_repr(self): + offline_bot = Bot(token="some_token", base_file_url="") + assert repr(offline_bot) == "Bot[token=some_token]" + + async def test_to_dict(self, offline_bot): + to_dict_bot = offline_bot.to_dict() assert isinstance(to_dict_bot, dict) - assert to_dict_bot["id"] == bot.id - assert to_dict_bot["username"] == bot.username - assert to_dict_bot["first_name"] == bot.first_name - if bot.last_name: - assert to_dict_bot["last_name"] == bot.last_name + assert to_dict_bot["id"] == offline_bot.id + assert to_dict_bot["username"] == offline_bot.username + assert to_dict_bot["first_name"] == offline_bot.first_name + if offline_bot.last_name: + assert to_dict_bot["last_name"] == offline_bot.last_name - async def test_initialize_and_shutdown(self, bot: PytestExtBot, monkeypatch): + async def test_initialize_and_shutdown(self, offline_bot: PytestExtBot, monkeypatch): async def initialize(*args, **kwargs): self.test_flag = ["initialize"] async def stop(*args, **kwargs): self.test_flag.append("stop") - temp_bot = PytestBot(token=bot.token) + temp_bot = PytestBot(token=offline_bot.token, request=OfflineRequest()) orig_stop = temp_bot.request.shutdown try: @@ -243,14 +343,14 @@ async def stop(*args, **kwargs): monkeypatch.setattr(temp_bot.request, "shutdown", stop) await temp_bot.initialize() assert self.test_flag == ["initialize"] - assert temp_bot.bot == bot.bot + assert temp_bot.bot == offline_bot.bot await temp_bot.shutdown() assert self.test_flag == ["initialize", "stop"] finally: await orig_stop() - async def test_multiple_inits_and_shutdowns(self, bot, monkeypatch): + async def test_multiple_inits_and_shutdowns(self, offline_bot, monkeypatch): self.received = defaultdict(int) async def initialize(*args, **kwargs): @@ -262,7 +362,7 @@ async def shutdown(*args, **kwargs): monkeypatch.setattr(HTTPXRequest, "initialize", initialize) monkeypatch.setattr(HTTPXRequest, "shutdown", shutdown) - test_bot = PytestBot(bot.token) + test_bot = PytestBot(offline_bot.token) await test_bot.initialize() await test_bot.initialize() await test_bot.initialize() @@ -270,53 +370,125 @@ async def shutdown(*args, **kwargs): await test_bot.shutdown() await test_bot.shutdown() - # 2 instead of 1 since we have to request objects for each bot + # 2 instead of 1 since we have to request objects for each offline_bot assert self.received["init"] == 2 assert self.received["shutdown"] == 2 - async def test_context_manager(self, monkeypatch, bot): + async def test_initialize_with_get_me_failure_then_success(self, offline_bot, monkeypatch): + """Test that bot can recover from get_me failure during initialization.""" + get_me_call_count = 0 + request_init_count = 0 + + test_bot = PytestBot(token=offline_bot.token, request=OfflineRequest()) + original_get_me = test_bot.get_me + original_request_init = test_bot.request.initialize + + async def failing_then_succeeding_get_me(*args, **kwargs): + nonlocal get_me_call_count + get_me_call_count += 1 + if get_me_call_count == 1: + # First call fails + raise TimedOut("Test timeout") + # Subsequent calls succeed + return await original_get_me(*args, **kwargs) + + async def counting_request_init(*args, **kwargs): + nonlocal request_init_count + request_init_count += 1 + await original_request_init(*args, **kwargs) + + monkeypatch.setattr(test_bot, "get_me", failing_then_succeeding_get_me) + monkeypatch.setattr(test_bot.request, "initialize", counting_request_init) + + try: + # First initialize attempt should fail due to get_me timeout + with pytest.raises(TimedOut): + await test_bot.initialize() + + # Request initialization should have been called (once per initialize call) + assert request_init_count == 1 + # get_me should have been called once and failed + assert get_me_call_count == 1 + + # Second initialize attempt should succeed + await test_bot.initialize() + # Request initialization should not be called again (still 1) + assert request_init_count == 1 + # get_me should have been called a second time and succeeded + assert get_me_call_count == 2 + # Verify bot is now accessible + assert test_bot.bot.id == offline_bot.id + + # Third initialize attempt should be a no-op (both flags already True) + await test_bot.initialize() + # Neither should be called again + assert request_init_count == 1 + assert get_me_call_count == 2 + finally: + await test_bot.shutdown() + + async def test_context_manager(self, monkeypatch, offline_bot): async def initialize(): self.test_flag = ["initialize"] async def shutdown(*args): self.test_flag.append("stop") - monkeypatch.setattr(bot, "initialize", initialize) - monkeypatch.setattr(bot, "shutdown", shutdown) + monkeypatch.setattr(offline_bot, "initialize", initialize) + monkeypatch.setattr(offline_bot, "shutdown", shutdown) - async with bot: + async with offline_bot: pass assert self.test_flag == ["initialize", "stop"] - async def test_context_manager_exception_on_init(self, monkeypatch, bot): + async def test_context_manager_exception_on_init(self, monkeypatch, offline_bot): async def initialize(): raise RuntimeError("initialize") async def shutdown(): self.test_flag = "stop" - monkeypatch.setattr(bot, "initialize", initialize) - monkeypatch.setattr(bot, "shutdown", shutdown) + monkeypatch.setattr(offline_bot, "initialize", initialize) + monkeypatch.setattr(offline_bot, "shutdown", shutdown) with pytest.raises(RuntimeError, match="initialize"): - async with bot: + async with offline_bot: pass assert self.test_flag == "stop" + async def test_shutdown_at_error_in_request_in_init(self, monkeypatch, offline_bot): + async def get_me_error(): + raise httpx.HTTPError("BadRequest wrong token sry :(") + + async def shutdown(*args): + self.test_flag = "stop" + + monkeypatch.setattr(offline_bot, "get_me", get_me_error) + monkeypatch.setattr(offline_bot, "shutdown", shutdown) + + async with offline_bot: + pass + + assert self.test_flag == "stop" + async def test_equality(self): - async with make_bot(token=FALLBACKS[0]["token"]) as a, make_bot( - token=FALLBACKS[0]["token"] - ) as b, make_bot(token=FALLBACKS[1]["token"]) as c, Bot(token=FALLBACKS[0]["token"]) as d: + async with ( + make_bot(token=FALLBACKS[0]["token"]) as a, + make_bot(token=FALLBACKS[0]["token"]) as b, + Bot(token=FALLBACKS[0]["token"]) as c, + make_bot(token=FALLBACKS[1]["token"]) as d, + ): e = Update(123456789) + f = Bot(token=FALLBACKS[0]["token"]) assert a == b assert hash(a) == hash(b) assert a is not b - assert a != c - assert hash(a) != hash(c) + assert a == c + assert hash(a) == hash(c) assert a != d assert hash(a) != hash(d) @@ -324,6 +496,9 @@ async def test_equality(self): assert a != e assert hash(a) != hash(e) + # We cant check equality for unintialized Bot object + assert hash(a) != hash(f) + @pytest.mark.parametrize( "attribute", [ @@ -338,55 +513,44 @@ async def test_equality(self): "link", ], ) - async def test_get_me_and_properties_not_initialized(self, bot: Bot, attribute): - bot = Bot(token=bot.token) + async def test_get_me_and_properties_not_initialized(self, attribute): + bot = make_bot(offline=True, token="randomtoken") try: with pytest.raises(RuntimeError, match="not properly initialized"): bot[attribute] finally: await bot.shutdown() - async def test_get_me_and_properties(self, bot): - get_me_bot = await ExtBot(bot.token).get_me() + async def test_get_me_and_properties(self, offline_bot): + get_me_bot = await ExtBot(offline_bot.token).get_me() assert isinstance(get_me_bot, User) - assert get_me_bot.id == bot.id - assert get_me_bot.username == bot.username - assert get_me_bot.first_name == bot.first_name - assert get_me_bot.last_name == bot.last_name - assert get_me_bot.name == bot.name - assert get_me_bot.can_join_groups == bot.can_join_groups - assert get_me_bot.can_read_all_group_messages == bot.can_read_all_group_messages - assert get_me_bot.supports_inline_queries == bot.supports_inline_queries - assert f"https://t.me/{get_me_bot.username}" == bot.link - - def test_bot_pickling_error(self, bot): + assert get_me_bot.id == offline_bot.id + assert get_me_bot.username == offline_bot.username + assert get_me_bot.first_name == offline_bot.first_name + assert get_me_bot.last_name == offline_bot.last_name + assert get_me_bot.name == offline_bot.name + assert get_me_bot.can_join_groups == offline_bot.can_join_groups + assert get_me_bot.can_read_all_group_messages == offline_bot.can_read_all_group_messages + assert get_me_bot.supports_inline_queries == offline_bot.supports_inline_queries + assert f"https://t.me/{get_me_bot.username}" == offline_bot.link + + def test_bot_pickling_error(self, offline_bot): with pytest.raises(pickle.PicklingError, match="Bot objects cannot be pickled"): - pickle.dumps(bot) + pickle.dumps(offline_bot) - def test_bot_deepcopy_error(self, bot): + def test_bot_deepcopy_error(self, offline_bot): with pytest.raises(TypeError, match="Bot objects cannot be deepcopied"): - copy.deepcopy(bot) - - @bot_methods(ext_bot=False) - def test_api_methods_have_log_decorator(self, bot_class, bot_method_name, bot_method): - """Check that all bot methods have the log decorator ...""" - # not islower() skips the camelcase aliases - if not bot_method_name.islower(): - return - source = inspect.getsource(bot_method) - assert ( - # Use re.match to only match at *the beginning* of the string - re.match(rf"\s*\@\_log\s*async def {bot_method_name}", source) - ), f"{bot_method_name} is missing the @_log decorator" + copy.deepcopy(offline_bot) @pytest.mark.parametrize( ("cls", "logger_name"), [(Bot, "telegram.Bot"), (ExtBot, "telegram.ext.ExtBot")] ) - async def test_log_decorator(self, bot: PytestExtBot, cls, logger_name, caplog): + async def test_bot_method_logging(self, offline_bot: PytestExtBot, cls, logger_name, caplog): + instance = cls(offline_bot.token) # Second argument makes sure that we ignore logs from e.g. httpx with caplog.at_level(logging.DEBUG, logger="telegram"): - await cls(bot.token).get_me() + await instance.get_me() # Only for stabilizing this test- if len(caplog.records) == 4: for idx, record in enumerate(caplog.records): @@ -395,11 +559,19 @@ async def test_log_decorator(self, bot: PytestExtBot, cls, logger_name, caplog): caplog.records.pop(idx) if record.getMessage().startswith("Task exception was never retrieved"): caplog.records.pop(idx) - assert len(caplog.records) == 3 + assert len(caplog.records) == 2 assert all(caplog.records[i].name == logger_name for i in [-1, 0]) - assert caplog.records[0].getMessage().startswith("Entering: get_me") - assert caplog.records[-1].getMessage().startswith("Exiting: get_me") + assert ( + caplog.records[0] + .getMessage() + .startswith("Calling Bot API endpoint `getMe` with parameters `{}`") + ) + assert ( + caplog.records[-1] + .getMessage() + .startswith("Call to Bot API endpoint `getMe` finished with return value") + ) @bot_methods() def test_camel_case_aliases(self, bot_class, bot_method_name, bot_method): @@ -408,15 +580,15 @@ def test_camel_case_aliases(self, bot_class, bot_method_name, bot_method): assert camel_case_function is not False, f"{camel_case_name} not found" assert camel_case_function is bot_method, f"{camel_case_name} is not {bot_method}" - @bot_methods() + @bot_methods(include_do_api_request=True) def test_coroutine_functions(self, bot_class, bot_method_name, bot_method): - """Check that all bot methods are defined as async def ...""" + """Check that all offline_bot methods are defined as async def ...""" meth = getattr(bot_method, "__wrapped__", bot_method) # to unwrap the @_log decorator assert inspect.iscoroutinefunction(meth), f"{bot_method_name} must be a coroutine function" - @bot_methods() + @bot_methods(include_do_api_request=True) def test_api_kwargs_and_timeouts_present(self, bot_class, bot_method_name, bot_method): - """Check that all bot methods have `api_kwargs` and timeout params.""" + """Check that all offline_bot methods have `api_kwargs` and timeout params.""" param_names = inspect.signature(bot_method).parameters.keys() params = ("pool_timeout", "read_timeout", "connect_timeout", "write_timeout", "api_kwargs") @@ -427,17 +599,18 @@ def test_api_kwargs_and_timeouts_present(self, bot_class, bot_method_name, bot_m if bot_method_name.replace("_", "").lower() != "getupdates" and bot_class is ExtBot: assert rate_arg in param_names, f"{bot_method} is missing the parameter `{rate_arg}`" - @bot_methods(ext_bot=False) + @bot_methods() async def test_defaults_handling( self, bot_class, bot_method_name: str, bot_method, - bot: PytestExtBot, + offline_bot: PytestExtBot, raw_bot: PytestBot, ): """ - Here we check that the bot methods handle tg.ext.Defaults correctly. This has two parts: + Here we check that the offline_bot methods handle tg.ext.Defaults correctly. This has two + parts: 1. Check that ExtBot actually inserts the defaults values correctly 2. Check that tg.Bot just replaces `DefaultValue(obj)` with `obj`, i.e. that it doesn't @@ -446,8 +619,8 @@ async def test_defaults_handling( As for most defaults, we can't really check the effect, we just check if we're passing the correct kwargs to - Request.post. As bot method tests a scattered across the different test files, we do - this here in one place. + Request.post. As offline_bot method tests a scattered across the different test files, we + do this here in one place. The same test is also run for all the shortcuts (Message.reply_text) etc in the corresponding tests. @@ -455,17 +628,16 @@ async def test_defaults_handling( Finally, there are some tests for Defaults.{parse_mode, quote, allow_sending_without_reply} at the appropriate places, as those are the only things we can actually check. """ - # Mocking get_me within check_defaults_handling messes with the cached values like - # Bot.{bot, username, id, …}` unless we return the expected User object. - return_value = bot.bot if bot_method_name.lower().replace("_", "") == "getme" else None - # Check that ExtBot does the right thing - bot_method = getattr(bot, bot_method_name) + bot_method = getattr(offline_bot, bot_method_name) raw_bot_method = getattr(raw_bot, bot_method_name) - assert await check_defaults_handling(bot_method, bot, return_value=return_value) - assert await check_defaults_handling(raw_bot_method, raw_bot, return_value=return_value) + assert await check_defaults_handling(bot_method, offline_bot) + assert await check_defaults_handling(raw_bot_method, raw_bot) - def test_ext_bot_signature(self): + @pytest.mark.parametrize( + ("name", "method"), inspect.getmembers(Bot, predicate=inspect.isfunction) + ) + def test_ext_bot_signature(self, name, method): """ Here we make sure that all methods of ext.ExtBot have the same signature as the corresponding methods of tg.Bot. @@ -477,43 +649,65 @@ def test_ext_bot_signature(self): ) different_hints_per_method = defaultdict(set, {"__setattr__": {"ext_bot"}}) - for name, method in inspect.getmembers(Bot, predicate=inspect.isfunction): - signature = inspect.signature(method) - ext_signature = inspect.signature(getattr(ExtBot, name)) + signature = inspect.signature(method) + ext_signature = inspect.signature(getattr(ExtBot, name)) - assert ( - ext_signature.return_annotation == signature.return_annotation - ), f"Wrong return annotation for method {name}" - assert ( - set(signature.parameters) - == set(ext_signature.parameters) - global_extra_args - extra_args_per_method[name] - ), f"Wrong set of parameters for method {name}" - for param_name, param in signature.parameters.items(): - if param_name in different_hints_per_method[name]: - continue - assert ( - param.annotation == ext_signature.parameters[param_name].annotation - ), f"Wrong annotation for parameter {param_name} of method {name}" - assert ( - param.default == ext_signature.parameters[param_name].default - ), f"Wrong default value for parameter {param_name} of method {name}" - assert ( - param.kind == ext_signature.parameters[param_name].kind - ), f"Wrong parameter kind for parameter {param_name} of method {name}" + assert ext_signature.return_annotation == signature.return_annotation, ( + f"Wrong return annotation for method {name}" + ) + assert ( + set(signature.parameters) + == set(ext_signature.parameters) - global_extra_args - extra_args_per_method[name] + ), f"Wrong set of parameters for method {name}" + for param_name, param in signature.parameters.items(): + if param_name in different_hints_per_method[name]: + continue + assert param.annotation == ext_signature.parameters[param_name].annotation, ( + f"Wrong annotation for parameter {param_name} of method {name}" + ) + assert param.default == ext_signature.parameters[param_name].default, ( + f"Wrong default value for parameter {param_name} of method {name}" + ) + assert param.kind == ext_signature.parameters[param_name].kind, ( + f"Wrong parameter kind for parameter {param_name} of method {name}" + ) - async def test_unknown_kwargs(self, bot, monkeypatch): + async def test_unknown_kwargs(self, offline_bot, monkeypatch): async def post(url, request_data: RequestData, *args, **kwargs): data = request_data.json_parameters if not all([data["unknown_kwarg_1"] == "7", data["unknown_kwarg_2"] == "5"]): pytest.fail("got wrong parameters") return True - monkeypatch.setattr(bot.request, "post", post) - await bot.send_message( + monkeypatch.setattr(offline_bot.request, "post", post) + await offline_bot.send_message( 123, "text", api_kwargs={"unknown_kwarg_1": 7, "unknown_kwarg_2": 5} ) - async def test_answer_web_app_query(self, bot, raw_bot, monkeypatch): + async def test_get_updates_deserialization_error(self, offline_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) + + offline_bot = PytestExtBot(get_updates_request=HTTPXRequest(), token=offline_bot.token) + + with caplog.at_level(logging.CRITICAL), pytest.raises(AttributeError): + await offline_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, offline_bot, raw_bot, monkeypatch): params = False # For now just test that our internals pass the correct data @@ -538,12 +732,12 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): result = InlineQueryResultArticle("1", "title", InputTextMessageContent("text")) copied_result = copy.copy(result) - ext_bot = bot - for bot in (ext_bot, raw_bot): - # We need to test 1) below both the bot and raw_bot and setting this up with + ext_bot = offline_bot + for bot_type in (ext_bot, raw_bot): + # We need to test 1) below both the offline_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" @@ -558,7 +752,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): @pytest.mark.parametrize( "default_bot", - [{"parse_mode": "Markdown", "disable_web_page_preview": True}], + [{"parse_mode": "Markdown", "link_preview_options": LinkPreviewOptions(is_disabled=True)}], indirect=True, ) @pytest.mark.parametrize( @@ -573,7 +767,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): "input_message_content": { "message_text": "text", "parse_mode": "Markdown", - "disable_web_page_preview": True, + "link_preview_options": { + "is_disabled": True, + }, }, "type": InlineQueryResultType.ARTICLE, "id": "1", @@ -595,7 +791,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): "input_message_content": { "message_text": "text", "parse_mode": "HTML", - "disable_web_page_preview": False, + "link_preview_options": { + "is_disabled": False, + }, }, "type": InlineQueryResultType.ARTICLE, "id": "1", @@ -616,7 +814,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): "title": "title", "input_message_content": { "message_text": "text", - "disable_web_page_preview": "False", + "link_preview_options": { + "is_disabled": "False", + }, }, "type": InlineQueryResultType.ARTICLE, "id": "1", @@ -628,7 +828,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): async def test_answer_web_app_query_defaults( self, default_bot, ilq_result, expected_params, monkeypatch ): - bot = default_bot + offline_bot = default_bot params = False # For now just test that our internals pass the correct data @@ -638,13 +838,13 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): params = request_data.parameters == expected_params return SentWebAppMessage("321").to_dict() - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) # We test different result types more thoroughly for answer_inline_query, so we just # use the one type here copied_result = copy.copy(ilq_result) - web_app_msg = await bot.answer_web_app_query("12345", ilq_result) + web_app_msg = await offline_bot.answer_web_app_query("12345", ilq_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" @@ -657,12 +857,15 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): ) # TODO: Needs improvement. We need incoming inline query to test answer. - @pytest.mark.parametrize("button_type", ["start", "web_app", "backward_compat"]) - async def test_answer_inline_query(self, monkeypatch, bot, raw_bot, button_type): + @pytest.mark.parametrize("button_type", ["start", "web_app"]) + @pytest.mark.parametrize("cache_time", [74, dtm.timedelta(seconds=74)]) + async def test_answer_inline_query( + self, monkeypatch, offline_bot, raw_bot, button_type, cache_time + ): # For now just test that our internals pass the correct data async def make_assertion(url, request_data: RequestData, *args, **kwargs): expected = { - "cache_time": 300, + "cache_time": 74, "results": [ { "title": "first", @@ -680,8 +883,10 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): "title": "test_result", "id": "123", "type": "document", - "document_url": "https://raw.githubusercontent.com/python-telegram-bot" - "/logos/master/logo/png/ptb-logo_240.png", + "document_url": ( + "https://raw.githubusercontent.com/python-telegram-bot" + "/logos/master/logo/png/ptb-logo_240.png" + ), "mime_type": "image/png", "caption": "ptb_logo", "input_message_content": {"message_text": "imc"}, @@ -692,7 +897,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): "is_personal": True, } - if button_type in ["start", "backward_compat"]: + if button_type == "start": button_dict = {"text": "button_text", "start_parameter": "start_parameter"} else: button_dict = { @@ -706,15 +911,17 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): results = [ InlineQueryResultArticle("11", "first", InputTextMessageContent("first")), - InlineQueryResultArticle("12", "second", InputMessageContentDWPP("second")), + InlineQueryResultArticle("12", "second", InputMessageContentLPO("second")), InlineQueryResultDocument( id="123", - document_url="https://raw.githubusercontent.com/python-telegram-bot/logos/master/" - "logo/png/ptb-logo_240.png", + document_url=( + "https://raw.githubusercontent.com/python-telegram-bot/logos/master/" + "logo/png/ptb-logo_240.png" + ), title="test_result", mime_type="image/png", caption="ptb_logo", - input_message_content=InputMessageContentDWPP("imc"), + input_message_content=InputMessageContentLPO("imc"), ), ] @@ -730,21 +937,17 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): button = None copied_results = copy.copy(results) - ext_bot = bot - for bot in (ext_bot, raw_bot): - # We need to test 1) below both the bot and raw_bot and setting this up with + ext_bot = offline_bot + for bot_type in (ext_bot, raw_bot): + # We need to test 1) below both the offline_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, + cache_time=cache_time, is_personal=True, next_offset="42", - switch_pm_text="button_text" if button_type == "backward_compat" else None, - switch_pm_parameter="start_parameter" - if button_type == "backward_compat" - else None, button=button, ) @@ -764,46 +967,9 @@ 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") - - @pytest.mark.parametrize("bot_class", ["Bot", "ExtBot"]) - async def test_answer_inline_query_deprecated_args( - self, monkeypatch, recwarn, bot_class, bot, raw_bot - ): - async def mock_post(*args, **kwargs): - return True - - bot = raw_bot if bot_class == "Bot" else bot - - monkeypatch.setattr(bot.request, "post", mock_post) + monkeypatch.delattr(bot_type.request, "post") - with pytest.raises( - TypeError, match="6.7, the parameter `button is mutually exclusive to the deprecated" - ): - await bot.answer_inline_query( - inline_query_id="123", - results=[], - button=True, - switch_pm_text="text", - switch_pm_parameter="param", - ) - - recwarn.clear() - assert await bot.answer_inline_query( - inline_query_id="123", - results=[], - switch_pm_text="text", - switch_pm_parameter="parameter", - ) - assert len(recwarn) == 1 - assert recwarn[-1].category is PTBDeprecationWarning - assert str(recwarn[-1].message).startswith( - "Since Bot API 6.7, the parameters `switch_pm_text` and `switch_pm_parameter` are " - "deprecated" - ) - assert recwarn[-1].filename == __file__, "stacklevel is incorrect!" - - async def test_answer_inline_query_no_default_parse_mode(self, monkeypatch, bot): + async def test_answer_inline_query_no_default_parse_mode(self, monkeypatch, offline_bot): async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.parameters == { "cache_time": 300, @@ -824,9 +990,11 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): "title": "test_result", "id": "123", "type": "document", - "document_url": "https://raw.githubusercontent.com/" - "python-telegram-bot/logos/master/logo/png/" - "ptb-logo_240.png", + "document_url": ( + "https://raw.githubusercontent.com/" + "python-telegram-bot/logos/master/logo/png/" + "ptb-logo_240.png" + ), "mime_type": "image/png", "caption": "ptb_logo", "input_message_content": {"message_text": "imc"}, @@ -837,23 +1005,25 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): "is_personal": True, } - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) results = [ InlineQueryResultArticle("11", "first", InputTextMessageContent("first")), - InlineQueryResultArticle("12", "second", InputMessageContentDWPP("second")), + InlineQueryResultArticle("12", "second", InputMessageContentLPO("second")), InlineQueryResultDocument( id="123", - document_url="https://raw.githubusercontent.com/python-telegram-bot/logos/master/" - "logo/png/ptb-logo_240.png", + document_url=( + "https://raw.githubusercontent.com/python-telegram-bot/logos/master/" + "logo/png/ptb-logo_240.png" + ), title="test_result", mime_type="image/png", caption="ptb_logo", - input_message_content=InputMessageContentDWPP("imc"), + input_message_content=InputMessageContentLPO("imc"), ), ] copied_results = copy.copy(results) - assert await bot.answer_inline_query( + assert await offline_bot.answer_inline_query( 1234, results=results, cache_time=300, @@ -877,7 +1047,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): @pytest.mark.parametrize( "default_bot", - [{"parse_mode": "Markdown", "disable_web_page_preview": True}], + [{"parse_mode": "Markdown", "link_preview_options": LinkPreviewOptions(is_disabled=True)}], indirect=True, ) async def test_answer_inline_query_default_parse_mode(self, monkeypatch, default_bot): @@ -888,35 +1058,43 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): { "title": "first", "id": "11", - "type": "article", + "type": InlineQueryResultType.ARTICLE, "input_message_content": { "message_text": "first", "parse_mode": "Markdown", - "disable_web_page_preview": True, + "link_preview_options": { + "is_disabled": True, + }, }, }, { "title": "second", "id": "12", - "type": "article", + "type": InlineQueryResultType.ARTICLE, "input_message_content": { "message_text": "second", - "disable_web_page_preview": True, + "link_preview_options": { + "is_disabled": True, + }, }, }, { "title": "test_result", "id": "123", - "type": "document", - "document_url": "https://raw.githubusercontent.com/" - "python-telegram-bot/logos/master/logo/png/" - "ptb-logo_240.png", + "type": InlineQueryResultType.DOCUMENT, + "document_url": ( + "https://raw.githubusercontent.com/" + "python-telegram-bot/logos/master/logo/png/" + "ptb-logo_240.png" + ), "mime_type": "image/png", "caption": "ptb_logo", "parse_mode": "Markdown", "input_message_content": { "message_text": "imc", - "disable_web_page_preview": True, + "link_preview_options": { + "is_disabled": True, + }, "parse_mode": "Markdown", }, }, @@ -929,11 +1107,13 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): monkeypatch.setattr(default_bot.request, "post", make_assertion) results = [ InlineQueryResultArticle("11", "first", InputTextMessageContent("first")), - InlineQueryResultArticle("12", "second", InputMessageContentDWPP("second")), + InlineQueryResultArticle("12", "second", InputMessageContentLPO("second")), InlineQueryResultDocument( id="123", - document_url="https://raw.githubusercontent.com/python-telegram-bot/logos/master/" - "logo/png/ptb-logo_240.png", + document_url=( + "https://raw.githubusercontent.com/python-telegram-bot/logos/master/" + "logo/png/ptb-logo_240.png" + ), title="test_result", mime_type="image/png", caption="ptb_logo", @@ -975,7 +1155,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): async def test_answer_inline_query_current_offset_1( self, monkeypatch, - bot, + offline_bot, inline_results, current_offset, num_results, @@ -991,13 +1171,15 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): next_offset_matches = data["next_offset"] == str(expected_next_offset) return length_matches and ids_match and next_offset_matches - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - assert await bot.answer_inline_query( + assert await offline_bot.answer_inline_query( 1234, results=inline_results, current_offset=current_offset ) - async def test_answer_inline_query_current_offset_2(self, monkeypatch, bot, inline_results): + async def test_answer_inline_query_current_offset_2( + self, monkeypatch, offline_bot, inline_results + ): # For now just test that our internals pass the correct data async def make_assertion(url, request_data: RequestData, *args, **kwargs): data = request_data.parameters @@ -1007,9 +1189,11 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): next_offset_matches = data["next_offset"] == "1" return length_matches and ids_match and next_offset_matches - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - assert await bot.answer_inline_query(1234, results=inline_results, current_offset=0) + assert await offline_bot.answer_inline_query( + 1234, results=inline_results, current_offset=0 + ) inline_results = inline_results[:30] @@ -1021,11 +1205,13 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): next_offset_matches = not data["next_offset"] return length_matches and ids_match and next_offset_matches - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - assert await bot.answer_inline_query(1234, results=inline_results, current_offset=0) + assert await offline_bot.answer_inline_query( + 1234, results=inline_results, current_offset=0 + ) - async def test_answer_inline_query_current_offset_callback(self, monkeypatch, bot): + async def test_answer_inline_query_current_offset_callback(self, monkeypatch, offline_bot): # For now just test that our internals pass the correct data async def make_assertion(url, request_data: RequestData, *args, **kwargs): data = request_data.parameters @@ -1035,9 +1221,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): next_offset = data["next_offset"] == "2" return length and ids and next_offset - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - assert await bot.answer_inline_query( + assert await offline_bot.answer_inline_query( 1234, results=inline_results_callback, current_offset=1 ) @@ -1048,15 +1234,63 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): next_offset = not data["next_offset"] return length and next_offset - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - assert await bot.answer_inline_query( + assert await offline_bot.answer_inline_query( 1234, results=inline_results_callback, current_offset=6 ) + async def test_send_edit_message_mutually_exclusive_link_preview(self, offline_bot, chat_id): + """Test that link_preview is mutually exclusive with disable_web_page_preview.""" + with pytest.raises(ValueError, match="`link_preview_options` are mutually exclusive"): + await offline_bot.send_message( + chat_id, "text", disable_web_page_preview=True, link_preview_options="something" + ) + + with pytest.raises(ValueError, match="`link_preview_options` are mutually exclusive"): + await offline_bot.edit_message_text( + "text", chat_id, 1, disable_web_page_preview=True, link_preview_options="something" + ) + + async def test_rtm_aswr_mutually_exclusive_reply_parameters(self, offline_bot, chat_id): + """Test that reply_to_message_id and allow_sending_without_reply are mutually exclusive + with reply_parameters.""" + with pytest.raises(ValueError, match="`reply_to_message_id` and"): + await offline_bot.send_message( + chat_id, "text", reply_to_message_id=1, reply_parameters=True + ) + + with pytest.raises(ValueError, match="`allow_sending_without_reply` and"): + await offline_bot.send_message( + chat_id, "text", allow_sending_without_reply=True, reply_parameters=True + ) + + # Test with copy message + with pytest.raises(ValueError, match="`reply_to_message_id` and"): + await offline_bot.copy_message( + chat_id, chat_id, 1, reply_to_message_id=1, reply_parameters=True + ) + + with pytest.raises(ValueError, match="`allow_sending_without_reply` and"): + await offline_bot.copy_message( + chat_id, chat_id, 1, allow_sending_without_reply=True, reply_parameters=True + ) + + # Test with send media group + media = InputMediaPhoto("") + with pytest.raises(ValueError, match="`reply_to_message_id` and"): + await offline_bot.send_media_group( + chat_id, media, reply_to_message_id=1, reply_parameters=True + ) + + with pytest.raises(ValueError, match="`allow_sending_without_reply` and"): + await offline_bot.send_media_group( + chat_id, media, allow_sending_without_reply=True, reply_parameters=True + ) + # get_file is tested multiple times in the test_*media* modules. - # Here we only test the behaviour for bot apis in local mode - async def test_get_file_local_mode(self, bot, monkeypatch): + # Here we only test the behaviour for offline_bot apis in local mode + async def test_get_file_local_mode(self, offline_bot, monkeypatch): path = str(data_file("game.gif")) async def make_assertion(*args, **kwargs): @@ -1067,14 +1301,14 @@ async def make_assertion(*args, **kwargs): "file_path": path, } - monkeypatch.setattr(bot, "_post", make_assertion) + monkeypatch.setattr(offline_bot, "_post", make_assertion) - resulting_path = (await bot.get_file("file_id")).file_path - assert bot.token not in resulting_path + resulting_path = (await offline_bot.get_file("file_id")).file_path + assert offline_bot.token not in resulting_path assert resulting_path == path # TODO: Needs improvement. No feasible way to test until bots can add members. - async def test_ban_chat_member(self, monkeypatch, bot): + async def test_ban_chat_member(self, monkeypatch, offline_bot): async def make_assertion(url, request_data: RequestData, *args, **kwargs): data = request_data.json_parameters chat_id = data["chat_id"] == "2" @@ -1083,13 +1317,13 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): revoke_msgs = data.get("revoke_messages", "true") == "true" return chat_id and user_id and until_date and revoke_msgs - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) until = from_timestamp(1577887200) - assert await bot.ban_chat_member(2, 32) - assert await bot.ban_chat_member(2, 32, until_date=until) - assert await bot.ban_chat_member(2, 32, until_date=1577887200) - assert await bot.ban_chat_member(2, 32, revoke_messages=True) + assert await offline_bot.ban_chat_member(2, 32) + assert await offline_bot.ban_chat_member(2, 32, until_date=until) + assert await offline_bot.ban_chat_member(2, 32, until_date=1577887200) + assert await offline_bot.ban_chat_member(2, 32, revoke_messages=True) async def test_ban_chat_member_default_tz(self, monkeypatch, tz_bot): until = dtm.datetime(2020, 1, 11, 16, 13) @@ -1108,7 +1342,7 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): assert await tz_bot.ban_chat_member(2, 32, until_date=until) assert await tz_bot.ban_chat_member(2, 32, until_date=until_timestamp) - async def test_ban_chat_sender_chat(self, monkeypatch, bot): + async def test_ban_chat_sender_chat(self, monkeypatch, offline_bot): # For now, we just test that we pass the correct data to TG async def make_assertion(url, request_data: RequestData, *args, **kwargs): data = request_data.parameters @@ -1116,12 +1350,12 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): sender_chat_id = data["sender_chat_id"] == 32 return chat_id and sender_chat_id - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.ban_chat_sender_chat(2, 32) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.ban_chat_sender_chat(2, 32) # TODO: Needs improvement. @pytest.mark.parametrize("only_if_banned", [True, False, None]) - async def test_unban_chat_member(self, monkeypatch, bot, only_if_banned): + async def test_unban_chat_member(self, monkeypatch, offline_bot, only_if_banned): async def make_assertion(url, request_data: RequestData, *args, **kwargs): data = request_data.parameters chat_id = data["chat_id"] == 2 @@ -1129,21 +1363,21 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): o_i_b = data.get("only_if_banned", None) == only_if_banned return chat_id and user_id and o_i_b - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - assert await bot.unban_chat_member(2, 32, only_if_banned=only_if_banned) + assert await offline_bot.unban_chat_member(2, 32, only_if_banned=only_if_banned) - async def test_unban_chat_sender_chat(self, monkeypatch, bot): + async def test_unban_chat_sender_chat(self, monkeypatch, offline_bot): async def make_assertion(url, request_data: RequestData, *args, **kwargs): data = request_data.json_parameters chat_id = data["chat_id"] == "2" sender_chat_id = data["sender_chat_id"] == "32" return chat_id and sender_chat_id - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.unban_chat_sender_chat(2, 32) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.unban_chat_sender_chat(2, 32) - async def test_set_chat_permissions(self, monkeypatch, bot, chat_permissions): + async def test_set_chat_permissions(self, monkeypatch, offline_bot, chat_permissions): async def make_assertion(url, request_data: RequestData, *args, **kwargs): data = request_data.json_parameters chat_id = data["chat_id"] == "2" @@ -1151,11 +1385,11 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): use_independent_chat_permissions = data["use_independent_chat_permissions"] return chat_id and permissions and use_independent_chat_permissions - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - assert await bot.set_chat_permissions(2, chat_permissions, True) + assert await offline_bot.set_chat_permissions(2, chat_permissions, True) - async def test_set_chat_administrator_custom_title(self, monkeypatch, bot): + async def test_set_chat_administrator_custom_title(self, monkeypatch, offline_bot): async def make_assertion(url, request_data: RequestData, *args, **kwargs): data = request_data.parameters chat_id = data["chat_id"] == 2 @@ -1163,42 +1397,43 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): custom_title = data["custom_title"] == "custom_title" return chat_id and user_id and custom_title - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.set_chat_administrator_custom_title(2, 32, "custom_title") + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.set_chat_administrator_custom_title(2, 32, "custom_title") # TODO: Needs improvement. Need an incoming callbackquery to test - async def test_answer_callback_query(self, monkeypatch, bot): + @pytest.mark.parametrize("cache_time", [74, dtm.timedelta(seconds=74)]) + async def test_answer_callback_query(self, monkeypatch, offline_bot, cache_time): # For now just test that our internals pass the correct data async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.parameters == { "callback_query_id": 23, "show_alert": True, "url": "no_url", - "cache_time": 1, + "cache_time": 74, "text": "answer", } - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - assert await bot.answer_callback_query( - 23, text="answer", show_alert=True, url="no_url", cache_time=1 + assert await offline_bot.answer_callback_query( + 23, text="answer", show_alert=True, url="no_url", cache_time=cache_time ) @pytest.mark.parametrize("drop_pending_updates", [True, False]) async def test_set_webhook_delete_webhook_drop_pending_updates( - self, bot, drop_pending_updates, monkeypatch + self, offline_bot, drop_pending_updates, monkeypatch ): async def make_assertion(url, request_data: RequestData, *args, **kwargs): data = request_data.parameters return data.get("drop_pending_updates") == drop_pending_updates - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - assert await bot.set_webhook("", drop_pending_updates=drop_pending_updates) - assert await bot.delete_webhook(drop_pending_updates=drop_pending_updates) + assert await offline_bot.set_webhook("", drop_pending_updates=drop_pending_updates) + assert await offline_bot.delete_webhook(drop_pending_updates=drop_pending_updates) @pytest.mark.parametrize("local_file", ["str", "Path", False]) - async def test_set_webhook_params(self, bot, monkeypatch, local_file): + async def test_set_webhook_params(self, offline_bot, monkeypatch, local_file): # actually making calls to TG is done in # test_set_webhook_get_webhook_info_and_delete_webhook. Sadly secret_token can't be tested # there so we have this function \o/ @@ -1223,7 +1458,7 @@ async def make_assertion(*args, **_): and kwargs["secret_token"] == "SoSecretToken" ) - monkeypatch.setattr(bot, "_post", make_assertion) + monkeypatch.setattr(offline_bot, "_post", make_assertion) cert_path = data_file("sslcert.pem") if local_file == "str": @@ -1233,7 +1468,7 @@ async def make_assertion(*args, **_): else: certificate = cert_path.read_bytes() - assert await bot.set_webhook( + assert await offline_bot.set_webhook( "example.com", certificate, 7, @@ -1243,8 +1478,63 @@ async def make_assertion(*args, **_): "SoSecretToken", ) + async def test_send_message_draft(self, offline_bot, monkeypatch): + entities = [ + MessageEntity(MessageEntity.BOLD, 0, 3), + MessageEntity(MessageEntity.ITALIC, 5, 8), + ] + + async def make_assertions(*args, **kwargs): + params = kwargs.get("request_data").parameters + assert params.get("chat_id") == 123 + assert params.get("draft_id") == 1 + assert params.get("text") == "test test" + assert params.get("message_thread_id") == 9 + assert params.get("parse_mode") == "markdown" + assert params.get("entities") == [e.to_dict() for e in entities] + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertions) + assert await offline_bot.send_message_draft( + chat_id=123, + draft_id=1, + text="test test", + message_thread_id=9, + parse_mode="markdown", + entities=entities, + ) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, "Markdown"), ("HTML", "HTML"), (None, None)], + ) + async def test_send_message_draft_default_parse_mode( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("parse_mode") == expected_value + return True + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "chat_id": 123, + "draft_id": 1, + "text": "test test", + "message_thread_id": 9, + "entities": [ + MessageEntity(MessageEntity.BOLD, 0, 3), + MessageEntity(MessageEntity.ITALIC, 5, 8), + ], + } + if passed_value is not DEFAULT_NONE: + kwargs["parse_mode"] = passed_value + + await default_bot.send_message_draft(**kwargs) + # TODO: Needs improvement. Need incoming shipping queries to test - async def test_answer_shipping_query_ok(self, monkeypatch, bot): + async def test_answer_shipping_query_ok(self, monkeypatch, offline_bot): # For now just test that our internals pass the correct data async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.parameters == { @@ -1255,11 +1545,13 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): ], } - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) shipping_options = ShippingOption(1, "option1", [LabeledPrice("price", 100)]) - assert await bot.answer_shipping_query(1, True, shipping_options=[shipping_options]) + assert await offline_bot.answer_shipping_query( + 1, True, shipping_options=[shipping_options] + ) - async def test_answer_shipping_query_error_message(self, monkeypatch, bot): + async def test_answer_shipping_query_error_message(self, monkeypatch, offline_bot): # For now just test that our internals pass the correct data async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.parameters == { @@ -1268,19 +1560,19 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): "ok": False, } - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.answer_shipping_query(1, False, error_message="Not enough fish") + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.answer_shipping_query(1, False, error_message="Not enough fish") # TODO: Needs improvement. Need incoming pre checkout queries to test - async def test_answer_pre_checkout_query_ok(self, monkeypatch, bot): + async def test_answer_pre_checkout_query_ok(self, monkeypatch, offline_bot): # For now just test that our internals pass the correct data async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.parameters == {"pre_checkout_query_id": 1, "ok": True} - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.answer_pre_checkout_query(1, True) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.answer_pre_checkout_query(1, True) - async def test_answer_pre_checkout_query_error_message(self, monkeypatch, bot): + async def test_answer_pre_checkout_query_error_message(self, monkeypatch, offline_bot): # For now just test that our internals pass the correct data async def make_assertion(url, request_data: RequestData, *args, **kwargs): return request_data.parameters == { @@ -1289,10 +1581,12 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): "ok": False, } - monkeypatch.setattr(bot.request, "post", make_assertion) - assert await bot.answer_pre_checkout_query(1, False, error_message="Not enough fish") + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.answer_pre_checkout_query( + 1, False, error_message="Not enough fish" + ) - async def test_restrict_chat_member(self, bot, chat_permissions, monkeypatch): + async def test_restrict_chat_member(self, offline_bot, chat_permissions, monkeypatch): async def make_assertion(url, request_data: RequestData, *args, **kwargs): data = request_data.json_parameters chat_id = data["chat_id"] == "@chat" @@ -1308,9 +1602,9 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): and use_independent_chat_permissions ) - monkeypatch.setattr(bot.request, "post", make_assertion) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - assert await bot.restrict_chat_member("@chat", 2, chat_permissions, 200, True) + assert await offline_bot.restrict_chat_member("@chat", 2, chat_permissions, 200, True) async def test_restrict_chat_member_default_tz( self, monkeypatch, tz_bot, channel_id, chat_permissions @@ -1332,10 +1626,12 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): ) @pytest.mark.parametrize("local_mode", [True, False]) - async def test_set_chat_photo_local_files(self, monkeypatch, bot, chat_id, local_mode): + async def test_set_chat_photo_local_files( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): try: - bot._local_mode = local_mode - # For just test that the correct paths are passed as we have no local bot API set up + offline_bot._local_mode = local_mode + # For just test that the correct paths are passed as we have no local Bot API set up self.test_flag = False file = data_file("telegram.jpg") expected = file.as_uri() @@ -1346,13 +1642,13 @@ async def make_assertion(_, data, *args, **kwargs): else: self.test_flag = isinstance(data.get("photo"), InputFile) - monkeypatch.setattr(bot, "_post", make_assertion) - await bot.set_chat_photo(chat_id, file) + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.set_chat_photo(chat_id, file) assert self.test_flag finally: - bot._local_mode = False + offline_bot._local_mode = False - async def test_timeout_propagation_explicit(self, monkeypatch, bot, chat_id): + async def test_timeout_propagation_explicit(self, monkeypatch, offline_bot, chat_id): # Use BaseException that's not a subclass of Exception such that # OkException should not be caught anywhere class OkException(BaseException): @@ -1367,59 +1663,60 @@ async def do_request(*args, **kwargs): return 200, b'{"ok": true, "result": []}' - monkeypatch.setattr(bot.request, "do_request", do_request) + monkeypatch.setattr(offline_bot.request, "do_request", do_request) # Test file uploading with pytest.raises(OkException): - await bot.send_photo( + await offline_bot.send_photo( chat_id, data_file("telegram.jpg").open("rb"), read_timeout=timeout ) # Test JSON submission with pytest.raises(OkException): - await bot.get_chat_administrators(chat_id, read_timeout=timeout) + await offline_bot.get_chat_administrators(chat_id, read_timeout=timeout) - async def test_timeout_propagation_implicit(self, monkeypatch, bot, chat_id): + async def test_timeout_propagation_implicit(self, monkeypatch, offline_bot, chat_id): # Use BaseException that's not a subclass of Exception such that # OkException should not be caught anywhere class OkException(BaseException): pass - async def do_request(*args, **kwargs): - obj = kwargs.get("write_timeout") - if obj == 20: + async def request(*args, **kwargs): + timeout = kwargs.get("timeout") + if timeout.write == 20: raise OkException return 200, b'{"ok": true, "result": []}' - monkeypatch.setattr(bot.request, "do_request", do_request) + monkeypatch.setattr(httpx.AsyncClient, "request", request) + monkeypatch.setattr(offline_bot, "_request", (object(), HTTPXRequest())) # Test file uploading with pytest.raises(OkException): - await bot.send_photo(chat_id, data_file("telegram.jpg").open("rb")) + await offline_bot.send_photo(chat_id, data_file("telegram.jpg").open("rb")) - async def test_log_out(self, monkeypatch, bot): + async def test_log_out(self, monkeypatch, offline_bot): # We don't actually make a request as to not break the test setup async def assertion(url, request_data: RequestData, *args, **kwargs): return request_data.json_parameters == {} and url.split("/")[-1] == "logOut" - monkeypatch.setattr(bot.request, "post", assertion) + monkeypatch.setattr(offline_bot.request, "post", assertion) - assert await bot.log_out() + assert await offline_bot.log_out() - async def test_close(self, monkeypatch, bot): + async def test_close(self, monkeypatch, offline_bot): # We don't actually make a request as to not break the test setup async def assertion(url, request_data: RequestData, *args, **kwargs): return request_data.json_parameters == {} and url.split("/")[-1] == "close" - monkeypatch.setattr(bot.request, "post", assertion) + monkeypatch.setattr(offline_bot.request, "post", assertion) - assert await bot.close() + assert await offline_bot.close() @pytest.mark.parametrize("json_keyboard", [True, False]) @pytest.mark.parametrize("caption", ["Test", "", None]) async def test_copy_message( - self, monkeypatch, bot, chat_id, media_message, json_keyboard, caption + self, monkeypatch, offline_bot, chat_id, media_message, json_keyboard, caption ): keyboard = InlineKeyboardMarkup( [[InlineKeyboardButton(text="test", callback_data="test2")]] @@ -1434,26 +1731,31 @@ async def post(url, request_data: RequestData, *args, **kwargs): data["message_id"] == media_message.message_id, data.get("caption") == caption, data["parse_mode"] == ParseMode.HTML, - data["reply_to_message_id"] == media_message.message_id, - data["reply_markup"] == keyboard.to_json() - if json_keyboard - else keyboard.to_dict(), + data["reply_parameters"] + == ReplyParameters(message_id=media_message.message_id).to_dict(), + ( + data["reply_markup"] == keyboard.to_json() + if json_keyboard + else keyboard.to_dict() + ), data["disable_notification"] is True, data["caption_entities"] == [MessageEntity(MessageEntity.BOLD, 0, 4).to_dict()], data["protect_content"] is True, data["message_thread_id"] == 1, + data["video_start_timestamp"] == 999, ] ): pytest.fail("I got wrong parameters in post") return data - monkeypatch.setattr(bot.request, "post", post) - await bot.copy_message( + monkeypatch.setattr(offline_bot.request, "post", post) + await offline_bot.copy_message( chat_id, from_chat_id=chat_id, message_id=media_message.message_id, caption=caption, + video_start_timestamp=999, caption_entities=[MessageEntity(MessageEntity.BOLD, 0, 4)], parse_mode=ParseMode.HTML, reply_to_message_id=media_message.message_id, @@ -1472,7 +1774,7 @@ async def post(url, request_data: RequestData, *args, **kwargs): [(True, 1024), (False, 1024), (0, 0), (None, None)], ) async def test_callback_data_maxsize(self, bot_info, acd_in, maxsize): - async with make_bot(bot_info, arbitrary_callback_data=acd_in) as acd_bot: + async with make_bot(bot_info, arbitrary_callback_data=acd_in, offline=True) as acd_bot: if acd_in is not False: assert acd_bot.callback_data_cache.maxsize == maxsize else: @@ -1480,7 +1782,7 @@ async def test_callback_data_maxsize(self, bot_info, acd_in, maxsize): async def test_arbitrary_callback_data_no_insert(self, monkeypatch, cdc_bot): """Updates that don't need insertion shouldn't fail obviously""" - bot = cdc_bot + offline_bot = cdc_bot async def post(*args, **kwargs): update = Update( @@ -1500,14 +1802,14 @@ async def post(*args, **kwargs): try: monkeypatch.setattr(BaseRequest, "post", post) - updates = await bot.get_updates(timeout=1) + updates = await offline_bot.get_updates(timeout=1) assert len(updates) == 1 assert updates[0].update_id == 17 assert updates[0].poll.id == "42" finally: - bot.callback_data_cache.clear_callback_data() - bot.callback_data_cache.clear_callback_queries() + offline_bot.callback_data_cache.clear_callback_data() + offline_bot.callback_data_cache.clear_callback_queries() @pytest.mark.parametrize( "message_type", ["channel_post", "edited_channel_post", "message", "edited_message"] @@ -1515,7 +1817,7 @@ async def post(*args, **kwargs): async def test_arbitrary_callback_data_pinned_message_reply_to_message( self, cdc_bot, monkeypatch, message_type ): - bot = cdc_bot + offline_bot = cdc_bot reply_markup = InlineKeyboardMarkup.from_button( InlineKeyboardButton(text="text", callback_data="callback_data") @@ -1524,12 +1826,12 @@ async def test_arbitrary_callback_data_pinned_message_reply_to_message( message = Message( 1, dtm.datetime.utcnow(), - None, - reply_markup=bot.callback_data_cache.process_keyboard(reply_markup), + get_dummy_object(Chat), + reply_markup=offline_bot.callback_data_cache.process_keyboard(reply_markup), ) message._unfreeze() # We do to_dict -> de_json to make sure those aren't the same objects - message.pinned_message = Message.de_json(message.to_dict(), bot) + message.pinned_message = Message.de_json(message.to_dict(), offline_bot) async def post(*args, **kwargs): update = Update( @@ -1538,9 +1840,9 @@ async def post(*args, **kwargs): message_type: Message( 1, dtm.datetime.utcnow(), - None, + get_dummy_object(Chat), pinned_message=message, - reply_to_message=Message.de_json(message.to_dict(), bot), + reply_to_message=Message.de_json(message.to_dict(), offline_bot), ) }, ) @@ -1548,7 +1850,7 @@ async def post(*args, **kwargs): try: monkeypatch.setattr(BaseRequest, "post", post) - updates = await bot.get_updates(timeout=1) + updates = await offline_bot.get_updates(timeout=1) assert isinstance(updates, tuple) assert len(updates) == 1 @@ -1569,11 +1871,11 @@ async def post(*args, **kwargs): ) finally: - bot.callback_data_cache.clear_callback_data() - bot.callback_data_cache.clear_callback_queries() + offline_bot.callback_data_cache.clear_callback_data() + offline_bot.callback_data_cache.clear_callback_queries() async def test_get_updates_invalid_callback_data(self, cdc_bot, monkeypatch): - bot = cdc_bot + offline_bot = cdc_bot async def post(*args, **kwargs): return [ @@ -1597,7 +1899,7 @@ async def post(*args, **kwargs): try: monkeypatch.setattr(BaseRequest, "post", post) - updates = await bot.get_updates(timeout=1) + updates = await offline_bot.get_updates(timeout=1) assert isinstance(updates, tuple) assert len(updates) == 1 @@ -1605,12 +1907,12 @@ async def post(*args, **kwargs): finally: # Reset b/c bots scope is session - bot.callback_data_cache.clear_callback_data() - bot.callback_data_cache.clear_callback_queries() + offline_bot.callback_data_cache.clear_callback_data() + offline_bot.callback_data_cache.clear_callback_queries() # TODO: Needs improvement. We need incoming inline query to test answer. async def test_replace_callback_data_answer_inline_query(self, monkeypatch, cdc_bot, chat_id): - bot = cdc_bot + offline_bot = cdc_bot # For now just test that our internals pass the correct data async def make_assertion( @@ -1627,7 +1929,7 @@ async def make_assertion( inline_keyboard[0][0].callback_data[32:], ) assertion_3 = ( - bot.callback_data_cache._keyboard_data[keyboard].button_data[button] + offline_bot.callback_data_cache._keyboard_data[keyboard].button_data[button] == "replace_test" ) assertion_4 = data["results"][1].reply_markup is None @@ -1645,8 +1947,9 @@ async def make_assertion( ] ) - bot.username # call this here so `bot.get_me()` won't be called after mocking - monkeypatch.setattr(bot, "_post", make_assertion) + # call this here so `offline_bot.get_me()` won't be called after mocking + offline_bot.username + monkeypatch.setattr(offline_bot, "_post", make_assertion) results = [ InlineQueryResultArticle( "11", "first", InputTextMessageContent("first"), reply_markup=reply_markup @@ -1658,11 +1961,11 @@ async def make_assertion( ), ] - assert await bot.answer_inline_query(chat_id, results=results) + assert await offline_bot.answer_inline_query(chat_id, results=results) finally: - bot.callback_data_cache.clear_callback_data() - bot.callback_data_cache.clear_callback_queries() + offline_bot.callback_data_cache.clear_callback_data() + offline_bot.callback_data_cache.clear_callback_queries() @pytest.mark.parametrize( "message_type", ["channel_post", "edited_channel_post", "message", "edited_message"] @@ -1680,7 +1983,7 @@ async def test_arbitrary_callback_data_via_bot( message = Message( 1, dtm.datetime.utcnow(), - None, + get_dummy_object(Chat), reply_markup=reply_markup, via_bot=bot.bot if self_sender else User(1, "first", False), ) @@ -1732,83 +2035,804 @@ async def test_http2_runtime_error(self, recwarn, bot_class): "You set the HTTP version for the get_updates_request and request HTTPXRequest " "instance" in str(recwarn[2].message) ) - for warning in recwarn: - assert warning.filename == __file__, "wrong stacklevel!" - assert warning.category is PTBUserWarning + for warning in recwarn: + assert warning.filename == __file__, "wrong stacklevel!" + assert warning.category is PTBUserWarning + + async def test_set_get_my_name(self, offline_bot, monkeypatch): + """We only test that we pass the correct values to TG since this endpoint is heavily + rate limited which makes automated tests rather infeasible.""" + default_name = "default_bot_name" + en_name = "en_bot_name" + de_name = "de_bot_name" + + # We predefine the responses that we would TG expect to send us + set_stack = asyncio.Queue() + get_stack = asyncio.Queue() + await set_stack.put({"name": default_name}) + await set_stack.put({"name": en_name, "language_code": "en"}) + await set_stack.put({"name": de_name, "language_code": "de"}) + await get_stack.put({"name": default_name, "language_code": None}) + await get_stack.put({"name": en_name, "language_code": "en"}) + await get_stack.put({"name": de_name, "language_code": "de"}) + + await set_stack.put({"name": default_name}) + await set_stack.put({"language_code": "en"}) + await set_stack.put({"language_code": "de"}) + await get_stack.put({"name": default_name, "language_code": None}) + await get_stack.put({"name": default_name, "language_code": "en"}) + await get_stack.put({"name": default_name, "language_code": "de"}) + + async def post(url, request_data: RequestData, *args, **kwargs): + # The mock-post now just fetches the predefined responses from the queues + if "setMyName" in url: + expected = await set_stack.get() + assert request_data.json_parameters == expected + set_stack.task_done() + return True + + bot_name = await get_stack.get() + if "language_code" in request_data.json_parameters: + assert request_data.json_parameters == {"language_code": bot_name["language_code"]} + else: + assert request_data.json_parameters == {} + get_stack.task_done() + return bot_name + + monkeypatch.setattr(offline_bot.request, "post", post) + + # Set the names + assert all( + await asyncio.gather( + offline_bot.set_my_name(default_name), + offline_bot.set_my_name(en_name, language_code="en"), + offline_bot.set_my_name(de_name, language_code="de"), + ) + ) + + # Check that they were set correctly + assert await asyncio.gather( + offline_bot.get_my_name(), offline_bot.get_my_name("en"), offline_bot.get_my_name("de") + ) == [ + BotName(default_name), + BotName(en_name), + BotName(de_name), + ] + + # Delete the names + assert all( + await asyncio.gather( + offline_bot.set_my_name(default_name), + offline_bot.set_my_name(None, language_code="en"), + offline_bot.set_my_name(None, language_code="de"), + ) + ) + + # Check that they were deleted correctly + assert await asyncio.gather( + offline_bot.get_my_name(), offline_bot.get_my_name("en"), offline_bot.get_my_name("de") + ) == 3 * [BotName(default_name)] + + async def test_set_message_reaction(self, offline_bot, monkeypatch): + """This is here so we can test the convenient conversion we do in the function without + having to do multiple requests to Telegram""" + + expected_param = [ + [{"emoji": ReactionEmoji.THUMBS_UP, "type": "emoji"}], + [{"emoji": ReactionEmoji.RED_HEART, "type": "emoji"}], + [{"custom_emoji_id": "custom_emoji_1", "type": "custom_emoji"}], + [{"custom_emoji_id": "custom_emoji_2", "type": "custom_emoji"}], + [{"emoji": ReactionEmoji.THUMBS_DOWN, "type": "emoji"}], + [{"custom_emoji_id": "custom_emoji_3", "type": "custom_emoji"}], + [ + {"emoji": ReactionEmoji.RED_HEART, "type": "emoji"}, + {"custom_emoji_id": "custom_emoji_4", "type": "custom_emoji"}, + {"emoji": ReactionEmoji.THUMBS_DOWN, "type": "emoji"}, + {"custom_emoji_id": "custom_emoji_5", "type": "custom_emoji"}, + ], + [], + ] + + amount = 0 + + async def post(url, request_data: RequestData, *args, **kwargs): + # The mock-post now just fetches the predefined responses from the queues + assert request_data.json_parameters["chat_id"] == "1" + assert request_data.json_parameters["message_id"] == "2" + assert request_data.json_parameters["is_big"] + nonlocal amount + assert request_data.parameters["reaction"] == expected_param[amount] + amount += 1 + + monkeypatch.setattr(offline_bot.request, "post", post) + await offline_bot.set_message_reaction( + 1, 2, [ReactionTypeEmoji(ReactionEmoji.THUMBS_UP)], True + ) + await offline_bot.set_message_reaction( + 1, 2, ReactionTypeEmoji(ReactionEmoji.RED_HEART), True + ) + await offline_bot.set_message_reaction( + 1, 2, [ReactionTypeCustomEmoji("custom_emoji_1")], True + ) + await offline_bot.set_message_reaction( + 1, 2, ReactionTypeCustomEmoji("custom_emoji_2"), True + ) + await offline_bot.set_message_reaction(1, 2, ReactionEmoji.THUMBS_DOWN, True) + await offline_bot.set_message_reaction(1, 2, "custom_emoji_3", True) + await offline_bot.set_message_reaction( + 1, + 2, + [ + ReactionTypeEmoji(ReactionEmoji.RED_HEART), + ReactionTypeCustomEmoji("custom_emoji_4"), + ReactionEmoji.THUMBS_DOWN, + ReactionTypeCustomEmoji("custom_emoji_5"), + ], + True, + ) + + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_message_default_quote_parse_mode( + self, default_bot, chat_id, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() + + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_message(chat_id, "test", reply_parameters=ReplyParameters(**kwargs)) + + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, "NOTHING"), + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_poll_default_text_question_parse_mode( + self, default_bot, raw_bot, chat_id, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + expected = default_bot.defaults.text_parse_mode if custom == "NOTHING" else custom + + option_1 = request_data.parameters["options"][0] + option_2 = request_data.parameters["options"][1] + assert option_1.get("text_parse_mode") == (default_bot.defaults.text_parse_mode) + assert option_2.get("text_parse_mode") == expected + assert request_data.parameters.get("question_parse_mode") == expected + + return make_message("dummy reply").to_dict() + + async def make_raw_assertion(url, request_data: RequestData, *args, **kwargs): + expected = None if custom == "NOTHING" else custom + + option_1 = request_data.parameters["options"][0] + option_2 = request_data.parameters["options"][1] + assert option_1.get("text_parse_mode") is None + assert option_2.get("text_parse_mode") == expected + + assert request_data.parameters.get("question_parse_mode") == expected + + return make_message("dummy reply").to_dict() + + if custom == "NOTHING": + option_2 = InputPollOption("option2") + kwargs = {} + else: + option_2 = InputPollOption("option2", text_parse_mode=custom) + kwargs = {"question_parse_mode": custom} + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_poll( + chat_id, question="question", options=["option1", option_2], **kwargs + ) + + monkeypatch.setattr(raw_bot.request, "post", make_raw_assertion) + await raw_bot.send_poll( + chat_id, question="question", options=["option1", option_2], **kwargs + ) + + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_poll_default_quote_parse_mode( + self, default_bot, chat_id, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() + + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_poll( + chat_id, + question="question", + options=["option1", "option2"], + reply_parameters=ReplyParameters(**kwargs), + ) + + async def test_send_poll_question_parse_mode_entities(self, offline_bot, monkeypatch): + # Currently only custom emoji are supported as entities which we can't test + # We just test that the correct data is passed for now + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["question_entities"] == [ + {"type": "custom_emoji", "offset": 0, "length": 1}, + {"type": "custom_emoji", "offset": 2, "length": 1}, + ] + assert request_data.parameters["question_parse_mode"] == ParseMode.MARKDOWN_V2 + return make_message("dummy reply").to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + await offline_bot.send_poll( + 1, + question="😀😃", + options=["option1", "option2"], + question_entities=[ + MessageEntity(MessageEntity.CUSTOM_EMOJI, 0, 1), + MessageEntity(MessageEntity.CUSTOM_EMOJI, 2, 1), + ], + question_parse_mode=ParseMode.MARKDOWN_V2, + ) + + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_game_default_quote_parse_mode( + self, default_bot, chat_id, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() + + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_game( + chat_id, "game_short_name", reply_parameters=ReplyParameters(**kwargs) + ) + + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_copy_message_default_quote_parse_mode( + self, default_bot, chat_id, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() + + kwargs = {"message_id": 1} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.copy_message(chat_id, 1, 1, reply_parameters=ReplyParameters(**kwargs)) + + async def test_do_api_request_camel_case_conversion(self, offline_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + return url.endswith("camelCase") + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.do_api_request("camel_case") + + @pytest.mark.filterwarnings("ignore::telegram.warnings.PTBUserWarning") + async def test_do_api_request_media_write_timeout(self, offline_bot, chat_id, monkeypatch): + test_flag = None + + class CustomRequest(BaseRequest): + async def initialize(self_) -> None: + pass + + async def shutdown(self_) -> None: + pass + + async def do_request(self_, *args, **kwargs) -> tuple[int, bytes]: + nonlocal test_flag + test_flag = ( + kwargs.get("read_timeout"), + kwargs.get("connect_timeout"), + kwargs.get("write_timeout"), + kwargs.get("pool_timeout"), + ) + return HTTPStatus.OK, b'{"ok": "True", "result": {}}' + + @property + def read_timeout(self): + return 1 + + custom_request = CustomRequest() + + offline_bot = Bot(offline_bot.token, request=custom_request) + await offline_bot.do_api_request( + "send_document", + api_kwargs={ + "chat_id": chat_id, + "caption": "test_caption", + "document": InputFile(data_file("telegram.png").open("rb")), + }, + ) + assert test_flag == ( + DEFAULT_NONE, + DEFAULT_NONE, + DEFAULT_NONE, + DEFAULT_NONE, + ) + + @pytest.mark.filterwarnings("ignore::telegram.warnings.PTBUserWarning") + async def test_do_api_request_default_timezone(self, tz_bot, monkeypatch): + until = dtm.datetime(2020, 1, 11, 16, 13) + until_timestamp = to_timestamp(until, tzinfo=tz_bot.defaults.tzinfo) + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + data = request_data.parameters + chat_id = data["chat_id"] == 2 + user_id = data["user_id"] == 32 + until_date = data.get("until_date", until_timestamp) == until_timestamp + return chat_id and user_id and until_date + + monkeypatch.setattr(tz_bot.request, "post", make_assertion) + + assert await tz_bot.do_api_request( + "banChatMember", api_kwargs={"chat_id": 2, "user_id": 32} + ) + assert await tz_bot.do_api_request( + "banChatMember", api_kwargs={"chat_id": 2, "user_id": 32, "until_date": until} + ) + assert await tz_bot.do_api_request( + "banChatMember", + api_kwargs={"chat_id": 2, "user_id": 32, "until_date": until_timestamp}, + ) + + async def test_business_connection_id_argument( + self, offline_bot, monkeypatch, dummy_message_dict + ): + """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 a few. Our linting will + catch any unused args with the others.""" + return_values = asyncio.Queue() + await return_values.put(dummy_message_dict) + await return_values.put( + Poll( + id="42", + question="question", + options=[PollOption("option", 0)], + total_voter_count=5, + is_closed=True, + is_anonymous=True, + type="regular", + allows_multiple_answers=False, + ).to_dict() + ) + await return_values.put(True) + await return_values.put(True) + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("business_connection_id") == 42 + return await return_values.get() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + await offline_bot.send_message(2, "text", business_connection_id=42) + await offline_bot.stop_poll(chat_id=1, message_id=2, business_connection_id=42) + await offline_bot.pin_chat_message(chat_id=1, message_id=2, business_connection_id=42) + await offline_bot.unpin_chat_message(chat_id=1, business_connection_id=42) + + async def test_message_effect_id_argument(self, offline_bot, monkeypatch): + """We can't test every single method easily, so we just test one. 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("message_effect_id") == 42 + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_message(2, "text", message_effect_id=42) + + async def test_allow_paid_broadcast_argument(self, offline_bot, monkeypatch): + """We can't test every single method easily, so we just test one. 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("allow_paid_broadcast") == 42 + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_message(2, "text", allow_paid_broadcast=42) + + async def test_direct_messages_topic_id_argument(self, offline_bot, monkeypatch): + """We can't test every single method easily, so we just test one. 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("direct_messages_topic_id") == 42 + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_message(2, "text", direct_messages_topic_id=42) + + async def test_suggested_post_parameters_argument(self, offline_bot, monkeypatch): + """We can't test every single method easily, so we just test one. Our linting will catch + any unused args with the others.""" + suggested_post_parameters = SuggestedPostParameters(price=SuggestedPostPrice("TON", 10)) + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + return ( + request_data.parameters.get("suggested_post_parameters") + == suggested_post_parameters.to_dict() + ) + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_message( + 2, "text", suggested_post_parameters=suggested_post_parameters + ) + + async def test_send_chat_action_all_args(self, bot, chat_id, monkeypatch): + async def make_assertion(*args, **_): + kwargs = args[1] + return ( + kwargs["chat_id"] == chat_id + and kwargs["action"] == "action" + and kwargs["message_thread_id"] == 1 + and kwargs["business_connection_id"] == 3 + ) + + monkeypatch.setattr(bot, "_post", make_assertion) + assert await bot.send_chat_action(chat_id, "action", 1, 3) + + async def test_gift_premium_subscription_all_args(self, bot, monkeypatch): + # can't make actual request so we just test that the correct data is passed + async def make_assertion(*args, **_): + kwargs = args[1] + return ( + kwargs.get("user_id") == 12 + and kwargs.get("month_count") == 3 + and kwargs.get("star_count") == 1000 + and kwargs.get("text") == "test text" + and kwargs.get("text_parse_mode") == "Markdown" + and kwargs.get("text_entities") + == [ + MessageEntity(MessageEntity.BOLD, 0, 3), + MessageEntity(MessageEntity.ITALIC, 5, 11), + ] + ) + + monkeypatch.setattr(bot, "_post", make_assertion) + assert await bot.gift_premium_subscription( + user_id=12, + month_count=3, + star_count=1000, + text="test text", + text_parse_mode="Markdown", + text_entities=[ + MessageEntity(MessageEntity.BOLD, 0, 3), + MessageEntity(MessageEntity.ITALIC, 5, 11), + ], + ) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, "Markdown"), ("HTML", "HTML"), (None, None)], + ) + async def test_gift_premium_subscription_default_parse_mode( + self, default_bot, monkeypatch, passed_value, expected_value + ): + # can't make actual request so we just test that the correct data is passed + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("text_parse_mode") == expected_value + return True + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "user_id": 123, + "month_count": 3, + "star_count": 1000, + "text": "text", + } + if passed_value is not DEFAULT_NONE: + kwargs["text_parse_mode"] = passed_value + + assert await default_bot.gift_premium_subscription(**kwargs) + + async def test_refund_star_payment(self, offline_bot, monkeypatch): + # can't make actual request so we just test that the correct data is passed + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + return ( + request_data.parameters.get("user_id") == 42 + and request_data.parameters.get("telegram_payment_charge_id") == "37" + ) + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.refund_star_payment(42, "37") + + async def test_get_star_transactions(self, offline_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(offline_bot.request, "do_request", do_request) + obj = await offline_bot.get_star_transactions(offset=3) + assert isinstance(obj, StarTransactions) + + async def test_edit_user_star_subscription(self, offline_bot, monkeypatch): + """Can't properly test, so we only check that the correct values are passed""" + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + return ( + request_data.parameters.get("user_id") == 42 + and request_data.parameters.get("telegram_payment_charge_id") + == "telegram_payment_charge_id" + and request_data.parameters.get("is_canceled") is False + ) + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.edit_user_star_subscription( + 42, "telegram_payment_charge_id", False + ) + + async def test_create_chat_subscription_invite_link( + self, + monkeypatch, + offline_bot, + ): + # Since the chat invite link object does not say if the sub args are passed we can + # only check here + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("subscription_period") == 2592000 + assert request_data.parameters.get("subscription_price") == 6 + return ChatInviteLink( + "https://t.me/joinchat/invite_link", User(1, "first", False), False, False, False + ).to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + await offline_bot.create_chat_subscription_invite_link(1234, 2592000, 6) + + @pytest.mark.parametrize( + "expiration_date", [dtm.datetime(2024, 1, 1), 1704067200], ids=["datetime", "timestamp"] + ) + async def test_set_user_emoji_status_basic(self, offline_bot, monkeypatch, expiration_date): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("user_id") == 4242 + assert ( + request_data.parameters.get("emoji_status_custom_emoji_id") + == "emoji_status_custom_emoji_id" + ) + assert request_data.parameters.get("emoji_status_expiration_date") == 1704067200 + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + await offline_bot.set_user_emoji_status( + 4242, "emoji_status_custom_emoji_id", expiration_date + ) + + async def test_set_user_emoji_status_default_timezone(self, tz_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("user_id") == 4242 + assert ( + request_data.parameters.get("emoji_status_custom_emoji_id") + == "emoji_status_custom_emoji_id" + ) + assert request_data.parameters.get("emoji_status_expiration_date") == to_timestamp( + dtm.datetime(2024, 1, 1), tzinfo=tz_bot.defaults.tzinfo + ) + + monkeypatch.setattr(tz_bot.request, "post", make_assertion) + await tz_bot.set_user_emoji_status( + 4242, "emoji_status_custom_emoji_id", dtm.datetime(2024, 1, 1) + ) - async def test_set_get_my_name(self, bot, monkeypatch): - """We only test that we pass the correct values to TG since this endpoint is heavily - rate limited which makes automated tests rather infeasible.""" - default_name = "default_bot_name" - en_name = "en_bot_name" - de_name = "de_bot_name" + async def test_verify_user(self, offline_bot, monkeypatch): + "No way to test this without getting verified" - # We predefine the responses that we would TG expect to send us - set_stack = asyncio.Queue() - get_stack = asyncio.Queue() - await set_stack.put({"name": default_name}) - await set_stack.put({"name": en_name, "language_code": "en"}) - await set_stack.put({"name": de_name, "language_code": "de"}) - await get_stack.put({"name": default_name, "language_code": None}) - await get_stack.put({"name": en_name, "language_code": "en"}) - await get_stack.put({"name": de_name, "language_code": "de"}) + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("user_id") == 1234 + assert request_data.parameters.get("custom_description") == "this is so custom" - await set_stack.put({"name": default_name}) - await set_stack.put({"language_code": "en"}) - await set_stack.put({"language_code": "de"}) - await get_stack.put({"name": default_name, "language_code": None}) - await get_stack.put({"name": default_name, "language_code": "en"}) - await get_stack.put({"name": default_name, "language_code": "de"}) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - async def post(url, request_data: RequestData, *args, **kwargs): - # The mock-post now just fetches the predefined responses from the queues - if "setMyName" in url: - expected = await set_stack.get() - assert request_data.json_parameters == expected - set_stack.task_done() - return True + await offline_bot.verify_user(1234, "this is so custom") - bot_name = await get_stack.get() - if "language_code" in request_data.json_parameters: - assert request_data.json_parameters == {"language_code": bot_name["language_code"]} - else: - assert request_data.json_parameters == {} - get_stack.task_done() - return bot_name + async def test_verify_chat(self, offline_bot, monkeypatch): + "No way to test this without getting verified" - monkeypatch.setattr(bot.request, "post", post) + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("chat_id") == 1234 + assert request_data.parameters.get("custom_description") == "this is so custom" - # Set the names - assert all( - await asyncio.gather( - bot.set_my_name(default_name), - bot.set_my_name(en_name, language_code="en"), - bot.set_my_name(de_name, language_code="de"), - ) - ) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) - # Check that they were set correctly - assert await asyncio.gather( - bot.get_my_name(), bot.get_my_name("en"), bot.get_my_name("de") - ) == [ - BotName(default_name), - BotName(en_name), - BotName(de_name), - ] + await offline_bot.verify_chat(1234, "this is so custom") - # Delete the names - assert all( - await asyncio.gather( - bot.set_my_name(default_name), - bot.set_my_name(None, language_code="en"), - bot.set_my_name(None, language_code="de"), - ) + async def test_unverify_user(self, offline_bot, monkeypatch): + "No way to test this without getting verified" + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("user_id") == 1234 + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + await offline_bot.remove_user_verification(1234) + + async def test_unverify_chat(self, offline_bot, monkeypatch): + "No way to test this without getting verified" + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("chat_id") == 1234 + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + await offline_bot.remove_chat_verification(1234) + + async def test_get_my_star_balance(self, offline_bot, monkeypatch): + sa = StarAmount(1000).to_json() + + async def do_request(url, request_data: RequestData, *args, **kwargs): + assert not request_data.parameters + return 200, f'{{"ok": true, "result": {sa}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", do_request) + obj = await offline_bot.get_my_star_balance() + assert isinstance(obj, StarAmount) + + async def test_approve_suggested_post(self, offline_bot, monkeypatch): + "No way to test this without receiving suggested posts" + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + data = request_data.json_parameters + chat_id = data.get("chat_id") == "1234" + message_id = data.get("message_id") == "5678" + send_date = data.get("send_date", "1577887200") == "1577887200" + return chat_id and message_id and send_date + + until = from_timestamp(1577887200) + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + assert await offline_bot.approve_suggested_post(1234, 5678, 1577887200) + assert await offline_bot.approve_suggested_post(1234, 5678, until) + + async def test_approve_suggested_post_with_tz(self, monkeypatch, tz_bot): + until = dtm.datetime(2020, 1, 11, 16, 13) + until_timestamp = to_timestamp(until, tzinfo=tz_bot.defaults.tzinfo) + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + data = request_data.parameters + chat_id = data["chat_id"] == 2 + message_id = data["message_id"] == 32 + until_date = data.get("until_date", until_timestamp) == until_timestamp + return chat_id and message_id and until_date + + monkeypatch.setattr(tz_bot.request, "post", make_assertion) + + assert await tz_bot.approve_suggested_post(2, 32) + assert await tz_bot.approve_suggested_post(2, 32, send_date=until) + assert await tz_bot.approve_suggested_post(2, 32, send_date=until_timestamp) + + async def test_decline_suggested_post(self, offline_bot, monkeypatch): + "No way to test this without receiving suggested posts" + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("chat_id") == 1234 + assert request_data.parameters.get("message_id") == 5678 + assert request_data.parameters.get("comment") == "declined" + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + await offline_bot.decline_suggested_post(1234, 5678, "declined") + + async def test_get_user_gifts_parameter_passing(self, offline_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + for param in ( + "user_id", + "exclude_unlimited", + "exclude_limited_upgradable", + "exclude_limited_non_upgradable", + "exclude_from_blockchain", + "exclude_unique", + "sort_by_price", + "offset", + "limit", + ): + assert request_data.parameters.get(param) == param + + return OwnedGifts(0, [], "null").to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + await offline_bot.get_user_gifts( + user_id="user_id", + exclude_unlimited="exclude_unlimited", + exclude_limited_upgradable="exclude_limited_upgradable", + exclude_limited_non_upgradable="exclude_limited_non_upgradable", + exclude_from_blockchain="exclude_from_blockchain", + exclude_unique="exclude_unique", + sort_by_price="sort_by_price", + offset="offset", + limit="limit", ) - # Check that they were deleted correctly - assert await asyncio.gather( - bot.get_my_name(), bot.get_my_name("en"), bot.get_my_name("de") - ) == 3 * [BotName(default_name)] + async def test_get_chat_gifts_parameter_passing(self, offline_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + for param in ( + "chat_id", + "exclude_saved", + "exclude_unsaved", + "exclude_unlimited", + "exclude_limited_upgradable", + "exclude_limited_non_upgradable", + "exclude_from_blockchain", + "exclude_unique", + "sort_by_price", + "offset", + "limit", + ): + assert request_data.parameters.get(param) == param + + return OwnedGifts(0, [], "null").to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + await offline_bot.get_chat_gifts( + chat_id="chat_id", + exclude_saved="exclude_saved", + exclude_unsaved="exclude_unsaved", + exclude_unlimited="exclude_unlimited", + exclude_limited_upgradable="exclude_limited_upgradable", + exclude_limited_non_upgradable="exclude_limited_non_upgradable", + exclude_from_blockchain="exclude_from_blockchain", + exclude_unique="exclude_unique", + sort_by_price="sort_by_price", + offset="offset", + limit="limit", + ) class TestBotWithRequest: @@ -1819,8 +2843,11 @@ class TestBotWithRequest: is tested in `test_callbackdatacache` """ + # get_available_gifts, send_gift are tested in `test_gift`. + # No need to duplicate here. + async def test_invalid_token_server_response(self): - with pytest.raises(InvalidToken, match="The token `12` was rejected by the server."): + with pytest.raises(InvalidToken, match="The token `12` was rejected by the server\\."): async with ExtBot(token="12"): pass @@ -1832,14 +2859,14 @@ async def test_multiple_init_cycles(self, bot): async with test_bot: await test_bot.get_me() - async def test_forward_message(self, bot, chat_id, message): + async def test_forward_message(self, bot, chat_id, static_message): forward_message = await bot.forward_message( - chat_id, from_chat_id=chat_id, message_id=message.message_id + chat_id, from_chat_id=chat_id, message_id=static_message.message_id ) - assert forward_message.text == message.text - assert forward_message.forward_from.username == message.from_user.username - assert isinstance(forward_message.forward_date, dtm.datetime) + assert forward_message.text == static_message.text + assert forward_message.forward_origin.sender_user == static_message.from_user + assert isinstance(forward_message.forward_origin.date, dtm.datetime) async def test_forward_protected_message(self, bot, chat_id): tasks = asyncio.gather( @@ -1864,10 +2891,40 @@ async def test_forward_protected_message(self, bot, chat_id): result = await tasks assert all("can't be forwarded" in str(exc) for exc in result) + async def test_forward_messages(self, bot, chat_id): + # not using gather here to have deteriminically ordered message_ids + msg1 = await bot.send_message(chat_id, text="will be forwarded") + msg2 = await bot.send_message(chat_id, text="will be forwarded") + + forward_messages = await bot.forward_messages( + chat_id, from_chat_id=chat_id, message_ids=(msg1.message_id, msg2.message_id) + ) + + assert isinstance(forward_messages, tuple) + + tasks = asyncio.gather( + bot.send_message( + chat_id, "temp 1", reply_to_message_id=forward_messages[0].message_id + ), + bot.send_message( + chat_id, "temp 2", reply_to_message_id=forward_messages[1].message_id + ), + ) + + temp_msg1, temp_msg2 = await tasks + forward_msg1 = temp_msg1.reply_to_message + forward_msg2 = temp_msg2.reply_to_message + + assert forward_msg1.text == msg1.text + assert forward_msg1.forward_origin.sender_user == msg1.from_user + assert isinstance(forward_msg1.forward_origin.date, dtm.datetime) + + assert forward_msg2.text == msg2.text + assert forward_msg2.forward_origin.sender_user == msg2.from_user + assert isinstance(forward_msg2.forward_origin.date, dtm.datetime) + async def test_delete_message(self, bot, chat_id): message = await bot.send_message(chat_id, text="will be deleted") - await asyncio.sleep(2) - assert await bot.delete_message(chat_id=chat_id, message_id=message.message_id) is True async def test_delete_message_old_message(self, bot, chat_id): @@ -1876,8 +2933,19 @@ async def test_delete_message_old_message(self, bot, chat_id): await bot.delete_message(chat_id=chat_id, message_id=1) # send_photo, send_audio, send_document, send_sticker, send_video, send_voice, send_video_note, - # send_media_group and send_animation are tested in their respective test modules. No need to - # duplicate here. + # send_media_group, send_animation, get_user_chat_boosts are tested in their respective + # test modules. No need to duplicate here. + + async def test_delete_messages(self, bot, chat_id): + 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=sorted((msg1.id, msg2.id))) + is True + ) async def test_send_venue(self, bot, chat_id): longitude = -46.788279 @@ -1948,18 +3016,6 @@ async def test_send_contact(self, bot, chat_id): assert message.contact.last_name == last_name assert message.has_protected_content - async def test_send_chat_action_all_args(self, bot, chat_id, monkeypatch): - async def make_assertion(*args, **_): - kwargs = args[1] - return ( - kwargs["chat_id"] == chat_id - and kwargs["action"] == "action" - and kwargs["message_thread_id"] == 1 - ) - - monkeypatch.setattr(bot, "_post", make_assertion) - assert await bot.send_chat_action(chat_id, "action", 1) - # TODO: Add bot to group to test polls too @pytest.mark.parametrize( "reply_markup", @@ -1975,7 +3031,7 @@ async def make_assertion(*args, **_): ) async def test_send_and_stop_poll(self, bot, super_group_id, reply_markup): question = "Is this a test?" - answers = ["Yes", "No", "Maybe"] + answers = ["Yes", InputPollOption("No"), "Maybe"] explanation = "[Here is a link](https://google.com)" explanation_entities = [ MessageEntity(MessageEntity.TEXT_LINK, 0, 14, url="https://google.com") @@ -2009,7 +3065,7 @@ async def test_send_and_stop_poll(self, bot, super_group_id, reply_markup): assert message.poll assert message.poll.question == question assert message.poll.options[0].text == answers[0] - assert message.poll.options[1].text == answers[1] + assert message.poll.options[1].text == answers[1].text assert message.poll.options[2].text == answers[2] assert not message.poll.is_anonymous assert message.poll.allows_multiple_answers @@ -2029,7 +3085,7 @@ async def test_send_and_stop_poll(self, bot, super_group_id, reply_markup): assert poll.is_closed assert poll.options[0].text == answers[0] assert poll.options[0].voter_count == 0 - assert poll.options[1].text == answers[1] + assert poll.options[1].text == answers[1].text assert poll.options[1].voter_count == 0 assert poll.options[2].text == answers[2] assert poll.options[2].voter_count == 0 @@ -2046,7 +3102,9 @@ async def test_send_and_stop_poll(self, bot, super_group_id, reply_markup): assert quiz_task.done() @pytest.mark.parametrize( - ("open_period", "close_date"), [(5, None), (None, True)], ids=["open_period", "close_date"] + ("open_period", "close_date"), + [(5, None), (dtm.timedelta(seconds=5), None), (None, True)], + ids=["open_period", "open_period-dtm", "close_date"], ) async def test_send_open_period(self, bot, super_group_id, open_period, close_date): question = "Is this a test?" @@ -2201,7 +3259,7 @@ async def test_send_poll_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="message not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_poll( chat_id, question=question, @@ -2258,7 +3316,7 @@ async def test_send_dice_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="message not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_dice( chat_id, reply_to_message_id=reply_to_message.message_id ) @@ -2281,11 +3339,20 @@ async def test_wrong_chat_action(self, bot, chat_id): await bot.send_chat_action(chat_id, "unknown action") async def test_answer_inline_query_current_offset_error(self, bot, inline_results): - with pytest.raises(ValueError, match=("`current_offset` and `next_offset`")): + with pytest.raises(ValueError, match="`current_offset` and `next_offset`"): await bot.answer_inline_query( 1234, results=inline_results, next_offset=42, current_offset=51 ) + async def test_save_prepared_inline_message(self, bot, chat_id): + # We can't really check that the result is stored correctly, we just ensur ethat we get + # a proper return value + result = InlineQueryResultArticle( + id="some_id", title="title", input_message_content=InputTextMessageContent("text") + ) + out = await bot.save_prepared_inline_message(chat_id, result, True, False, True, False) + assert isinstance(out, PreparedInlineMessage) + async def test_get_user_profile_photos(self, bot, chat_id): user_profile_photos = await bot.get_user_profile_photos(chat_id) assert user_profile_photos.photos[0][0].file_size == 5403 @@ -2295,18 +3362,18 @@ async def test_get_one_user_profile_photo(self, bot, chat_id): assert user_profile_photos.total_count == 1 assert user_profile_photos.photos[0][0].file_size == 5403 - async def test_edit_message_text(self, bot, message): + async def test_edit_message_text(self, bot, one_time_message): message = await bot.edit_message_text( text="new_text", - chat_id=message.chat_id, - message_id=message.message_id, + chat_id=one_time_message.chat_id, + message_id=one_time_message.message_id, parse_mode="HTML", disable_web_page_preview=True, ) assert message.text == "new_text" - async def test_edit_message_text_entities(self, bot, message): + async def test_edit_message_text_entities(self, bot, one_time_message): test_string = "Italic Bold Code" entities = [ MessageEntity(MessageEntity.ITALIC, 0, 6), @@ -2315,8 +3382,8 @@ async def test_edit_message_text_entities(self, bot, message): ] message = await bot.edit_message_text( text=test_string, - chat_id=message.chat_id, - message_id=message.message_id, + chat_id=one_time_message.chat_id, + message_id=one_time_message.message_id, entities=entities, ) @@ -2324,14 +3391,16 @@ async def test_edit_message_text_entities(self, bot, message): assert message.entities == tuple(entities) @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) - async def test_edit_message_text_default_parse_mode(self, default_bot, message): + async def test_edit_message_text_default_parse_mode( + self, default_bot, chat_id, one_time_message + ): test_string = "Italic Bold Code" test_markdown_string = "_Italic_ *Bold* `Code`" message = await default_bot.edit_message_text( text=test_markdown_string, - chat_id=message.chat_id, - message_id=message.message_id, + chat_id=one_time_message.chat_id, + message_id=one_time_message.message_id, disable_web_page_preview=True, ) assert message.text_markdown == test_markdown_string @@ -2347,21 +3416,16 @@ async def test_edit_message_text_default_parse_mode(self, default_bot, message): assert message.text == test_markdown_string assert message.text_markdown == escape_markdown(test_markdown_string) + suffix = " edited" message = await default_bot.edit_message_text( - text=test_markdown_string, - chat_id=message.chat_id, - message_id=message.message_id, - disable_web_page_preview=True, - ) - message = await default_bot.edit_message_text( - text=test_markdown_string, + text=test_markdown_string + suffix, chat_id=message.chat_id, message_id=message.message_id, parse_mode="HTML", disable_web_page_preview=True, ) - assert message.text == test_markdown_string - assert message.text_markdown == escape_markdown(test_markdown_string) + assert message.text == test_markdown_string + suffix + assert message.text_markdown == escape_markdown(test_markdown_string) + suffix @pytest.mark.skip(reason="need reference to an inline message") async def test_edit_message_text_inline(self): @@ -2372,9 +3436,11 @@ async def test_edit_message_caption(self, bot, media_message): caption="new_caption", chat_id=media_message.chat_id, message_id=media_message.message_id, + show_caption_above_media=False, ) assert message.caption == "new_caption" + assert not message.show_caption_above_media async def test_edit_message_caption_entities(self, bot, media_message): test_string = "Italic Bold Code" @@ -2445,10 +3511,12 @@ async def test_edit_message_caption_with_parse_mode(self, bot, media_message): async def test_edit_message_caption_inline(self): pass - async def test_edit_reply_markup(self, bot, message): + async def test_edit_reply_markup(self, bot, one_time_message): new_markup = InlineKeyboardMarkup([[InlineKeyboardButton(text="test", callback_data="1")]]) message = await bot.edit_message_reply_markup( - chat_id=message.chat_id, message_id=message.message_id, reply_markup=new_markup + chat_id=one_time_message.chat_id, + message_id=one_time_message.message_id, + reply_markup=new_markup, ) assert message is not True @@ -2457,17 +3525,46 @@ async def test_edit_reply_markup(self, bot, message): async def test_edit_reply_markup_inline(self): pass - @pytest.mark.xdist_group("getUpdates_and_webhook") # TODO: Actually send updates to the test bot so this can be tested properly - async def test_get_updates(self, bot): + @pytest.mark.parametrize("timeout", [1, dtm.timedelta(seconds=1)]) + async def test_get_updates(self, bot, timeout): await bot.delete_webhook() # make sure there is no webhook set if webhook tests failed - updates = await bot.get_updates(timeout=1) + updates = await bot.get_updates(timeout=timeout) assert isinstance(updates, tuple) if updates: assert isinstance(updates[0], Update) - @pytest.mark.xdist_group("getUpdates_and_webhook") + @pytest.mark.parametrize( + ("read_timeout", "timeout", "expected"), + [ + (None, None, 0), + (1, None, 1), + (None, 1, 1), + (None, dtm.timedelta(seconds=1), 1), + (DEFAULT_NONE, None, 10), + (DEFAULT_NONE, 1, 11), + (DEFAULT_NONE, dtm.timedelta(seconds=1), 11), + (1, 2, 3), + (1, dtm.timedelta(seconds=2), 3), + ], + ) + async def test_get_updates_read_timeout_value_passing( + self, bot, read_timeout, timeout, expected, monkeypatch + ): + caught_read_timeout = None + + async def catch_timeouts(*args, **kwargs): + nonlocal caught_read_timeout + caught_read_timeout = kwargs.get("read_timeout") + return HTTPStatus.OK, b'{"ok": "True", "result": {}}' + + monkeypatch.setattr(HTTPXRequest, "do_request", catch_timeouts) + + bot = Bot(get_updates_request=HTTPXRequest(read_timeout=10), token=bot.token) + await bot.get_updates(read_timeout=read_timeout, timeout=timeout) + assert caught_read_timeout == expected + @pytest.mark.parametrize("use_ip", [True, False]) # local file path as file_input is tested below in test_set_webhook_params @pytest.mark.parametrize("file_input", ["bytes", "file_handle"]) @@ -2509,14 +3606,11 @@ async def test_leave_chat(self, bot): with pytest.raises(BadRequest, match="Chat not found"): await bot.leave_chat(-123456) - with pytest.raises(NetworkError, match="Chat not found"): - await bot.leave_chat(-123456) - async def test_get_chat(self, bot, super_group_id): - chat = await bot.get_chat(super_group_id) - assert chat.type == "supergroup" - assert chat.title == f">>> telegram.Bot(test) @{bot.username}" - assert chat.id == int(super_group_id) + cfi = await bot.get_chat(super_group_id) + assert cfi.type == "supergroup" + assert cfi.title == f">>> telegram.Bot(test) @{bot.username}" + assert cfi.id == int(super_group_id) async def test_get_chat_administrators(self, bot, channel_id): admins = await bot.get_chat_administrators(channel_id) @@ -2533,7 +3627,7 @@ async def test_get_chat_member_count(self, bot, channel_id): async def test_get_chat_member(self, bot, channel_id, chat_id): chat_member = await bot.get_chat_member(channel_id, chat_id) - assert chat_member.status == "administrator" + assert chat_member.status == "creator" assert chat_member.user.first_name == "PTB" assert chat_member.user.last_name == "Test user" @@ -2550,8 +3644,9 @@ async def test_send_game(self, bot, chat_id): message = await bot.send_game(chat_id, game_short_name, protect_content=True) assert message.game - assert message.game.description == ( - "A no-op test game, for python-telegram-bot bot framework testing." + assert ( + message.game.description + == "A no-op test game, for python-telegram-bot bot framework testing." ) assert message.game.animation.file_id # We added some test bots later and for some reason the file size is not the same for them @@ -2590,7 +3685,7 @@ async def test_send_game_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="message not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_game( chat_id, game_short_name, reply_to_message_id=reply_to_message.message_id ) @@ -2604,10 +3699,8 @@ async def test_send_game_default_protect_content(self, default_bot, chat_id, val protected = await default_bot.send_game(chat_id, "test_game", protect_content=val) assert protected.has_protected_content is val - @pytest.mark.xdist_group("game") @xfail - async def test_set_game_score_1(self, bot, chat_id): - # NOTE: numbering of methods assures proper order between test_set_game_scoreX methods + async def test_set_game_score_and_high_scores(self, bot, chat_id): # First, test setting a score. game_short_name = "test_game" game = await bot.send_game(chat_id, game_short_name) @@ -2624,10 +3717,6 @@ async def test_set_game_score_1(self, bot, chat_id): assert message.game.animation.file_unique_id == game.game.animation.file_unique_id assert message.game.text != game.game.text - @pytest.mark.xdist_group("game") - @xfail - async def test_set_game_score_2(self, bot, chat_id): - # NOTE: numbering of methods assures proper order between test_set_game_scoreX methods # Test setting a score higher than previous game_short_name = "test_game" game = await bot.send_game(chat_id, game_short_name) @@ -2647,10 +3736,6 @@ async def test_set_game_score_2(self, bot, chat_id): assert message.game.animation.file_unique_id == game.game.animation.file_unique_id assert message.game.text == game.game.text - @pytest.mark.xdist_group("game") - @xfail - async def test_set_game_score_3(self, bot, chat_id): - # NOTE: numbering of methods assures proper order between test_set_game_scoreX methods # Test setting a score lower than previous (should raise error) game_short_name = "test_game" game = await bot.send_game(chat_id, game_short_name) @@ -2662,10 +3747,6 @@ async def test_set_game_score_3(self, bot, chat_id): user_id=chat_id, score=score, chat_id=game.chat_id, message_id=game.message_id ) - @pytest.mark.xdist_group("game") - @xfail - async def test_set_game_score_4(self, bot, chat_id): - # NOTE: numbering of methods assures proper order between test_set_game_scoreX methods # Test force setting a lower score game_short_name = "test_game" game = await bot.send_game(chat_id, game_short_name) @@ -2690,9 +3771,6 @@ async def test_set_game_score_4(self, bot, chat_id): game2 = await bot.send_game(chat_id, game_short_name) assert str(score) in game2.game.text - @pytest.mark.xdist_group("game") - @xfail - async def test_get_game_high_scores(self, bot, chat_id): # We need a game to get the scores for game_short_name = "test_game" game = await bot.send_game(chat_id, game_short_name) @@ -2706,7 +3784,7 @@ async def test_promote_chat_member(self, bot, channel_id, monkeypatch): with pytest.raises(BadRequest, match="Not enough rights"): assert await bot.promote_chat_member( channel_id, - 95205500, + 1325859552, is_anonymous=True, can_change_info=True, can_post_messages=True, @@ -2719,6 +3797,10 @@ async def test_promote_chat_member(self, bot, channel_id, monkeypatch): can_manage_chat=True, can_manage_video_chats=True, can_manage_topics=True, + can_post_stories=True, + can_edit_stories=True, + can_delete_stories=True, + can_manage_direct_messages=True, ) # Test that we pass the correct params to TG @@ -2726,7 +3808,7 @@ async def make_assertion(*args, **_): data = args[1] return ( data.get("chat_id") == channel_id - and data.get("user_id") == 95205500 + and data.get("user_id") == 1325859552 and data.get("is_anonymous") == 1 and data.get("can_change_info") == 2 and data.get("can_post_messages") == 3 @@ -2739,12 +3821,16 @@ async def make_assertion(*args, **_): and data.get("can_manage_chat") == 10 and data.get("can_manage_video_chats") == 11 and data.get("can_manage_topics") == 12 + and data.get("can_post_stories") == 13 + and data.get("can_edit_stories") == 14 + and data.get("can_delete_stories") == 15 + and data.get("can_manage_direct_messages") == 16 ) monkeypatch.setattr(bot, "_post", make_assertion) assert await bot.promote_chat_member( channel_id, - 95205500, + 1325859552, is_anonymous=1, can_change_info=2, can_post_messages=3, @@ -2757,6 +3843,10 @@ async def make_assertion(*args, **_): can_manage_chat=10, can_manage_video_chats=11, can_manage_topics=12, + can_post_stories=13, + can_edit_stories=14, + can_delete_stories=15, + can_manage_direct_messages=16, ) async def test_export_chat_invite_link(self, bot, channel_id): @@ -2804,7 +3894,6 @@ async def test_create_chat_invite_link_basics( ) assert revoked_link.is_revoked - @pytest.mark.skipif(not TEST_WITH_OPT_DEPS, reason="This test's implementation requires pytz") @pytest.mark.parametrize("datetime", argvalues=[True, False], ids=["datetime", "integer"]) async def test_advanced_chat_invite_links(self, bot, channel_id, datetime): # we are testing this all in one function in order to save api calls @@ -2812,7 +3901,7 @@ async def test_advanced_chat_invite_links(self, bot, channel_id, datetime): add_seconds = dtm.timedelta(0, 70) time_in_future = timestamp + add_seconds expire_time = time_in_future if datetime else to_timestamp(time_in_future) - aware_time_in_future = UTC.localize(time_in_future) + aware_time_in_future = localize(time_in_future, UTC) invite_link = await bot.create_chat_invite_link( channel_id, expire_date=expire_time, member_limit=10 @@ -2825,7 +3914,7 @@ async def test_advanced_chat_invite_links(self, bot, channel_id, datetime): add_seconds = dtm.timedelta(0, 80) time_in_future = timestamp + add_seconds expire_time = time_in_future if datetime else to_timestamp(time_in_future) - aware_time_in_future = UTC.localize(time_in_future) + aware_time_in_future = localize(time_in_future, UTC) edited_invite_link = await bot.edit_chat_invite_link( channel_id, @@ -2921,7 +4010,7 @@ async def test_decline_chat_join_request(self, bot, chat_id, channel_id): # # The error message Hide_requester_missing started showing up instead of # User_already_participant. Don't know why … - with pytest.raises(BadRequest, match="User_already_participant|Hide_requester_missing"): + with pytest.raises(BadRequest, match=r"User_already_participant|Hide_requester_missing"): await bot.decline_chat_join_request(chat_id=channel_id, user_id=chat_id) async def test_set_chat_photo(self, bot, channel_id): @@ -2987,11 +4076,124 @@ async def test_pin_and_unpin_message(self, bot, super_group_id): assert await bot.unpin_all_chat_messages(super_group_id, read_timeout=10) # get_sticker_set, upload_sticker_file, create_new_sticker_set, add_sticker_to_set, - # set_sticker_position_in_set, delete_sticker_from_set and get_custom_emoji_stickers - # are tested in the test_sticker module. + # set_sticker_position_in_set, delete_sticker_from_set and get_custom_emoji_stickers, + # replace_sticker_in_set are tested in the test_sticker module. # get_forum_topic_icon_stickers, edit_forum_topic, general_forum etc... # are tested in the test_forum module. + async def test_send_message_disable_web_page_preview(self, bot, chat_id): + """Test that disable_web_page_preview is substituted for link_preview_options and that + it still works as expected for backward compatability.""" + msg = await bot.send_message( + chat_id, + "https://github.com/python-telegram-bot/python-telegram-bot", + disable_web_page_preview=True, + ) + assert msg.link_preview_options + assert msg.link_preview_options.is_disabled + + async def test_send_message_link_preview_options(self, bot, chat_id): + """Test whether link_preview_options is correctly passed to the API.""" + # btw it is possible to have no url in the text, but set a url for the preview. + msg = await bot.send_message( + chat_id, + "https://github.com/python-telegram-bot/python-telegram-bot", + link_preview_options=LinkPreviewOptions(prefer_small_media=True, show_above_text=True), + ) + assert msg.link_preview_options + assert not msg.link_preview_options.is_disabled + # The prefer_* options aren't very consistent on the client side (big pic shown) + + # they are not returned by the API. + # assert msg.link_preview_options.prefer_small_media + assert msg.link_preview_options.show_above_text + + @pytest.mark.parametrize( + "default_bot", + [{"link_preview_options": LinkPreviewOptions(show_above_text=True)}], + indirect=True, + ) + async def test_send_message_default_link_preview_options(self, default_bot, chat_id): + """Test whether Defaults.link_preview_options is correctly fused with the passed LPO.""" + github_url = "https://github.com/python-telegram-bot/python-telegram-bot" + website = "https://python-telegram-bot.org/" + + # First test just the default passing: + coro1 = default_bot.send_message(chat_id, github_url) + # Next test fusion of both LPOs: + coro2 = default_bot.send_message( + chat_id, + github_url, + link_preview_options=LinkPreviewOptions(url=website, prefer_large_media=True), + ) + # Now test fusion + overriding of passed LPO: + coro3 = default_bot.send_message( + chat_id, + github_url, + link_preview_options=LinkPreviewOptions(show_above_text=False, url=website), + ) + # finally test explicitly setting to None + coro4 = default_bot.send_message(chat_id, github_url, link_preview_options=None) + + msgs = asyncio.gather(coro1, coro2, coro3, coro4) + msg1, msg2, msg3, msg4 = await msgs + assert msg1.link_preview_options + assert msg1.link_preview_options.show_above_text + + assert msg2.link_preview_options + assert msg2.link_preview_options.show_above_text + assert msg2.link_preview_options.url == website + assert msg2.link_preview_options.prefer_large_media # Now works correctly using new url.. + + assert msg3.link_preview_options + assert not msg3.link_preview_options.show_above_text + assert msg3.link_preview_options.url == website + + assert msg4.link_preview_options == LinkPreviewOptions(url=github_url) + + @pytest.mark.parametrize( + "default_bot", + [{"link_preview_options": LinkPreviewOptions(show_above_text=True)}], + indirect=True, + ) + async def test_edit_message_text_default_link_preview_options(self, default_bot, chat_id): + """Test whether Defaults.link_preview_options is correctly fused with the passed LPO.""" + github_url = "https://github.com/python-telegram-bot/python-telegram-bot" + website = "https://python-telegram-bot.org/" + telegram_url = "https://telegram.org" + base_1, base_2, base_3, base_4 = await asyncio.gather( + *(default_bot.send_message(chat_id, telegram_url) for _ in range(4)) + ) + + # First test just the default passing: + coro1 = base_1.edit_text(github_url) + # Next test fusion of both LPOs: + coro2 = base_2.edit_text( + github_url, + link_preview_options=LinkPreviewOptions(url=website, prefer_large_media=True), + ) + # Now test fusion + overriding of passed LPO: + coro3 = base_3.edit_text( + github_url, + link_preview_options=LinkPreviewOptions(show_above_text=False, url=website), + ) + # finally test explicitly setting to None + coro4 = base_4.edit_text(github_url, link_preview_options=None) + + msgs = asyncio.gather(coro1, coro2, coro3, coro4) + msg1, msg2, msg3, msg4 = await msgs + assert msg1.link_preview_options + assert msg1.link_preview_options.show_above_text + + assert msg2.link_preview_options + assert msg2.link_preview_options.show_above_text + assert msg2.link_preview_options.url == website + assert msg2.link_preview_options.prefer_large_media # Now works correctly using new url.. + + assert msg3.link_preview_options + assert not msg3.link_preview_options.show_above_text + assert msg3.link_preview_options.url == website + + assert msg4.link_preview_options == LinkPreviewOptions(url=github_url) async def test_send_message_entities(self, bot, chat_id): test_string = "Italic Bold Code Spoiler" @@ -3064,7 +4266,7 @@ async def test_send_message_default_allow_sending_without_reply( ) assert message.reply_to_message is None else: - with pytest.raises(BadRequest, match="message not found"): + with pytest.raises(BadRequest, match="Message to be replied not found"): await default_bot.send_message( chat_id, "test", reply_to_message_id=reply_to_message.message_id ) @@ -3121,8 +4323,8 @@ async def test_set_and_get_my_commands(self, bot): assert await bot.set_my_commands(commands) for i, bc in enumerate(await bot.get_my_commands()): - assert bc.command == f"cmd{i+1}" - assert bc.description == f"descr{i+1}" + assert bc.command == f"cmd{i + 1}" + assert bc.description == f"descr{i + 1}" async def test_get_set_delete_my_commands_with_scope(self, bot, super_group_id, chat_id): group_cmds = [BotCommand("group_cmd", "visible to this supergroup only")] @@ -3177,6 +4379,7 @@ async def test_copy_message_without_reply(self, bot, chat_id, media_message): parse_mode=ParseMode.HTML, reply_to_message_id=media_message.message_id, reply_markup=keyboard, + show_caption_above_media=False, ) # we send a temp message which replies to the returned message id in order to get a # message object @@ -3229,6 +4432,28 @@ async def test_copy_message_with_default(self, default_bot, chat_id, media_messa else: assert len(message.caption_entities) == 0 + async def test_copy_messages(self, bot, chat_id): + # not using gather here to have deterministically ordered message_ids + msg1 = await bot.send_message(chat_id, text="will be copied 1") + msg2 = await bot.send_message(chat_id, text="will be copied 2") + + copy_messages = await bot.copy_messages( + chat_id, from_chat_id=chat_id, message_ids=(msg1.message_id, msg2.message_id) + ) + assert isinstance(copy_messages, tuple) + + tasks = asyncio.gather( + bot.send_message(chat_id, "temp 1", reply_to_message_id=copy_messages[0].message_id), + bot.send_message(chat_id, "temp 2", reply_to_message_id=copy_messages[1].message_id), + ) + temp_msg1, temp_msg2 = await tasks + + forward_msg1 = temp_msg1.reply_to_message + forward_msg2 = temp_msg2.reply_to_message + + assert forward_msg1.text == msg1.text + assert forward_msg2.text == msg2.text + # Continue testing arbitrary callback data here with actual requests: async def test_replace_callback_data_send_message(self, cdc_bot, chat_id): bot = cdc_bot @@ -3251,8 +4476,10 @@ async def test_replace_callback_data_send_message(self, cdc_bot, chat_id): assert inline_keyboard[0][1] == no_replace_button assert inline_keyboard[0][0] == replace_button - keyboard = list(bot.callback_data_cache._keyboard_data)[0] - data = list(bot.callback_data_cache._keyboard_data[keyboard].button_data.values())[0] + keyboard = next(iter(bot.callback_data_cache._keyboard_data)) + data = next( + iter(bot.callback_data_cache._keyboard_data[keyboard].button_data.values()) + ) assert data == "replace_test" finally: bot.callback_data_cache.clear_callback_data() @@ -3274,14 +4501,16 @@ async def test_replace_callback_data_stop_poll_and_repl_to_message(self, cdc_bot ] ) await poll_message.stop_poll(reply_markup=reply_markup) - helper_message = await poll_message.reply_text("temp", quote=True) + helper_message = await poll_message.reply_text("temp", do_quote=True) message = helper_message.reply_to_message inline_keyboard = message.reply_markup.inline_keyboard assert inline_keyboard[0][1] == no_replace_button assert inline_keyboard[0][0] == replace_button - keyboard = list(bot.callback_data_cache._keyboard_data)[0] - data = list(bot.callback_data_cache._keyboard_data[keyboard].button_data.values())[0] + keyboard = next(iter(bot.callback_data_cache._keyboard_data)) + data = next( + iter(bot.callback_data_cache._keyboard_data[keyboard].button_data.values()) + ) assert data == "replace_test" finally: bot.callback_data_cache.clear_callback_data() @@ -3313,14 +4542,16 @@ async def test_replace_callback_data_copy_message(self, cdc_bot, chat_id): assert inline_keyboard[0][1] == no_replace_button assert inline_keyboard[0][0] == replace_button - keyboard = list(bot.callback_data_cache._keyboard_data)[0] - data = list(bot.callback_data_cache._keyboard_data[keyboard].button_data.values())[0] + keyboard = next(iter(bot.callback_data_cache._keyboard_data)) + data = next( + iter(bot.callback_data_cache._keyboard_data[keyboard].button_data.values()) + ) assert data == "replace_test" finally: bot.callback_data_cache.clear_callback_data() bot.callback_data_cache.clear_callback_queries() - async def test_get_chat_arbitrary_callback_data(self, channel_id, cdc_bot): + async def test_get_chat_arbitrary_callback_data(self, chat_id, cdc_bot): bot = cdc_bot try: @@ -3329,17 +4560,23 @@ async def test_get_chat_arbitrary_callback_data(self, channel_id, cdc_bot): ) message = await bot.send_message( - channel_id, text="get_chat_arbitrary_callback_data", reply_markup=reply_markup + chat_id, text="get_chat_arbitrary_callback_data", reply_markup=reply_markup ) await message.pin() - keyboard = list(bot.callback_data_cache._keyboard_data)[0] - data = list(bot.callback_data_cache._keyboard_data[keyboard].button_data.values())[0] + keyboard = next(iter(bot.callback_data_cache._keyboard_data)) + data = next( + iter(bot.callback_data_cache._keyboard_data[keyboard].button_data.values()) + ) assert data == "callback_data" - chat = await bot.get_chat(channel_id) - assert chat.pinned_message == message - assert chat.pinned_message.reply_markup == reply_markup + cfi = await bot.get_chat(chat_id) + + if not cfi.pinned_message: + pytest.xfail("Pinning messages is not always reliable on TG") + + assert cfi.pinned_message == message + assert cfi.pinned_message.reply_markup == reply_markup assert await message.unpin() # (not placed in finally block since msg can be unbound) finally: bot.callback_data_cache.clear_callback_data() @@ -3352,11 +4589,11 @@ async def test_arbitrary_callback_data_get_chat_no_pinned_message( await bot.unpin_all_chat_messages(super_group_id) try: - chat = await bot.get_chat(super_group_id) + cfi = await bot.get_chat(super_group_id) - assert isinstance(chat, Chat) - assert int(chat.id) == int(super_group_id) - assert chat.pinned_message is None + assert isinstance(cfi, ChatFullInfo) + assert int(cfi.id) == int(super_group_id) + assert cfi.pinned_message is None finally: bot.callback_data_cache.clear_callback_data() bot.callback_data_cache.clear_callback_queries() @@ -3440,3 +4677,220 @@ async def test_set_get_my_short_description(self, bot): bot.get_my_short_description("en"), bot.get_my_short_description("de"), ) == 3 * [BotShortDescription("")] + + async def test_set_message_reaction(self, bot, chat_id, static_message): + assert await bot.set_message_reaction( + chat_id, static_message.message_id, ReactionEmoji.THUMBS_DOWN, True + ) + + @pytest.mark.parametrize("bot_class", [Bot, ExtBot]) + async def test_do_api_request_warning_known_method(self, bot, bot_class): + with pytest.warns(PTBUserWarning, match="Please use 'Bot.get_me'") as record: + await bot_class(bot.token).do_api_request("get_me") + + assert record[0].filename == __file__, "Wrong stack level!" + + async def test_do_api_request_unknown_method(self, bot): + with pytest.raises(EndPointNotFound, match="'unknownEndpoint' not found"): + await bot.do_api_request("unknown_endpoint") + + @pytest.mark.filterwarnings("ignore::telegram.warnings.PTBUserWarning") + async def test_do_api_request_invalid_token(self, bot): + # we do not initialize the bot here on purpose b/c that's the case were we actually + # do not know for sure if the token is invalid or the method was not found + with pytest.raises( + InvalidToken, match="token was rejected by Telegram or the endpoint 'getMe'" + ): + await Bot("invalid_token").do_api_request("get_me") + + # same test, but with a valid token bot and unknown endpoint + with pytest.raises( + InvalidToken, match="token was rejected by Telegram or the endpoint 'unknownEndpoint'" + ): + await Bot(bot.token).do_api_request("unknown_endpoint") + + @pytest.mark.filterwarnings("ignore::telegram.warnings.PTBUserWarning") + @pytest.mark.parametrize("return_type", [Message, None]) + async def test_do_api_request_basic_and_files(self, bot, chat_id, return_type): + result = await bot.do_api_request( + "send_document", + api_kwargs={ + "chat_id": chat_id, + "caption": "test_caption", + "document": InputFile(data_file("telegram.png").open("rb")), + }, + return_type=return_type, + ) + if return_type is None: + assert isinstance(result, dict) + result = Message.de_json(result, bot) + + assert isinstance(result, Message) + assert result.chat_id == int(chat_id) + assert result.caption == "test_caption" + out = BytesIO() + await (await result.document.get_file()).download_to_memory(out) + out.seek(0) + assert out.read() == data_file("telegram.png").open("rb").read() + assert result.document.file_name == "telegram.png" + + @pytest.mark.filterwarnings("ignore::telegram.warnings.PTBUserWarning") + @pytest.mark.parametrize("return_type", [Message, None]) + async def test_do_api_request_list_return_type(self, bot, chat_id, return_type): + result = await bot.do_api_request( + "send_media_group", + api_kwargs={ + "chat_id": chat_id, + "media": [ + InputMediaDocument( + InputFile( + data_file("text_file.txt").open("rb"), + attach=True, + ) + ), + InputMediaDocument( + InputFile( + data_file("local_file.txt").open("rb"), + attach=True, + ) + ), + ], + }, + return_type=return_type, + ) + if return_type is None: + assert isinstance(result, list) + for entry in result: + assert isinstance(entry, dict) + result = Message.de_list(result, bot) + + for message, file_name in zip(result, ("text_file.txt", "local_file.txt"), strict=False): + assert isinstance(message, Message) + assert message.chat_id == int(chat_id) + out = BytesIO() + await (await message.document.get_file()).download_to_memory(out) + out.seek(0) + assert out.read() == data_file(file_name).open("rb").read() + assert message.document.file_name == file_name + + @pytest.mark.filterwarnings("ignore::telegram.warnings.PTBUserWarning") + @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 + + @pytest.mark.parametrize("subscription_period", [2592000, dtm.timedelta(days=30)]) + async def test_create_edit_chat_subscription_link( + self, bot, subscription_channel_id, channel_id, subscription_period + ): + sub_link = await bot.create_chat_subscription_invite_link( + subscription_channel_id, + name="sub_name", + subscription_period=subscription_period, + subscription_price=13, + ) + assert sub_link.name == "sub_name" + assert sub_link.subscription_period == 2592000 + assert sub_link.subscription_price == 13 + + edited_link = await bot.edit_chat_subscription_invite_link( + chat_id=subscription_channel_id, invite_link=sub_link, name="sub_name_2" + ) + assert edited_link.name == "sub_name_2" + assert sub_link.subscription_period == 2592000 + assert sub_link.subscription_price == 13 + + async def test_get_my_star_balance(self, bot): + balance = await bot.get_my_star_balance() + assert isinstance(balance, StarAmount) + assert balance.amount == 0 + + async def test_get_user_gifts_basic(self, bot): + gifts = await bot.get_user_gifts(bot.bot.id) + assert isinstance(gifts, OwnedGifts) + assert gifts.total_count == 0 + + async def test_get_chat_gifts_basic(self, bot, chat_id): + gifts = await bot.get_chat_gifts(chat_id) + assert isinstance(gifts, OwnedGifts) + assert gifts.total_count == 0 + + async def test_initialize_tracks_requests_and_bot_separately(self, offline_bot, monkeypatch): + """Test that requests and bot user are initialized separately and only once.""" + request_init_count = 0 + get_me_call_count = 0 + + async def counting_request_init(*args, **kwargs): + nonlocal request_init_count + request_init_count += 1 + + original_get_me = offline_bot.get_me + + async def counting_get_me(*args, **kwargs): + nonlocal get_me_call_count + get_me_call_count += 1 + return await original_get_me(*args, **kwargs) + + test_bot = PytestBot(token=offline_bot.token, request=OfflineRequest()) + monkeypatch.setattr(test_bot.request, "initialize", counting_request_init) + monkeypatch.setattr(test_bot, "get_me", counting_get_me) + + try: + # First initialization + await test_bot.initialize() + assert request_init_count == 1 + assert get_me_call_count == 1 + + # Second initialization should not call either again + await test_bot.initialize() + assert request_init_count == 1 + assert get_me_call_count == 1 + finally: + await test_bot.shutdown() + + async def test_shutdown_allows_reinitialization(self, offline_bot, monkeypatch): + """Test that after shutdown, bot can be reinitialized.""" + request_init_count = 0 + request_shutdown_count = 0 + get_me_call_count = 0 + + async def counting_request_init(*args, **kwargs): + nonlocal request_init_count + request_init_count += 1 + + async def counting_request_shutdown(*args, **kwargs): + nonlocal request_shutdown_count + request_shutdown_count += 1 + + original_get_me = offline_bot.get_me + + async def counting_get_me(*args, **kwargs): + nonlocal get_me_call_count + get_me_call_count += 1 + return await original_get_me(*args, **kwargs) + + test_bot = PytestBot(token=offline_bot.token, request=OfflineRequest()) + monkeypatch.setattr(test_bot.request, "initialize", counting_request_init) + monkeypatch.setattr(test_bot.request, "shutdown", counting_request_shutdown) + monkeypatch.setattr(test_bot, "get_me", counting_get_me) + + try: + # First initialization + await test_bot.initialize() + assert request_init_count == 1 + assert get_me_call_count == 1 + + # Shutdown + await test_bot.shutdown() + assert request_shutdown_count == 1 + + # Re-initialize should call everything again + await test_bot.initialize() + assert request_init_count == 2 + assert get_me_call_count == 2 + finally: + await test_bot.shutdown() diff --git a/tests/test_botcommand.py b/tests/test_botcommand.py index 5e8ff724c8e..4ecceb796b8 100644 --- a/tests/test_botcommand.py +++ b/tests/test_botcommand.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -37,16 +37,14 @@ def test_slot_behaviour(self, bot_command): assert getattr(bot_command, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(bot_command)) == len(set(mro_slots(bot_command))), "duplicate slot" - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = {"command": self.command, "description": self.description} - bot_command = BotCommand.de_json(json_dict, bot) + bot_command = BotCommand.de_json(json_dict, offline_bot) assert bot_command.api_kwargs == {} assert bot_command.command == self.command assert bot_command.description == self.description - assert BotCommand.de_json(None, bot) is None - def test_to_dict(self, bot_command): bot_command_dict = bot_command.to_dict() diff --git a/tests/test_botcommandscope.py b/tests/test_botcommandscope.py index c9aee01bd0f..6b520d53192 100644 --- a/tests/test_botcommandscope.py +++ b/tests/test_botcommandscope.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,7 +16,6 @@ # # 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 @@ -31,148 +30,340 @@ BotCommandScopeDefault, Dice, ) +from telegram.constants import BotCommandScopeType from tests.auxil.slots import mro_slots -@pytest.fixture(scope="module", params=["str", "int"]) -def chat_id(request): - if request.param == "str": - return "@supergroupusername" - return 43 - - -@pytest.fixture( - scope="class", - params=[ - BotCommandScope.DEFAULT, - BotCommandScope.ALL_PRIVATE_CHATS, - BotCommandScope.ALL_GROUP_CHATS, - BotCommandScope.ALL_CHAT_ADMINISTRATORS, - BotCommandScope.CHAT, - BotCommandScope.CHAT_ADMINISTRATORS, - BotCommandScope.CHAT_MEMBER, - ], -) -def scope_type(request): - return request.param - - -@pytest.fixture( - scope="module", - params=[ - BotCommandScopeDefault, - BotCommandScopeAllPrivateChats, - BotCommandScopeAllGroupChats, - BotCommandScopeAllChatAdministrators, - BotCommandScopeChat, - BotCommandScopeChatAdministrators, - BotCommandScopeChatMember, - ], - ids=[ - BotCommandScope.DEFAULT, - BotCommandScope.ALL_PRIVATE_CHATS, - BotCommandScope.ALL_GROUP_CHATS, - BotCommandScope.ALL_CHAT_ADMINISTRATORS, - BotCommandScope.CHAT, - BotCommandScope.CHAT_ADMINISTRATORS, - BotCommandScope.CHAT_MEMBER, - ], -) -def scope_class(request): - return request.param - - -@pytest.fixture( - scope="module", - params=[ - (BotCommandScopeDefault, BotCommandScope.DEFAULT), - (BotCommandScopeAllPrivateChats, BotCommandScope.ALL_PRIVATE_CHATS), - (BotCommandScopeAllGroupChats, BotCommandScope.ALL_GROUP_CHATS), - (BotCommandScopeAllChatAdministrators, BotCommandScope.ALL_CHAT_ADMINISTRATORS), - (BotCommandScopeChat, BotCommandScope.CHAT), - (BotCommandScopeChatAdministrators, BotCommandScope.CHAT_ADMINISTRATORS), - (BotCommandScopeChatMember, BotCommandScope.CHAT_MEMBER), - ], - ids=[ - BotCommandScope.DEFAULT, - BotCommandScope.ALL_PRIVATE_CHATS, - BotCommandScope.ALL_GROUP_CHATS, - BotCommandScope.ALL_CHAT_ADMINISTRATORS, - BotCommandScope.CHAT, - BotCommandScope.CHAT_ADMINISTRATORS, - BotCommandScope.CHAT_MEMBER, - ], -) -def scope_class_and_type(request): - return request.param +@pytest.fixture +def bot_command_scope(): + return BotCommandScope(BotCommandScopeTestBase.type) -@pytest.fixture(scope="module") -def bot_command_scope(scope_class_and_type, chat_id): - # we use de_json here so that we don't have to worry about which class needs which arguments - return scope_class_and_type[0].de_json( - {"type": scope_class_and_type[1], "chat_id": chat_id, "user_id": 42}, bot=None - ) +class BotCommandScopeTestBase: + type = BotCommandScopeType.DEFAULT + chat_id = 123456789 + user_id = 987654321 -# All the scope types are very similar, so we test everything via parametrization -class TestBotCommandScopeWithoutRequest: +class TestBotCommandScopeWithoutRequest(BotCommandScopeTestBase): def test_slot_behaviour(self, bot_command_scope): - for attr in bot_command_scope.__slots__: - assert getattr(bot_command_scope, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(bot_command_scope)) == len( - set(mro_slots(bot_command_scope)) - ), "duplicate slot" - - def test_de_json(self, bot, scope_class_and_type, chat_id): - cls = scope_class_and_type[0] - type_ = scope_class_and_type[1] - - assert cls.de_json({}, bot) is None - - json_dict = {"type": type_, "chat_id": chat_id, "user_id": 42} - bot_command_scope = BotCommandScope.de_json(json_dict, bot) - assert set(bot_command_scope.api_kwargs.keys()) == {"chat_id", "user_id"} - set( - cls.__slots__ + inst = bot_command_scope + 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_type_enum_conversion(self, bot_command_scope): + assert type(BotCommandScope("default").type) is BotCommandScopeType + assert BotCommandScope("unknown").type == "unknown" + + def test_de_json(self, offline_bot): + data = {"type": "unknown"} + transaction_partner = BotCommandScope.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "unknown" + + @pytest.mark.parametrize( + ("bcs_type", "subclass"), + [ + ("all_private_chats", BotCommandScopeAllPrivateChats), + ("all_chat_administrators", BotCommandScopeAllChatAdministrators), + ("all_group_chats", BotCommandScopeAllGroupChats), + ("chat", BotCommandScopeChat), + ("chat_administrators", BotCommandScopeChatAdministrators), + ("chat_member", BotCommandScopeChatMember), + ("default", BotCommandScopeDefault), + ], + ) + def test_de_json_subclass(self, offline_bot, bcs_type, subclass): + json_dict = { + "type": bcs_type, + "chat_id": self.chat_id, + "user_id": self.user_id, + } + bcs = BotCommandScope.de_json(json_dict, offline_bot) + + assert type(bcs) is subclass + assert set(bcs.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { + "type" + } + assert bcs.type == bcs_type + + def test_to_dict(self, bot_command_scope): + data = bot_command_scope.to_dict() + assert data == {"type": "default"} + + def test_equality(self, bot_command_scope): + a = bot_command_scope + b = BotCommandScope(self.type) + c = BotCommandScope("unknown") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def bot_command_scope_all_private_chats(): + return BotCommandScopeAllPrivateChats() + + +class TestBotCommandScopeAllPrivateChatsWithoutRequest(BotCommandScopeTestBase): + type = BotCommandScopeType.ALL_PRIVATE_CHATS + + def test_slot_behaviour(self, bot_command_scope_all_private_chats): + inst = bot_command_scope_all_private_chats + 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, offline_bot): + transaction_partner = BotCommandScopeAllPrivateChats.de_json({}, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "all_private_chats" + + def test_to_dict(self, bot_command_scope_all_private_chats): + assert bot_command_scope_all_private_chats.to_dict() == { + "type": bot_command_scope_all_private_chats.type + } + + def test_equality(self, bot_command_scope_all_private_chats): + a = bot_command_scope_all_private_chats + b = BotCommandScopeAllPrivateChats() + c = Dice(5, "test") + d = BotCommandScopeDefault() + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def bot_command_scope_all_chat_administrators(): + return BotCommandScopeAllChatAdministrators() + + +class TestBotCommandScopeAllChatAdministratorsWithoutRequest(BotCommandScopeTestBase): + type = BotCommandScopeType.ALL_CHAT_ADMINISTRATORS + + def test_slot_behaviour(self, bot_command_scope_all_chat_administrators): + inst = bot_command_scope_all_chat_administrators + 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, offline_bot): + transaction_partner = BotCommandScopeAllChatAdministrators.de_json({}, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "all_chat_administrators" + + def test_to_dict(self, bot_command_scope_all_chat_administrators): + assert bot_command_scope_all_chat_administrators.to_dict() == { + "type": bot_command_scope_all_chat_administrators.type + } + + def test_equality(self, bot_command_scope_all_chat_administrators): + a = bot_command_scope_all_chat_administrators + b = BotCommandScopeAllChatAdministrators() + c = Dice(5, "test") + d = BotCommandScopeDefault() + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def bot_command_scope_all_group_chats(): + return BotCommandScopeAllGroupChats() + + +class TestBotCommandScopeAllGroupChatsWithoutRequest(BotCommandScopeTestBase): + type = BotCommandScopeType.ALL_GROUP_CHATS + + def test_slot_behaviour(self, bot_command_scope_all_group_chats): + inst = bot_command_scope_all_group_chats + 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, offline_bot): + transaction_partner = BotCommandScopeAllGroupChats.de_json({}, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "all_group_chats" + + def test_to_dict(self, bot_command_scope_all_group_chats): + assert bot_command_scope_all_group_chats.to_dict() == { + "type": bot_command_scope_all_group_chats.type + } + + def test_equality(self, bot_command_scope_all_group_chats): + a = bot_command_scope_all_group_chats + b = BotCommandScopeAllGroupChats() + c = Dice(5, "test") + d = BotCommandScopeDefault() + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def bot_command_scope_chat(): + return BotCommandScopeChat(TestBotCommandScopeChatWithoutRequest.chat_id) + + +class TestBotCommandScopeChatWithoutRequest(BotCommandScopeTestBase): + type = BotCommandScopeType.CHAT + + def test_slot_behaviour(self, bot_command_scope_chat): + inst = bot_command_scope_chat + 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, offline_bot): + transaction_partner = BotCommandScopeChat.de_json({"chat_id": self.chat_id}, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "chat" + assert transaction_partner.chat_id == self.chat_id + + def test_to_dict(self, bot_command_scope_chat): + assert bot_command_scope_chat.to_dict() == { + "type": bot_command_scope_chat.type, + "chat_id": self.chat_id, + } + + def test_equality(self, bot_command_scope_chat): + a = bot_command_scope_chat + b = BotCommandScopeChat(self.chat_id) + c = BotCommandScopeChat(self.chat_id + 1) + d = Dice(5, "test") + e = BotCommandScopeDefault() + + 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) + + +@pytest.fixture +def bot_command_scope_chat_administrators(): + return BotCommandScopeChatAdministrators( + TestBotCommandScopeChatAdministratorsWithoutRequest.chat_id + ) + + +class TestBotCommandScopeChatAdministratorsWithoutRequest(BotCommandScopeTestBase): + type = BotCommandScopeType.CHAT_ADMINISTRATORS + + def test_slot_behaviour(self, bot_command_scope_chat_administrators): + inst = bot_command_scope_chat_administrators + 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, offline_bot): + transaction_partner = BotCommandScopeChatAdministrators.de_json( + {"chat_id": self.chat_id}, offline_bot ) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "chat_administrators" + assert transaction_partner.chat_id == self.chat_id + + def test_to_dict(self, bot_command_scope_chat_administrators): + assert bot_command_scope_chat_administrators.to_dict() == { + "type": bot_command_scope_chat_administrators.type, + "chat_id": self.chat_id, + } + + def test_equality(self, bot_command_scope_chat_administrators): + a = bot_command_scope_chat_administrators + b = BotCommandScopeChatAdministrators(self.chat_id) + c = BotCommandScopeChatAdministrators(self.chat_id + 1) + d = Dice(5, "test") + e = BotCommandScopeDefault() - assert isinstance(bot_command_scope, BotCommandScope) - assert isinstance(bot_command_scope, cls) - assert bot_command_scope.type == type_ - if "chat_id" in cls.__slots__: - assert bot_command_scope.chat_id == chat_id - if "user_id" in cls.__slots__: - assert bot_command_scope.user_id == 42 + assert a == b + assert hash(a) == hash(b) - def test_de_json_invalid_type(self, bot): - json_dict = {"type": "invalid", "chat_id": chat_id, "user_id": 42} - bot_command_scope = BotCommandScope.de_json(json_dict, bot) + assert a != c + assert hash(a) != hash(c) - assert type(bot_command_scope) is BotCommandScope - assert bot_command_scope.type == "invalid" + assert a != d + assert hash(a) != hash(d) - def test_de_json_subclass(self, scope_class, bot, chat_id): - """This makes sure that e.g. BotCommandScopeDefault(data) never returns a - BotCommandScopeChat instance.""" - json_dict = {"type": "invalid", "chat_id": chat_id, "user_id": 42} - assert type(scope_class.de_json(json_dict, bot)) is scope_class + assert a != e + assert hash(a) != hash(e) - def test_to_dict(self, bot_command_scope): - bot_command_scope_dict = bot_command_scope.to_dict() - - assert isinstance(bot_command_scope_dict, dict) - assert bot_command_scope["type"] == bot_command_scope.type - if hasattr(bot_command_scope, "chat_id"): - assert bot_command_scope["chat_id"] == bot_command_scope.chat_id - if hasattr(bot_command_scope, "user_id"): - assert bot_command_scope["user_id"] == bot_command_scope.user_id - - def test_equality(self, bot_command_scope, bot): - a = BotCommandScope("base_type") - b = BotCommandScope("base_type") - c = bot_command_scope - d = deepcopy(bot_command_scope) - e = Dice(4, "emoji") + +@pytest.fixture +def bot_command_scope_chat_member(): + return BotCommandScopeChatMember( + TestBotCommandScopeChatMemberWithoutRequest.chat_id, + TestBotCommandScopeChatMemberWithoutRequest.user_id, + ) + + +class TestBotCommandScopeChatMemberWithoutRequest(BotCommandScopeTestBase): + type = BotCommandScopeType.CHAT_MEMBER + + def test_slot_behaviour(self, bot_command_scope_chat_member): + inst = bot_command_scope_chat_member + 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, offline_bot): + transaction_partner = BotCommandScopeChatMember.de_json( + {"chat_id": self.chat_id, "user_id": self.user_id}, offline_bot + ) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "chat_member" + assert transaction_partner.chat_id == self.chat_id + assert transaction_partner.user_id == self.user_id + + def test_to_dict(self, bot_command_scope_chat_member): + assert bot_command_scope_chat_member.to_dict() == { + "type": bot_command_scope_chat_member.type, + "chat_id": self.chat_id, + "user_id": self.user_id, + } + + def test_equality(self, bot_command_scope_chat_member): + a = bot_command_scope_chat_member + b = BotCommandScopeChatMember(self.chat_id, self.user_id) + c = BotCommandScopeChatMember(self.chat_id + 1, self.user_id) + d = BotCommandScopeChatMember(self.chat_id, self.user_id + 1) + e = Dice(5, "test") + f = BotCommandScopeDefault() assert a == b assert hash(a) == hash(b) @@ -186,24 +377,43 @@ def test_equality(self, bot_command_scope, bot): assert a != e assert hash(a) != hash(e) - assert c == d - assert hash(c) == hash(d) + assert a != f + assert hash(a) != hash(f) + - assert c != e - assert hash(c) != hash(e) +@pytest.fixture +def bot_command_scope_default(): + return BotCommandScopeDefault() - if hasattr(c, "chat_id"): - json_dict = c.to_dict() - json_dict["chat_id"] = 0 - f = c.__class__.de_json(json_dict, bot) - assert c != f - assert hash(c) != hash(f) +class TestBotCommandScopeDefaultWithoutRequest(BotCommandScopeTestBase): + type = BotCommandScopeType.DEFAULT - if hasattr(c, "user_id"): - json_dict = c.to_dict() - json_dict["user_id"] = 0 - g = c.__class__.de_json(json_dict, bot) + def test_slot_behaviour(self, bot_command_scope_default): + inst = bot_command_scope_default + 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" - assert c != g - assert hash(c) != hash(g) + def test_de_json(self, offline_bot): + transaction_partner = BotCommandScopeDefault.de_json({}, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "default" + + def test_to_dict(self, bot_command_scope_default): + assert bot_command_scope_default.to_dict() == {"type": bot_command_scope_default.type} + + def test_equality(self, bot_command_scope_default): + a = bot_command_scope_default + b = BotCommandScopeDefault() + c = Dice(5, "test") + d = BotCommandScopeChatMember(123, 456) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_botdescription.py b/tests/test_botdescription.py index 9ae588f9634..142545c3419 100644 --- a/tests/test_botdescription.py +++ b/tests/test_botdescription.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -24,26 +24,26 @@ @pytest.fixture(scope="module") def bot_description(bot): - return BotDescription(TestBotDescriptionBase.description) + return BotDescription(BotDescriptionTestBase.description) @pytest.fixture(scope="module") def bot_short_description(bot): - return BotShortDescription(TestBotDescriptionBase.short_description) + return BotShortDescription(BotDescriptionTestBase.short_description) -class TestBotDescriptionBase: +class BotDescriptionTestBase: description = "This is a test description" short_description = "This is a test short description" -class TestBotDescriptionWithoutRequest(TestBotDescriptionBase): +class TestBotDescriptionWithoutRequest(BotDescriptionTestBase): def test_slot_behaviour(self, bot_description): for attr in bot_description.__slots__: assert getattr(bot_description, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(bot_description)) == len( - set(mro_slots(bot_description)) - ), "duplicate slot" + assert len(mro_slots(bot_description)) == len(set(mro_slots(bot_description))), ( + "duplicate slot" + ) def test_to_dict(self, bot_description): bot_description_dict = bot_description.to_dict() @@ -64,7 +64,7 @@ def test_equality(self): assert hash(a) != hash(c) -class TestBotShortDescriptionWithoutRequest(TestBotDescriptionBase): +class TestBotShortDescriptionWithoutRequest(BotDescriptionTestBase): def test_slot_behaviour(self, bot_short_description): for attr in bot_short_description.__slots__: assert getattr(bot_short_description, attr, "err") != "err", f"got extra slot '{attr}'" diff --git a/tests/test_botname.py b/tests/test_botname.py index 89d2482ed31..289e07e1add 100644 --- a/tests/test_botname.py +++ b/tests/test_botname.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -24,14 +24,14 @@ @pytest.fixture(scope="module") def bot_name(bot): - return BotName(TestBotNameBase.name) + return BotName(BotNameTestBase.name) -class TestBotNameBase: +class BotNameTestBase: name = "This is a test name" -class TestBotNameWithoutRequest(TestBotNameBase): +class TestBotNameWithoutRequest(BotNameTestBase): def test_slot_behaviour(self, bot_name): for attr in bot_name.__slots__: assert getattr(bot_name, attr, "err") != "err", f"got extra slot '{attr}'" diff --git a/tests/test_business_classes.py b/tests/test_business_classes.py new file mode 100644 index 00000000000..8994243d1e6 --- /dev/null +++ b/tests/test_business_classes.py @@ -0,0 +1,781 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm +from zoneinfo import ZoneInfo + +import pytest + +from telegram import ( + BusinessConnection, + BusinessIntro, + BusinessLocation, + BusinessMessagesDeleted, + BusinessOpeningHours, + BusinessOpeningHoursInterval, + Chat, + Location, + Sticker, + User, +) +from telegram._business import BusinessBotRights +from telegram._utils.datetime import UTC, to_timestamp +from tests.auxil.slots import mro_slots + + +class BusinessTestBase: + id_ = "123" + user = User(123, "test_user", False) + user_chat_id = 123 + date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + can_change_gift_settings = True + can_convert_gifts_to_stars = True + can_delete_all_messages = True + can_delete_sent_messages = True + can_edit_bio = True + can_edit_name = True + can_edit_profile_photo = True + can_edit_username = True + can_manage_stories = True + can_read_messages = True + can_reply = True + can_transfer_and_upgrade_gifts = True + can_transfer_stars = True + can_view_gifts_and_stars = True + is_enabled = True + message_ids = (123, 321) + business_connection_id = "123" + chat = Chat(123, "test_chat") + title = "Business Title" + message = "Business description" + sticker = Sticker("sticker_id", "unique_id", 50, 50, True, False, Sticker.REGULAR) + address = "address" + location = Location(-23.691288, 46.788279) + opening_minute = 0 + closing_minute = 60 + time_zone_name = "Country/City" + opening_hours = [ + BusinessOpeningHoursInterval(opening, opening + 60) for opening in (0, 24 * 60) + ] + + +@pytest.fixture(scope="module") +def business_bot_rights(): + return BusinessBotRights( + can_change_gift_settings=BusinessTestBase.can_change_gift_settings, + can_convert_gifts_to_stars=BusinessTestBase.can_convert_gifts_to_stars, + can_delete_all_messages=BusinessTestBase.can_delete_all_messages, + can_delete_sent_messages=BusinessTestBase.can_delete_sent_messages, + can_edit_bio=BusinessTestBase.can_edit_bio, + can_edit_name=BusinessTestBase.can_edit_name, + can_edit_profile_photo=BusinessTestBase.can_edit_profile_photo, + can_edit_username=BusinessTestBase.can_edit_username, + can_manage_stories=BusinessTestBase.can_manage_stories, + can_read_messages=BusinessTestBase.can_read_messages, + can_reply=BusinessTestBase.can_reply, + can_transfer_and_upgrade_gifts=BusinessTestBase.can_transfer_and_upgrade_gifts, + can_transfer_stars=BusinessTestBase.can_transfer_stars, + can_view_gifts_and_stars=BusinessTestBase.can_view_gifts_and_stars, + ) + + +@pytest.fixture(scope="module") +def business_connection(business_bot_rights): + return BusinessConnection( + BusinessTestBase.id_, + BusinessTestBase.user, + BusinessTestBase.user_chat_id, + BusinessTestBase.date, + BusinessTestBase.is_enabled, + rights=business_bot_rights, + ) + + +@pytest.fixture(scope="module") +def business_messages_deleted(): + return BusinessMessagesDeleted( + BusinessTestBase.business_connection_id, + BusinessTestBase.chat, + BusinessTestBase.message_ids, + ) + + +@pytest.fixture(scope="module") +def business_intro(): + return BusinessIntro( + BusinessTestBase.title, + BusinessTestBase.message, + BusinessTestBase.sticker, + ) + + +@pytest.fixture(scope="module") +def business_location(): + return BusinessLocation( + BusinessTestBase.address, + BusinessTestBase.location, + ) + + +@pytest.fixture(scope="module") +def business_opening_hours_interval(): + return BusinessOpeningHoursInterval( + BusinessTestBase.opening_minute, + BusinessTestBase.closing_minute, + ) + + +@pytest.fixture(scope="module") +def business_opening_hours(): + return BusinessOpeningHours( + BusinessTestBase.time_zone_name, + BusinessTestBase.opening_hours, + ) + + +class TestBusinessBotRightsWithoutRequest(BusinessTestBase): + def test_slot_behaviour(self, business_bot_rights): + inst = business_bot_rights + 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_to_dict(self, business_bot_rights): + rights_dict = business_bot_rights.to_dict() + + assert isinstance(rights_dict, dict) + assert rights_dict["can_reply"] is self.can_reply + assert rights_dict["can_read_messages"] is self.can_read_messages + assert rights_dict["can_delete_sent_messages"] is self.can_delete_sent_messages + assert rights_dict["can_delete_all_messages"] is self.can_delete_all_messages + assert rights_dict["can_edit_name"] is self.can_edit_name + assert rights_dict["can_edit_bio"] is self.can_edit_bio + assert rights_dict["can_edit_profile_photo"] is self.can_edit_profile_photo + assert rights_dict["can_edit_username"] is self.can_edit_username + assert rights_dict["can_change_gift_settings"] is self.can_change_gift_settings + assert rights_dict["can_view_gifts_and_stars"] is self.can_view_gifts_and_stars + assert rights_dict["can_convert_gifts_to_stars"] is self.can_convert_gifts_to_stars + assert rights_dict["can_transfer_and_upgrade_gifts"] is self.can_transfer_and_upgrade_gifts + assert rights_dict["can_transfer_stars"] is self.can_transfer_stars + assert rights_dict["can_manage_stories"] is self.can_manage_stories + + def test_de_json(self): + json_dict = { + "can_reply": self.can_reply, + "can_read_messages": self.can_read_messages, + "can_delete_sent_messages": self.can_delete_sent_messages, + "can_delete_all_messages": self.can_delete_all_messages, + "can_edit_name": self.can_edit_name, + "can_edit_bio": self.can_edit_bio, + "can_edit_profile_photo": self.can_edit_profile_photo, + "can_edit_username": self.can_edit_username, + "can_change_gift_settings": self.can_change_gift_settings, + "can_view_gifts_and_stars": self.can_view_gifts_and_stars, + "can_convert_gifts_to_stars": self.can_convert_gifts_to_stars, + "can_transfer_and_upgrade_gifts": self.can_transfer_and_upgrade_gifts, + "can_transfer_stars": self.can_transfer_stars, + "can_manage_stories": self.can_manage_stories, + } + + rights = BusinessBotRights.de_json(json_dict, None) + assert rights.can_reply is self.can_reply + assert rights.can_read_messages is self.can_read_messages + assert rights.can_delete_sent_messages is self.can_delete_sent_messages + assert rights.can_delete_all_messages is self.can_delete_all_messages + assert rights.can_edit_name is self.can_edit_name + assert rights.can_edit_bio is self.can_edit_bio + assert rights.can_edit_profile_photo is self.can_edit_profile_photo + assert rights.can_edit_username is self.can_edit_username + assert rights.can_change_gift_settings is self.can_change_gift_settings + assert rights.can_view_gifts_and_stars is self.can_view_gifts_and_stars + assert rights.can_convert_gifts_to_stars is self.can_convert_gifts_to_stars + assert rights.can_transfer_and_upgrade_gifts is self.can_transfer_and_upgrade_gifts + assert rights.can_transfer_stars is self.can_transfer_stars + assert rights.can_manage_stories is self.can_manage_stories + assert rights.api_kwargs == {} + assert isinstance(rights, BusinessBotRights) + + def test_equality(self): + rights1 = BusinessBotRights( + can_reply=self.can_reply, + ) + + rights2 = BusinessBotRights( + can_reply=True, + ) + + rights3 = BusinessBotRights( + can_reply=True, + can_read_messages=self.can_read_messages, + ) + + assert rights1 == rights2 + assert hash(rights1) == hash(rights2) + assert rights1 is not rights2 + + assert rights1 != rights3 + assert hash(rights1) != hash(rights3) + + +class TestBusinessConnectionWithoutRequest(BusinessTestBase): + def test_slots(self, business_connection): + bc = business_connection + for attr in bc.__slots__: + assert getattr(bc, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(bc)) == len(set(mro_slots(bc))), "duplicate slot" + + def test_de_json(self, business_bot_rights): + json_dict = { + "id": self.id_, + "user": self.user.to_dict(), + "user_chat_id": self.user_chat_id, + "date": to_timestamp(self.date), + "is_enabled": self.is_enabled, + "rights": business_bot_rights.to_dict(), + } + bc = BusinessConnection.de_json(json_dict, None) + assert bc.id == self.id_ + assert bc.user == self.user + assert bc.user_chat_id == self.user_chat_id + assert bc.date == self.date + assert bc.is_enabled == self.is_enabled + assert bc.rights == business_bot_rights + assert bc.api_kwargs == {} + assert isinstance(bc, BusinessConnection) + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot, business_bot_rights): + json_dict = { + "id": self.id_, + "user": self.user.to_dict(), + "user_chat_id": self.user_chat_id, + "date": to_timestamp(self.date), + "is_enabled": self.is_enabled, + "rights": business_bot_rights.to_dict(), + } + chat_bot = BusinessConnection.de_json(json_dict, offline_bot) + chat_bot_raw = BusinessConnection.de_json(json_dict, raw_bot) + chat_bot_tz = BusinessConnection.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing tzinfo objects is not reliable + date_offset = chat_bot_tz.date.utcoffset() + date_offset_tz = tz_bot.defaults.tzinfo.utcoffset(chat_bot_tz.date.replace(tzinfo=None)) + + assert chat_bot.date.tzinfo == UTC + assert chat_bot_raw.date.tzinfo == UTC + assert date_offset_tz == date_offset + + def test_to_dict(self, business_connection, business_bot_rights): + bc_dict = business_connection.to_dict() + assert isinstance(bc_dict, dict) + assert bc_dict["id"] == self.id_ + assert bc_dict["user"] == self.user.to_dict() + assert bc_dict["user_chat_id"] == self.user_chat_id + assert bc_dict["date"] == to_timestamp(self.date) + assert bc_dict["is_enabled"] == self.is_enabled + assert bc_dict["rights"] == business_bot_rights.to_dict() + + def test_equality(self, business_bot_rights): + bc1 = BusinessConnection( + self.id_, + self.user, + self.user_chat_id, + self.date, + self.is_enabled, + rights=business_bot_rights, + ) + bc2 = BusinessConnection( + self.id_, + self.user, + self.user_chat_id, + self.date, + self.is_enabled, + rights=business_bot_rights, + ) + bc3 = BusinessConnection( + "321", + self.user, + self.user_chat_id, + self.date, + self.is_enabled, + rights=business_bot_rights, + ) + bc4 = BusinessConnection( + self.id_, + self.user, + self.user_chat_id, + self.date, + self.is_enabled, + rights=BusinessBotRights(), + ) + + assert bc1 == bc2 + assert hash(bc1) == hash(bc2) + + assert bc1 != bc3 + assert hash(bc1) != hash(bc3) + + assert bc1 != bc4 + assert hash(bc1) != hash(bc4) + + +class TestBusinessMessagesDeleted(BusinessTestBase): + def test_slots(self, business_messages_deleted): + bmd = business_messages_deleted + for attr in bmd.__slots__: + assert getattr(bmd, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(bmd)) == len(set(mro_slots(bmd))), "duplicate slot" + + def test_to_dict(self, business_messages_deleted): + bmd_dict = business_messages_deleted.to_dict() + assert isinstance(bmd_dict, dict) + assert bmd_dict["message_ids"] == list(self.message_ids) + assert bmd_dict["business_connection_id"] == self.business_connection_id + assert bmd_dict["chat"] == self.chat.to_dict() + + def test_de_json(self): + json_dict = { + "business_connection_id": self.business_connection_id, + "chat": self.chat.to_dict(), + "message_ids": self.message_ids, + } + bmd = BusinessMessagesDeleted.de_json(json_dict, None) + assert bmd.business_connection_id == self.business_connection_id + assert bmd.chat == self.chat + assert bmd.message_ids == self.message_ids + assert bmd.api_kwargs == {} + assert isinstance(bmd, BusinessMessagesDeleted) + + def test_equality(self): + bmd1 = BusinessMessagesDeleted(self.business_connection_id, self.chat, self.message_ids) + bmd2 = BusinessMessagesDeleted(self.business_connection_id, self.chat, self.message_ids) + bmd3 = BusinessMessagesDeleted("1", Chat(4, "random"), [321, 123]) + + assert bmd1 == bmd2 + assert hash(bmd1) == hash(bmd2) + + assert bmd1 != bmd3 + assert hash(bmd1) != hash(bmd3) + + +class TestBusinessIntroWithoutRequest(BusinessTestBase): + def test_slot_behaviour(self, business_intro): + intro = business_intro + for attr in intro.__slots__: + assert getattr(intro, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(intro)) == len(set(mro_slots(intro))), "duplicate slot" + + def test_to_dict(self, business_intro): + intro_dict = business_intro.to_dict() + assert isinstance(intro_dict, dict) + assert intro_dict["title"] == self.title + assert intro_dict["message"] == self.message + assert intro_dict["sticker"] == self.sticker.to_dict() + + def test_de_json(self): + json_dict = { + "title": self.title, + "message": self.message, + "sticker": self.sticker.to_dict(), + } + intro = BusinessIntro.de_json(json_dict, None) + assert intro.title == self.title + assert intro.message == self.message + assert intro.sticker == self.sticker + assert intro.api_kwargs == {} + assert isinstance(intro, BusinessIntro) + + def test_equality(self): + intro1 = BusinessIntro(self.title, self.message, self.sticker) + intro2 = BusinessIntro(self.title, self.message, self.sticker) + intro3 = BusinessIntro("Other Business", self.message, self.sticker) + + assert intro1 == intro2 + assert hash(intro1) == hash(intro2) + assert intro1 is not intro2 + + assert intro1 != intro3 + assert hash(intro1) != hash(intro3) + + +class TestBusinessLocationWithoutRequest(BusinessTestBase): + def test_slot_behaviour(self, business_location): + inst = business_location + 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_to_dict(self, business_location): + blc_dict = business_location.to_dict() + assert isinstance(blc_dict, dict) + assert blc_dict["address"] == self.address + assert blc_dict["location"] == self.location.to_dict() + + def test_de_json(self): + json_dict = { + "address": self.address, + "location": self.location.to_dict(), + } + blc = BusinessLocation.de_json(json_dict, None) + assert blc.address == self.address + assert blc.location == self.location + assert blc.api_kwargs == {} + assert isinstance(blc, BusinessLocation) + + def test_equality(self): + blc1 = BusinessLocation(self.address, self.location) + blc2 = BusinessLocation(self.address, self.location) + blc3 = BusinessLocation("Other Address", self.location) + + assert blc1 == blc2 + assert hash(blc1) == hash(blc2) + assert blc1 is not blc2 + + assert blc1 != blc3 + assert hash(blc1) != hash(blc3) + + +class TestBusinessOpeningHoursIntervalWithoutRequest(BusinessTestBase): + def test_slot_behaviour(self, business_opening_hours_interval): + inst = business_opening_hours_interval + 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_to_dict(self, business_opening_hours_interval): + bohi_dict = business_opening_hours_interval.to_dict() + assert isinstance(bohi_dict, dict) + assert bohi_dict["opening_minute"] == self.opening_minute + assert bohi_dict["closing_minute"] == self.closing_minute + + def test_de_json(self): + json_dict = { + "opening_minute": self.opening_minute, + "closing_minute": self.closing_minute, + } + bohi = BusinessOpeningHoursInterval.de_json(json_dict, None) + assert bohi.opening_minute == self.opening_minute + assert bohi.closing_minute == self.closing_minute + assert bohi.api_kwargs == {} + assert isinstance(bohi, BusinessOpeningHoursInterval) + + def test_equality(self): + bohi1 = BusinessOpeningHoursInterval(self.opening_minute, self.closing_minute) + bohi2 = BusinessOpeningHoursInterval(self.opening_minute, self.closing_minute) + bohi3 = BusinessOpeningHoursInterval(61, 100) + + assert bohi1 == bohi2 + assert hash(bohi1) == hash(bohi2) + assert bohi1 is not bohi2 + + assert bohi1 != bohi3 + assert hash(bohi1) != hash(bohi3) + + @pytest.mark.parametrize( + ("opening_minute", "expected"), + [ # openings per docstring + (8 * 60, (0, 8, 0)), + (24 * 60, (1, 0, 0)), + (6 * 24 * 60, (6, 0, 0)), + ], + ) + def test_opening_time(self, opening_minute, expected): + bohi = BusinessOpeningHoursInterval(opening_minute, -0) + + opening_time = bohi.opening_time + assert opening_time == expected + + cached = bohi.opening_time + assert cached is opening_time + + @pytest.mark.parametrize( + ("closing_minute", "expected"), + [ # closings per docstring + (20 * 60 + 30, (0, 20, 30)), + (2 * 24 * 60 - 1, (1, 23, 59)), + (7 * 24 * 60 - 2, (6, 23, 58)), + ], + ) + def test_closing_time(self, closing_minute, expected): + bohi = BusinessOpeningHoursInterval(-0, closing_minute) + + closing_time = bohi.closing_time + assert closing_time == expected + + cached = bohi.closing_time + assert cached is closing_time + + +class TestBusinessOpeningHoursWithoutRequest(BusinessTestBase): + def test_slot_behaviour(self, business_opening_hours): + inst = business_opening_hours + 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_to_dict(self, business_opening_hours): + boh_dict = business_opening_hours.to_dict() + assert isinstance(boh_dict, dict) + assert boh_dict["time_zone_name"] == self.time_zone_name + assert boh_dict["opening_hours"] == [opening.to_dict() for opening in self.opening_hours] + + def test_de_json(self): + json_dict = { + "time_zone_name": self.time_zone_name, + "opening_hours": [opening.to_dict() for opening in self.opening_hours], + } + boh = BusinessOpeningHours.de_json(json_dict, None) + assert boh.time_zone_name == self.time_zone_name + assert boh.opening_hours == tuple(self.opening_hours) + assert boh.api_kwargs == {} + assert isinstance(boh, BusinessOpeningHours) + + def test_equality(self): + boh1 = BusinessOpeningHours(self.time_zone_name, self.opening_hours) + boh2 = BusinessOpeningHours(self.time_zone_name, self.opening_hours) + boh3 = BusinessOpeningHours("Other/Timezone", self.opening_hours) + + assert boh1 == boh2 + assert hash(boh1) == hash(boh2) + assert boh1 is not boh2 + + assert boh1 != boh3 + assert hash(boh1) != hash(boh3) + + class TestBusinessOpeningHoursGetOpeningHoursForDayWithoutRequest: + @pytest.fixture + def sample_opening_hours(self): + # Monday 8am-8:30pm (480-1230) + # Tuesday 24 hours (1440-2879) + # Sunday 12am-11:58pm (8640-10078) + intervals = [ + BusinessOpeningHoursInterval(480, 1230), # Monday 8am-8:30pm + BusinessOpeningHoursInterval(1440, 2879), # Tuesday 24 hours + BusinessOpeningHoursInterval(8640, 10078), # Sunday 12am-11:58pm + ] + return BusinessOpeningHours(time_zone_name="UTC", opening_hours=intervals) + + def test_monday_opening_hours(self, sample_opening_hours): + # Test for Monday + test_date = dtm.date(2023, 11, 6) # Monday + time_zone = ZoneInfo("UTC") + result = sample_opening_hours.get_opening_hours_for_day(test_date, time_zone) + + expected = ( + ( + dtm.datetime(2023, 11, 6, 8, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 6, 20, 30, tzinfo=time_zone), + ), + ) + + assert result == expected + + def test_tuesday_24_hours(self, sample_opening_hours): + # Test for Tuesday (24 hours) + test_date = dtm.date(2023, 11, 7) # Tuesday + time_zone = ZoneInfo("UTC") + result = sample_opening_hours.get_opening_hours_for_day(test_date, time_zone) + + expected = ( + ( + dtm.datetime(2023, 11, 7, 0, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 7, 23, 59, tzinfo=time_zone), + ), + ) + + assert result == expected + + def test_sunday_opening_hours(self, sample_opening_hours): + # Test for Sunday + test_date = dtm.date(2023, 11, 12) # Sunday + time_zone = ZoneInfo("UTC") + result = sample_opening_hours.get_opening_hours_for_day(test_date, time_zone) + + expected = ( + ( + dtm.datetime(2023, 11, 12, 0, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 12, 23, 58, tzinfo=time_zone), + ), + ) + + assert result == expected + + def test_day_with_no_opening_hours(self, sample_opening_hours): + # Test for Wednesday (no opening hours defined) + test_date = dtm.date(2023, 11, 8) # Wednesday + time_zone = ZoneInfo("UTC") + result = sample_opening_hours.get_opening_hours_for_day(test_date, time_zone) + + assert result == () + + def test_multiple_intervals_same_day(self): + # Test with multiple intervals on the same day + intervals = [ + # unsorted on purpose to check that the sorting works (even though this is + # currently undocumented behaviour) + BusinessOpeningHoursInterval(900, 1230), # Monday 3pm-8:30pm + BusinessOpeningHoursInterval(480, 720), # Monday 8am-12pm + ] + opening_hours = BusinessOpeningHours(time_zone_name="UTC", opening_hours=intervals) + + test_date = dtm.date(2023, 11, 6) # Monday + time_zone = ZoneInfo("UTC") + result = opening_hours.get_opening_hours_for_day(test_date, time_zone) + + expected = ( + ( + dtm.datetime(2023, 11, 6, 8, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 6, 12, 0, tzinfo=time_zone), + ), + ( + dtm.datetime(2023, 11, 6, 15, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 6, 20, 30, tzinfo=time_zone), + ), + ) + + assert result == expected + + @pytest.mark.parametrize("input_type", [str, ZoneInfo]) + def test_timezone_conversion(self, sample_opening_hours, input_type): + # Test that timezone is properly applied + test_date = dtm.date(2023, 11, 6) # Monday + time_zone = input_type("America/New_York") + zone_info = ZoneInfo("America/New_York") + result = sample_opening_hours.get_opening_hours_for_day(test_date, time_zone) + + expected = ( + ( + dtm.datetime(2023, 11, 6, 3, 0, tzinfo=zone_info), + dtm.datetime(2023, 11, 6, 15, 30, tzinfo=zone_info), + ), + ) + + assert result == expected + assert result[0][0].tzinfo == zone_info + assert result[0][1].tzinfo == zone_info + + def test_timezone_conversation_changing_date(self): + # test for the edge case where the returned time is on a different date in the target + # timezone than in the business timezone + intervals = [ + BusinessOpeningHoursInterval(60, 120), # Monday 1am-2am UTC + ] + opening_hours = BusinessOpeningHours(time_zone_name="UTC", opening_hours=intervals) + test_date = dtm.date(2023, 11, 6) # Monday + time_zone = ZoneInfo("America/New_York") # UTC-5, so 1am UTC is 8pm previous day + result = opening_hours.get_opening_hours_for_day(test_date, time_zone) + expected = ( + ( + dtm.datetime(2023, 11, 5, 20, 0, tzinfo=time_zone), + dtm.datetime(2023, 11, 5, 21, 0, tzinfo=time_zone), + ), + ) + assert result == expected + + def test_no_timezone_provided(self, sample_opening_hours): + # Test when no timezone is provided + test_date = dtm.date(2023, 11, 6) # Monday + result = sample_opening_hours.get_opening_hours_for_day(test_date) + + expected = ( + ( + dtm.datetime( + 2023, + 11, + 6, + 8, + 0, + tzinfo=ZoneInfo(sample_opening_hours.time_zone_name), + ), + dtm.datetime( + 2023, + 11, + 6, + 20, + 30, + tzinfo=ZoneInfo(sample_opening_hours.time_zone_name), + ), + ), + ) + + assert result == expected + + class TestBusinessOpeningHoursIsOpenWithoutRequest: + @pytest.fixture + def sample_opening_hours(self): + # Monday 8am-8:30pm (480-1230) + # Tuesday 24 hours (1440-2879) + # Sunday 12am-11:59pm (8640-10079) + intervals = [ + BusinessOpeningHoursInterval(480, 1230), # Monday 8am-8:30pm UTC + BusinessOpeningHoursInterval(1440, 2879), # Tuesday 24 hours UTC + BusinessOpeningHoursInterval(8640, 10079), # Sunday 12am-11:59pm UTC + ] + return BusinessOpeningHours(time_zone_name="UTC", opening_hours=intervals) + + def test_is_open_during_business_hours(self, sample_opening_hours): + # Monday 10am UTC (within 8am-8:30pm) + dt = dtm.datetime(2023, 11, 6, 10, 0, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is True + + def test_is_open_at_opening_time(self, sample_opening_hours): + # Monday exactly 8am UTC + dt = dtm.datetime(2023, 11, 6, 8, 0, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is True + + def test_is_closed_at_closing_time(self, sample_opening_hours): + # Monday exactly 8:30pm UTC (closing time is exclusive) + dt = dtm.datetime(2023, 11, 6, 20, 30, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is False + + def test_is_closed_outside_business_hours(self, sample_opening_hours): + # Monday 7am UTC (before opening) + dt = dtm.datetime(2023, 11, 6, 7, 0, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is False + + def test_is_open_24h_day(self, sample_opening_hours): + # Tuesday 3am UTC (24h opening) + dt = dtm.datetime(2023, 11, 7, 3, 0, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is True + + def test_is_closed_on_day_with_no_hours(self, sample_opening_hours): + # Wednesday (no opening hours) + dt = dtm.datetime(2023, 11, 8, 12, 0, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is False + + def test_timezone_conversion(self, sample_opening_hours): + # Monday 5am EDT is 10am UTC (should be open) + dt = dtm.datetime(2023, 11, 6, 5, 0, tzinfo=ZoneInfo("America/New_York")) + assert sample_opening_hours.is_open(dt) is True + + # Monday 2am EDT is 7am UTC (should be closed) + dt = dtm.datetime(2023, 11, 6, 2, 0, tzinfo=ZoneInfo("America/New_York")) + assert sample_opening_hours.is_open(dt) is False + + def test_naive_datetime_uses_business_timezone(self, sample_opening_hours): + # Naive datetime - should be interpreted as UTC (business timezone) + dt = dtm.datetime(2023, 11, 6, 10, 0) # 10am naive + assert sample_opening_hours.is_open(dt) is True + + def test_boundary_conditions(self, sample_opening_hours): + # Sunday 11:58pm UTC (should be open) + dt = dtm.datetime(2023, 11, 12, 23, 58, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is True + + # Sunday 11:59pm UTC (should be closed) + dt = dtm.datetime(2023, 11, 12, 23, 59, tzinfo=ZoneInfo("UTC")) + assert sample_opening_hours.is_open(dt) is False diff --git a/tests/test_business_methods.py b/tests/test_business_methods.py new file mode 100644 index 00000000000..243bd753799 --- /dev/null +++ b/tests/test_business_methods.py @@ -0,0 +1,887 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm + +import pytest + +from telegram import ( + Bot, + BusinessBotRights, + BusinessConnection, + Chat, + InputProfilePhotoStatic, + InputStoryContentPhoto, + MessageEntity, + StarAmount, + Story, + StoryAreaTypeLink, + StoryAreaTypeUniqueGift, + User, +) +from telegram._files._inputstorycontent import InputStoryContentVideo +from telegram._files.sticker import Sticker +from telegram._gifts import AcceptedGiftTypes, Gift +from telegram._inline.inlinekeyboardbutton import InlineKeyboardButton +from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup +from telegram._inputchecklist import InputChecklist, InputChecklistTask +from telegram._message import Message +from telegram._ownedgift import OwnedGiftRegular, OwnedGifts +from telegram._reply import ReplyParameters +from telegram._utils.datetime import UTC +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram.constants import InputProfilePhotoType, InputStoryContentType +from telegram.ext import ExtBot +from telegram.warnings import PTBDeprecationWarning +from tests.auxil.files import data_file +from tests.auxil.networking import OfflineRequest + + +class BusinessMethodsTestBase: + bci = "42" + + +class TestBusinessMethodsWithoutRequest(BusinessMethodsTestBase): + async def test_get_business_connection(self, offline_bot, monkeypatch): + user = User(1, "first", False) + user_chat_id = 1 + date = dtm.datetime.utcnow() + rights = BusinessBotRights(can_reply=True) + is_enabled = True + bc = BusinessConnection( + self.bci, + user, + user_chat_id, + date, + is_enabled, + rights=rights, + ).to_json() + + async def do_request(*args, **kwargs): + data = kwargs.get("request_data") + obj = data.parameters.get("business_connection_id") + if obj == self.bci: + return 200, f'{{"ok": true, "result": {bc}}}'.encode() + return 400, b'{"ok": false, "result": []}' + + monkeypatch.setattr(offline_bot.request, "do_request", do_request) + obj = await offline_bot.get_business_connection(business_connection_id=self.bci) + assert isinstance(obj, BusinessConnection) + + @pytest.mark.parametrize("bool_param", [True, False, None]) + async def test_get_business_account_gifts(self, offline_bot, monkeypatch, bool_param): + offset = 50 + limit = 50 + owned_gifts = OwnedGifts( + total_count=1, + gifts=[ + OwnedGiftRegular( + gift=Gift( + id="id1", + sticker=Sticker( + "file_id", "file_unique_id", 512, 512, False, False, "regular" + ), + star_count=5, + ), + send_date=dtm.datetime.now(tz=UTC).replace(microsecond=0), + owned_gift_id="some_id_1", + ) + ], + ).to_json() + + async def do_request_and_make_assertions(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("exclude_unsaved") is bool_param + assert data.get("exclude_saved") is bool_param + assert data.get("exclude_unlimited") is bool_param + assert data.get("exclude_limited") is bool_param + assert data.get("exclude_limited_upgradable") is bool_param + assert data.get("exclude_limited_non_upgradable") is bool_param + assert data.get("exclude_unique") is bool_param + assert data.get("exclude_from_blockchain") is bool_param + assert data.get("sort_by_price") is bool_param + assert data.get("offset") == offset + assert data.get("limit") == limit + + return 200, f'{{"ok": true, "result": {owned_gifts}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", do_request_and_make_assertions) + obj = await offline_bot.get_business_account_gifts( + business_connection_id=self.bci, + exclude_unsaved=bool_param, + exclude_saved=bool_param, + exclude_unlimited=bool_param, + exclude_limited=bool_param, + exclude_limited_upgradable=bool_param, + exclude_limited_non_upgradable=bool_param, + exclude_unique=bool_param, + exclude_from_blockchain=bool_param, + sort_by_price=bool_param, + offset=offset, + limit=limit, + ) + assert isinstance(obj, OwnedGifts) + + @pytest.mark.parametrize("bot_class", [Bot, ExtBot]) + async def test_get_business_account_gifts_exclude_limited_deprecation( + self, offline_bot, monkeypatch, bot_class + ): + bot = bot_class(offline_bot.token, request=OfflineRequest()) + + async def dummy_response(*args, **kwargs): + return OwnedGifts( + total_count=1, + gifts=[ + OwnedGiftRegular( + gift=Gift( + id="id1", + sticker=Sticker( + "file_id", "file_unique_id", 512, 512, False, False, "regular" + ), + star_count=5, + ), + send_date=dtm.datetime.now(tz=UTC).replace(microsecond=0), + owned_gift_id="some_id_1", + ) + ], + ).to_dict() + + monkeypatch.setattr(bot.request, "post", dummy_response) + with pytest.warns(PTBDeprecationWarning, match=r"9\.3.*exclude_limited") as record: + await bot.get_business_account_gifts( + business_connection_id=self.bci, + exclude_limited=True, + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + + async def test_get_business_account_star_balance(self, offline_bot, monkeypatch): + star_amount_json = StarAmount(amount=100, nanostar_amount=356).to_json() + + async def do_request(*args, **kwargs): + data = kwargs.get("request_data") + obj = data.parameters.get("business_connection_id") + if obj == self.bci: + return 200, f'{{"ok": true, "result": {star_amount_json}}}'.encode() + return 400, b'{"ok": false, "result": []}' + + monkeypatch.setattr(offline_bot.request, "do_request", do_request) + obj = await offline_bot.get_business_account_star_balance(business_connection_id=self.bci) + assert isinstance(obj, StarAmount) + + async def test_read_business_message(self, offline_bot, monkeypatch): + chat_id = 43 + message_id = 44 + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("chat_id") == chat_id + assert data.get("message_id") == message_id + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.read_business_message( + business_connection_id=self.bci, chat_id=chat_id, message_id=message_id + ) + + async def test_delete_business_messages(self, offline_bot, monkeypatch): + message_ids = [1, 2, 3] + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("message_ids") == message_ids + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.delete_business_messages( + business_connection_id=self.bci, message_ids=message_ids + ) + + @pytest.mark.parametrize("last_name", [None, "last_name"]) + async def test_set_business_account_name(self, offline_bot, monkeypatch, last_name): + first_name = "Test Business Account" + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("first_name") == first_name + assert data.get("last_name") == last_name + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.set_business_account_name( + business_connection_id=self.bci, first_name=first_name, last_name=last_name + ) + + @pytest.mark.parametrize("username", ["username", None]) + async def test_set_business_account_username(self, offline_bot, monkeypatch, username): + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("username") == username + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.set_business_account_username( + business_connection_id=self.bci, username=username + ) + + @pytest.mark.parametrize("bio", ["bio", None]) + async def test_set_business_account_bio(self, offline_bot, monkeypatch, bio): + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("bio") == bio + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.set_business_account_bio(business_connection_id=self.bci, bio=bio) + + async def test_set_business_account_gift_settings(self, offline_bot, monkeypatch): + show_gift_button = True + accepted_gift_types = AcceptedGiftTypes(True, True, True, True, True) + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").json_parameters + assert data.get("business_connection_id") == self.bci + assert data.get("show_gift_button") == "true" + assert data.get("accepted_gift_types") == accepted_gift_types.to_json() + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.set_business_account_gift_settings( + business_connection_id=self.bci, + show_gift_button=show_gift_button, + accepted_gift_types=accepted_gift_types, + ) + + async def test_convert_gift_to_stars(self, offline_bot, monkeypatch): + owned_gift_id = "some_id" + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("owned_gift_id") == owned_gift_id + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.convert_gift_to_stars( + business_connection_id=self.bci, + owned_gift_id=owned_gift_id, + ) + + @pytest.mark.parametrize("keep_original_details", [True, None]) + @pytest.mark.parametrize("star_count", [100, None]) + async def test_upgrade_gift(self, offline_bot, monkeypatch, keep_original_details, star_count): + owned_gift_id = "some_id" + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("owned_gift_id") == owned_gift_id + assert data.get("keep_original_details") is keep_original_details + assert data.get("star_count") == star_count + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.upgrade_gift( + business_connection_id=self.bci, + owned_gift_id=owned_gift_id, + keep_original_details=keep_original_details, + star_count=star_count, + ) + + @pytest.mark.parametrize("star_count", [100, None]) + async def test_transfer_gift(self, offline_bot, monkeypatch, star_count): + owned_gift_id = "some_id" + new_owner_chat_id = 123 + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("owned_gift_id") == owned_gift_id + assert data.get("new_owner_chat_id") == new_owner_chat_id + assert data.get("star_count") == star_count + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.transfer_gift( + business_connection_id=self.bci, + owned_gift_id=owned_gift_id, + new_owner_chat_id=new_owner_chat_id, + star_count=star_count, + ) + + async def test_transfer_business_account_stars(self, offline_bot, monkeypatch): + star_count = 100 + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("star_count") == star_count + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.transfer_business_account_stars( + business_connection_id=self.bci, + star_count=star_count, + ) + + @pytest.mark.parametrize("is_public", [True, False, None, DEFAULT_NONE]) + async def test_set_business_account_profile_photo(self, offline_bot, monkeypatch, is_public): + async def make_assertion(*args, **kwargs): + request_data = kwargs.get("request_data") + params = request_data.parameters + assert params.get("business_connection_id") == self.bci + if is_public is DEFAULT_NONE: + assert "is_public" not in params + else: + assert params.get("is_public") == is_public + + assert (photo_dict := params.get("photo")).get("type") == InputProfilePhotoType.STATIC + assert (photo_attach := photo_dict["photo"]).startswith("attach://") + assert isinstance( + request_data.multipart_data.get(photo_attach.removeprefix("attach://")), tuple + ) + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "photo": InputProfilePhotoStatic( + photo=data_file("telegram.jpg").read_bytes(), + ), + } + if is_public is not DEFAULT_NONE: + kwargs["is_public"] = is_public + + assert await offline_bot.set_business_account_profile_photo(**kwargs) + + async def test_set_business_account_profile_photo_local_file(self, offline_bot, monkeypatch): + async def make_assertion(*args, **kwargs): + request_data = kwargs.get("request_data") + params = request_data.parameters + assert params.get("business_connection_id") == self.bci + + assert (photo_dict := params.get("photo")).get("type") == InputProfilePhotoType.STATIC + assert photo_dict["photo"] == data_file("telegram.jpg").as_uri() + assert not request_data.multipart_data + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "photo": InputProfilePhotoStatic( + photo=data_file("telegram.jpg"), + ), + } + + assert await offline_bot.set_business_account_profile_photo(**kwargs) + + @pytest.mark.parametrize("is_public", [True, False, None, DEFAULT_NONE]) + async def test_remove_business_account_profile_photo( + self, offline_bot, monkeypatch, is_public + ): + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + if is_public is DEFAULT_NONE: + assert "is_public" not in data + else: + assert data.get("is_public") == is_public + + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + kwargs = {"business_connection_id": self.bci} + if is_public is not DEFAULT_NONE: + kwargs["is_public"] = is_public + + assert await offline_bot.remove_business_account_profile_photo(**kwargs) + + @pytest.mark.parametrize("active_period", [dtm.timedelta(seconds=30), 30]) + async def test_post_story_all_args(self, offline_bot, monkeypatch, active_period): + content = InputStoryContentPhoto(photo=data_file("telegram.jpg").read_bytes()) + caption = "test caption" + caption_entities = [ + MessageEntity(MessageEntity.BOLD, 0, 3), + MessageEntity(MessageEntity.ITALIC, 5, 11), + ] + parse_mode = "Markdown" + areas = [StoryAreaTypeLink("http_url"), StoryAreaTypeUniqueGift("unique_gift_name")] + post_to_chat_page = True + protect_content = True + json_story = Story(chat=Chat(123, "private"), id=123).to_json() + + async def do_request_and_make_assertions(*args, **kwargs): + request_data = kwargs.get("request_data") + params = kwargs.get("request_data").parameters + assert params.get("business_connection_id") == self.bci + assert params.get("active_period") == 30 + assert params.get("caption") == caption + assert params.get("caption_entities") == [e.to_dict() for e in caption_entities] + assert params.get("parse_mode") == parse_mode + assert params.get("areas") == [area.to_dict() for area in areas] + assert params.get("post_to_chat_page") is post_to_chat_page + assert params.get("protect_content") is protect_content + + assert (content_dict := params.get("content")).get( + "type" + ) == InputStoryContentType.PHOTO + assert (photo_attach := content_dict["photo"]).startswith("attach://") + assert isinstance( + request_data.multipart_data.get(photo_attach.removeprefix("attach://")), tuple + ) + + return 200, f'{{"ok": true, "result": {json_story}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", do_request_and_make_assertions) + obj = await offline_bot.post_story( + business_connection_id=self.bci, + content=content, + active_period=active_period, + caption=caption, + caption_entities=caption_entities, + parse_mode=parse_mode, + areas=areas, + post_to_chat_page=post_to_chat_page, + protect_content=protect_content, + ) + assert isinstance(obj, Story) + + @pytest.mark.parametrize("active_period", [dtm.timedelta(seconds=30), 30]) + async def test_post_story_local_file(self, offline_bot, monkeypatch, active_period): + json_story = Story(chat=Chat(123, "private"), id=123).to_json() + + async def make_assertion(*args, **kwargs): + request_data = kwargs.get("request_data") + params = request_data.parameters + assert params.get("business_connection_id") == self.bci + + assert (content_dict := params.get("content")).get( + "type" + ) == InputStoryContentType.PHOTO + assert content_dict["photo"] == data_file("telegram.jpg").as_uri() + assert not request_data.multipart_data + + return 200, f'{{"ok": true, "result": {json_story}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "content": InputStoryContentPhoto( + photo=data_file("telegram.jpg"), + ), + "active_period": active_period, + } + + assert await offline_bot.post_story(**kwargs) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, "Markdown"), ("HTML", "HTML"), (None, None)], + ) + async def test_post_story_default_parse_mode( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("parse_mode") == expected_value + return Story(chat=Chat(123, "private"), id=123).to_dict() + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "content": InputStoryContentPhoto(photo=data_file("telegram.jpg").read_bytes()), + "active_period": dtm.timedelta(seconds=20), + "caption": "caption", + } + if passed_value is not DEFAULT_NONE: + kwargs["parse_mode"] = passed_value + + await default_bot.post_story(**kwargs) + + @pytest.mark.parametrize("default_bot", [{"protect_content": True}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, True), (False, False), (None, None)], + ) + async def test_post_story_default_protect_content( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("protect_content") == expected_value + return Story(chat=Chat(123, "private"), id=123).to_dict() + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "content": InputStoryContentPhoto(bytes("photo", encoding="utf-8")), + "active_period": dtm.timedelta(seconds=20), + } + if passed_value is not DEFAULT_NONE: + kwargs["protect_content"] = passed_value + + await default_bot.post_story(**kwargs) + + @pytest.mark.parametrize( + ("argument", "expected"), + [(4, 4), (4.0, 4), (dtm.timedelta(seconds=4), 4), (4.5, 4.5)], + ) + async def test_post_story_float_time_period( + self, offline_bot, monkeypatch, argument, expected + ): + # We test that whole number conversion works properly. Only tested here but + # relevant for some other methods too (e.g bot.set_business_account_profile_photo) + async def make_assertion(url, request_data, *args, **kwargs): + data = request_data.parameters + content = data["content"] + + assert content["duration"] == expected + assert type(content["duration"]) is type(expected) + assert content["cover_frame_timestamp"] == expected + assert type(content["cover_frame_timestamp"]) is type(expected) + + return Story(chat=Chat(123, "private"), id=123).to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "content": InputStoryContentVideo( + video=data_file("telegram.mp4"), + duration=argument, + cover_frame_timestamp=argument, + ), + "active_period": dtm.timedelta(seconds=20), + } + + assert await offline_bot.post_story(**kwargs) + + async def test_edit_story_all_args(self, offline_bot, monkeypatch): + story_id = 1234 + content = InputStoryContentPhoto(photo=data_file("telegram.jpg").read_bytes()) + caption = "test caption" + caption_entities = [ + MessageEntity(MessageEntity.BOLD, 0, 3), + MessageEntity(MessageEntity.ITALIC, 5, 11), + ] + parse_mode = "Markdown" + areas = [StoryAreaTypeLink("http_url"), StoryAreaTypeUniqueGift("unique_gift_name")] + json_story = Story(chat=Chat(123, "private"), id=123).to_json() + + async def do_request_and_make_assertions(*args, **kwargs): + request_data = kwargs.get("request_data") + params = kwargs.get("request_data").parameters + assert params.get("business_connection_id") == self.bci + assert params.get("story_id") == story_id + assert params.get("caption") == caption + assert params.get("caption_entities") == [e.to_dict() for e in caption_entities] + assert params.get("parse_mode") == parse_mode + assert params.get("areas") == [area.to_dict() for area in areas] + + assert (content_dict := params.get("content")).get( + "type" + ) == InputStoryContentType.PHOTO + assert (photo_attach := content_dict["photo"]).startswith("attach://") + assert isinstance( + request_data.multipart_data.get(photo_attach.removeprefix("attach://")), tuple + ) + + return 200, f'{{"ok": true, "result": {json_story}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", do_request_and_make_assertions) + obj = await offline_bot.edit_story( + business_connection_id=self.bci, + story_id=story_id, + content=content, + caption=caption, + caption_entities=caption_entities, + parse_mode=parse_mode, + areas=areas, + ) + assert isinstance(obj, Story) + + async def test_edit_story_local_file(self, offline_bot, monkeypatch): + json_story = Story(chat=Chat(123, "private"), id=123).to_json() + + async def make_assertion(*args, **kwargs): + request_data = kwargs.get("request_data") + params = request_data.parameters + assert params.get("business_connection_id") == self.bci + + assert (content_dict := params.get("content")).get( + "type" + ) == InputStoryContentType.PHOTO + assert content_dict["photo"] == data_file("telegram.jpg").as_uri() + assert not request_data.multipart_data + + return 200, f'{{"ok": true, "result": {json_story}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "story_id": 1234, + "content": InputStoryContentPhoto( + photo=data_file("telegram.jpg"), + ), + } + + assert await offline_bot.edit_story(**kwargs) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, "Markdown"), ("HTML", "HTML"), (None, None)], + ) + async def test_edit_story_default_parse_mode( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("parse_mode") == expected_value + return Story(chat=Chat(123, "private"), id=123).to_dict() + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "story_id": 1234, + "content": InputStoryContentPhoto(photo=data_file("telegram.jpg").read_bytes()), + "caption": "caption", + } + if passed_value is not DEFAULT_NONE: + kwargs["parse_mode"] = passed_value + + await default_bot.edit_story(**kwargs) + + async def test_delete_story(self, offline_bot, monkeypatch): + story_id = 123 + + async def make_assertion(*args, **kwargs): + data = kwargs.get("request_data").parameters + assert data.get("business_connection_id") == self.bci + assert data.get("story_id") == story_id + return True + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.delete_story(business_connection_id=self.bci, story_id=story_id) + + async def test_send_checklist_all_args(self, offline_bot, monkeypatch): + chat_id = 123 + checklist = InputChecklist( + title="My Checklist", + tasks=[InputChecklistTask(1, "Task 1"), InputChecklistTask(2, "Task 2")], + ) + disable_notification = True + protect_content = False + message_effect_id = 42 + reply_parameters = ReplyParameters(23, chat_id, allow_sending_without_reply=True) + reply_markup = InlineKeyboardMarkup( + [[InlineKeyboardButton(text="test", callback_data="test2")]] + ) + json_message = Message(1, dtm.datetime.now(), Chat(1, ""), text="test").to_json() + + async def make_assertions(*args, **kwargs): + params = kwargs.get("request_data").parameters + assert params.get("business_connection_id") == self.bci + assert params.get("chat_id") == chat_id + assert params.get("checklist") == checklist.to_dict() + assert params.get("disable_notification") is disable_notification + assert params.get("protect_content") is protect_content + assert params.get("message_effect_id") == message_effect_id + assert params.get("reply_parameters") == reply_parameters.to_dict() + assert params.get("reply_markup") == reply_markup.to_dict() + + return 200, f'{{"ok": true, "result": {json_message}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", make_assertions) + obj = await offline_bot.send_checklist( + business_connection_id=self.bci, + chat_id=chat_id, + checklist=checklist, + disable_notification=disable_notification, + protect_content=protect_content, + message_effect_id=message_effect_id, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + ) + assert isinstance(obj, Message) + + @pytest.mark.parametrize("default_bot", [{"disable_notification": True}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, True), (False, False), (None, None)], + ) + async def test_send_checklist_default_disable_notification( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("disable_notification") is expected_value + return Message(1, dtm.datetime.now(), Chat(1, ""), text="test").to_dict() + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "chat_id": 123, + "checklist": InputChecklist( + title="My Checklist", + tasks=[InputChecklistTask(1, "Task 1")], + ), + } + if passed_value is not DEFAULT_NONE: + kwargs["disable_notification"] = passed_value + + await default_bot.send_checklist(**kwargs) + + @pytest.mark.parametrize("default_bot", [{"protect_content": True}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, True), (False, False), (None, None)], + ) + async def test_send_checklist_default_protect_content( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("protect_content") is expected_value + return Message(1, dtm.datetime.now(), Chat(1, ""), text="test").to_dict() + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "chat_id": 123, + "checklist": InputChecklist( + title="My Checklist", + tasks=[InputChecklistTask(1, "Task 1")], + ), + } + if passed_value is not DEFAULT_NONE: + kwargs["protect_content"] = passed_value + + await default_bot.send_checklist(**kwargs) + + async def test_send_checklist_mutually_exclusive_reply_parameters(self, offline_bot): + """Test that reply_to_message_id and allow_sending_without_reply are mutually exclusive + with reply_parameters.""" + with pytest.raises(ValueError, match="`reply_to_message_id` and"): + await offline_bot.send_checklist( + self.bci, + 123, + InputChecklist(title="My Checklist", tasks=[InputChecklistTask(1, "Task 1")]), + reply_to_message_id=1, + reply_parameters=True, + ) + + with pytest.raises(ValueError, match="`allow_sending_without_reply` and"): + await offline_bot.send_checklist( + self.bci, + 123, + InputChecklist(title="My Checklist", tasks=[InputChecklistTask(1, "Task 1")]), + allow_sending_without_reply=True, + reply_parameters=True, + ) + + async def test_edit_message_checklist_all_args(self, offline_bot, monkeypatch): + chat_id = 123 + message_id = 45 + checklist = InputChecklist( + title="My Checklist", + tasks=[InputChecklistTask(1, "Task 1"), InputChecklistTask(2, "Task 2")], + ) + reply_markup = InlineKeyboardMarkup( + [[InlineKeyboardButton(text="test", callback_data="test2")]] + ) + json_message = Message(1, dtm.datetime.now(), Chat(1, ""), text="test").to_json() + + async def make_assertions(*args, **kwargs): + params = kwargs.get("request_data").parameters + assert params.get("business_connection_id") == self.bci + assert params.get("chat_id") == chat_id + assert params.get("message_id") == message_id + assert params.get("checklist") == checklist.to_dict() + assert params.get("reply_markup") == reply_markup.to_dict() + + return 200, f'{{"ok": true, "result": {json_message}}}'.encode() + + monkeypatch.setattr(offline_bot.request, "do_request", make_assertions) + obj = await offline_bot.edit_message_checklist( + business_connection_id=self.bci, + chat_id=chat_id, + message_id=message_id, + checklist=checklist, + reply_markup=reply_markup, + ) + assert isinstance(obj, Message) + + async def test_repost_story(self, offline_bot, monkeypatch): + """No way to test this without stories""" + + async def make_assertion(url, request_data, *args, **kwargs): + for param in ( + "business_connection_id", + "from_chat_id", + "from_story_id", + "active_period", + "post_to_chat_page", + "protect_content", + ): + assert request_data.parameters.get(param) == param + return Story(chat=Chat(id=1, type=Chat.PRIVATE), id=42).to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + story = await offline_bot.repost_story( + business_connection_id="business_connection_id", + from_chat_id="from_chat_id", + from_story_id="from_story_id", + active_period="active_period", + post_to_chat_page="post_to_chat_page", + protect_content="protect_content", + ) + assert story.chat.id == 1 + assert story.id == 42 + + @pytest.mark.parametrize("default_bot", [{"protect_content": True}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, True), (False, False), (None, None)], + ) + async def test_repost_story_default_protect_content( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data, *args, **kwargs): + assert request_data.parameters.get("protect_content") == expected_value + return Story(chat=Chat(123, "private"), id=123).to_dict() + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "business_connection_id": self.bci, + "from_chat_id": 123, + "from_story_id": 456, + "active_period": dtm.timedelta(seconds=20), + } + if passed_value is not DEFAULT_NONE: + kwargs["protect_content"] = passed_value + + await default_bot.repost_story(**kwargs) diff --git a/tests/test_callbackquery.py b/tests/test_callbackquery.py index f1b598a775d..d45f171ef8a 100644 --- a/tests/test_callbackquery.py +++ b/tests/test_callbackquery.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,11 +17,21 @@ # 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 datetime import datetime +import datetime as dtm import pytest -from telegram import Audio, Bot, CallbackQuery, Chat, Message, User +from telegram import ( + Audio, + Bot, + CallbackQuery, + Chat, + InaccessibleMessage, + InputChecklist, + InputChecklistTask, + Message, + User, +) from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -30,41 +40,48 @@ from tests.auxil.slots import mro_slots -@pytest.fixture(params=["message", "inline"]) +@pytest.fixture(params=["message", "inline", "inaccessible_message"]) def callback_query(bot, request): cbq = CallbackQuery( - TestCallbackQueryBase.id_, - TestCallbackQueryBase.from_user, - TestCallbackQueryBase.chat_instance, - data=TestCallbackQueryBase.data, - game_short_name=TestCallbackQueryBase.game_short_name, + CallbackQueryTestBase.id_, + CallbackQueryTestBase.from_user, + CallbackQueryTestBase.chat_instance, + data=CallbackQueryTestBase.data, + game_short_name=CallbackQueryTestBase.game_short_name, ) cbq.set_bot(bot) cbq._unfreeze() if request.param == "message": - cbq.message = TestCallbackQueryBase.message + cbq.message = CallbackQueryTestBase.message cbq.message.set_bot(bot) - else: - cbq.inline_message_id = TestCallbackQueryBase.inline_message_id + elif request.param == "inline": + cbq.inline_message_id = CallbackQueryTestBase.inline_message_id + elif request.param == "inaccessible_message": + cbq.message = InaccessibleMessage( + chat=CallbackQueryTestBase.message.chat, + message_id=CallbackQueryTestBase.message.message_id, + ) return cbq -class TestCallbackQueryBase: +class CallbackQueryTestBase: id_ = "id" from_user = User(1, "test_user", False) chat_instance = "chat_instance" - message = Message(3, datetime.utcnow(), Chat(4, "private"), from_user=User(5, "bot", False)) + message = Message( + 3, dtm.datetime.utcnow(), Chat(4, "private"), from_user=User(5, "bot", False) + ) data = "data" inline_message_id = "inline_message_id" game_short_name = "the_game" -class TestCallbackQueryWithoutRequest(TestCallbackQueryBase): +class TestCallbackQueryWithoutRequest(CallbackQueryTestBase): @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): @@ -89,7 +106,7 @@ def test_slot_behaviour(self, callback_query): assert getattr(callback_query, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(callback_query)) == len(set(mro_slots(callback_query))), "same slot" - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "id": self.id_, "from": self.from_user.to_dict(), @@ -99,7 +116,7 @@ def test_de_json(self, bot): "inline_message_id": self.inline_message_id, "game_short_name": self.game_short_name, } - callback_query = CallbackQuery.de_json(json_dict, bot) + callback_query = CallbackQuery.de_json(json_dict, offline_bot) assert callback_query.api_kwargs == {} assert callback_query.id == self.id_ @@ -117,9 +134,9 @@ def test_to_dict(self, callback_query): assert callback_query_dict["id"] == callback_query.id assert callback_query_dict["from"] == callback_query.from_user.to_dict() assert callback_query_dict["chat_instance"] == callback_query.chat_instance - if callback_query.message: + if callback_query.message is not None: assert callback_query_dict["message"] == callback_query.message.to_dict() - else: + elif callback_query.inline_message_id: assert callback_query_dict["inline_message_id"] == callback_query.inline_message_id assert callback_query_dict["data"] == callback_query.data assert callback_query_dict["game_short_name"] == callback_query.game_short_name @@ -160,6 +177,11 @@ async def make_assertion(*_, **kwargs): assert await callback_query.answer() async def test_edit_message_text(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.edit_message_text("test") + return + async def make_assertion(*_, **kwargs): text = kwargs["text"] == "test" ids = self.check_passed_ids(callback_query, kwargs) @@ -168,7 +190,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( @@ -187,6 +209,11 @@ async def make_assertion(*_, **kwargs): assert await callback_query.edit_message_text("test") async def test_edit_message_caption(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.edit_message_caption("test") + return + async def make_assertion(*_, **kwargs): caption = kwargs["caption"] == "new caption" ids = self.check_passed_ids(callback_query, kwargs) @@ -195,7 +222,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( @@ -213,7 +240,49 @@ async def make_assertion(*_, **kwargs): assert await callback_query.edit_message_caption(caption="new caption") assert await callback_query.edit_message_caption("new caption") + async def test_edit_message_checklist(self, monkeypatch, callback_query): + checklist = InputChecklist(title="My Checklist", tasks=[InputChecklistTask(1, "Task 1")]) + + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.edit_message_checklist(checklist) + return + + if callback_query.inline_message_id: + pytest.skip("Can't edit inline messages") + + async def make_assertion(*_, **kwargs): + chat_id = kwargs["chat_id"] == callback_query.message.chat_id + message_id = kwargs["message_id"] == callback_query.message.message_id + caption = kwargs["checklist"] == checklist + return chat_id and message_id and caption + + assert check_shortcut_signature( + CallbackQuery.edit_message_checklist, + Bot.edit_message_checklist, + ["chat_id", "message_id", "business_connection_id"], + [], + ) + assert await check_shortcut_call( + callback_query.edit_message_checklist, + callback_query.get_bot(), + "edit_message_checklist", + shortcut_kwargs=["chat_id", "message_id", "business_connection_id"], + ) + assert await check_defaults_handling( + callback_query.edit_message_checklist, callback_query.get_bot() + ) + + monkeypatch.setattr(callback_query.get_bot(), "edit_message_checklist", make_assertion) + assert await callback_query.edit_message_checklist(checklist=checklist) + assert await callback_query.edit_message_checklist(checklist) + async def test_edit_message_reply_markup(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.edit_message_reply_markup("test") + return + async def make_assertion(*_, **kwargs): reply_markup = kwargs["reply_markup"] == [["1", "2"]] ids = self.check_passed_ids(callback_query, kwargs) @@ -222,7 +291,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( @@ -241,6 +310,11 @@ async def make_assertion(*_, **kwargs): assert await callback_query.edit_message_reply_markup([["1", "2"]]) async def test_edit_message_media(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.edit_message_media("test") + return + async def make_assertion(*_, **kwargs): message_media = kwargs.get("media") == [["1", "2"]] ids = self.check_passed_ids(callback_query, kwargs) @@ -249,7 +323,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( @@ -268,16 +342,22 @@ async def make_assertion(*_, **kwargs): assert await callback_query.edit_message_media([["1", "2"]]) async def test_edit_message_live_location(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.edit_message_live_location("test") + return + async def make_assertion(*_, **kwargs): latitude = kwargs.get("latitude") == 1 longitude = kwargs.get("longitude") == 2 + live = kwargs.get("live_period") == 900 ids = self.check_passed_ids(callback_query, kwargs) - return ids and latitude and longitude + return ids and latitude and longitude and live 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( @@ -292,17 +372,24 @@ async def make_assertion(*_, **kwargs): ) monkeypatch.setattr(callback_query.get_bot(), "edit_message_live_location", make_assertion) - assert await callback_query.edit_message_live_location(latitude=1, longitude=2) - assert await callback_query.edit_message_live_location(1, 2) + assert await callback_query.edit_message_live_location( + latitude=1, longitude=2, live_period=900 + ) + assert await callback_query.edit_message_live_location(1, 2, live_period=900) async def test_stop_message_live_location(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.stop_message_live_location("test") + return + async def make_assertion(*_, **kwargs): return self.check_passed_ids(callback_query, 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( @@ -320,6 +407,11 @@ async def make_assertion(*_, **kwargs): assert await callback_query.stop_message_live_location() async def test_set_game_score(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.set_game_score(user_id=1, score=2) + return + async def make_assertion(*_, **kwargs): user_id = kwargs.get("user_id") == 1 score = kwargs.get("score") == 2 @@ -348,6 +440,11 @@ async def make_assertion(*_, **kwargs): assert await callback_query.set_game_score(1, 2) async def test_get_game_high_scores(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.get_game_high_scores("test") + return + async def make_assertion(*_, **kwargs): user_id = kwargs.get("user_id") == 1 ids = self.check_passed_ids(callback_query, kwargs) @@ -375,6 +472,10 @@ async def make_assertion(*_, **kwargs): assert await callback_query.get_game_high_scores(1) async def test_delete_message(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.delete_message() + return if callback_query.inline_message_id: pytest.skip("Can't delete inline messages") @@ -400,6 +501,10 @@ async def make_assertion(*args, **kwargs): assert await callback_query.delete_message() async def test_pin_message(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.pin_message() + return if callback_query.inline_message_id: pytest.skip("Can't pin inline messages") @@ -409,11 +514,14 @@ async def make_assertion(*args, **kwargs): assert check_shortcut_signature( CallbackQuery.pin_message, Bot.pin_chat_message, - ["message_id", "chat_id"], + ["message_id", "chat_id", "business_connection_id"], [], ) assert await check_shortcut_call( - callback_query.pin_message, callback_query.get_bot(), "pin_chat_message" + callback_query.pin_message, + callback_query.get_bot(), + "pin_chat_message", + ["business_connection_id"], ) assert await check_defaults_handling(callback_query.pin_message, callback_query.get_bot()) @@ -421,6 +529,10 @@ async def make_assertion(*args, **kwargs): assert await callback_query.pin_message() async def test_unpin_message(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.unpin_message() + return if callback_query.inline_message_id: pytest.skip("Can't unpin inline messages") @@ -430,14 +542,15 @@ async def make_assertion(*args, **kwargs): assert check_shortcut_signature( CallbackQuery.unpin_message, Bot.unpin_chat_message, - ["message_id", "chat_id"], + ["message_id", "chat_id", "business_connection_id"], [], ) assert await check_shortcut_call( callback_query.unpin_message, callback_query.get_bot(), "unpin_chat_message", - shortcut_kwargs=["message_id", "chat_id"], + shortcut_kwargs=["message_id"], + skip_params=["business_connection_id"], ) assert await check_defaults_handling( callback_query.unpin_message, callback_query.get_bot() @@ -447,6 +560,10 @@ async def make_assertion(*args, **kwargs): assert await callback_query.unpin_message() async def test_copy_message(self, monkeypatch, callback_query): + if isinstance(callback_query.message, InaccessibleMessage): + with pytest.raises(TypeError, match="inaccessible message"): + await callback_query.copy_message(1) + return if callback_query.inline_message_id: pytest.skip("Can't copy inline messages") @@ -459,11 +576,14 @@ async def make_assertion(*args, **kwargs): assert check_shortcut_signature( CallbackQuery.copy_message, Bot.copy_message, - ["message_id", "from_chat_id"], + ["message_id", "from_chat_id", "direct_messages_topic_id"], [], ) assert await check_shortcut_call( - callback_query.copy_message, callback_query.get_bot(), "copy_message" + callback_query.copy_message, + callback_query.get_bot(), + "copy_message", + shortcut_kwargs=["direct_messages_topic_id"], ) assert await check_defaults_handling(callback_query.copy_message, callback_query.get_bot()) diff --git a/tests/test_chat.py b/tests/test_chat.py index d93be7c9f8c..624711da263 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,10 +17,19 @@ # 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 Bot, Chat, ChatLocation, ChatPermissions, Location, User -from telegram.constants import ChatAction, ChatType +from telegram import ( + Bot, + Chat, + ChatPermissions, + InputChecklist, + InputChecklistTask, + ReactionTypeEmoji, + User, +) +from telegram.constants import ChatAction, ChatType, ReactionEmoji from telegram.helpers import escape_markdown from tests.auxil.bot_method_checks import ( check_defaults_handling, @@ -33,125 +42,58 @@ @pytest.fixture(scope="module") def chat(bot): chat = Chat( - TestChatBase.id_, - title=TestChatBase.title, - type=TestChatBase.type_, - username=TestChatBase.username, - sticker_set_name=TestChatBase.sticker_set_name, - can_set_sticker_set=TestChatBase.can_set_sticker_set, - permissions=TestChatBase.permissions, - slow_mode_delay=TestChatBase.slow_mode_delay, - bio=TestChatBase.bio, - linked_chat_id=TestChatBase.linked_chat_id, - location=TestChatBase.location, - has_private_forwards=True, - has_protected_content=True, - join_to_send_messages=True, - join_by_request=True, - has_restricted_voice_and_video_messages=True, + ChatTestBase.id_, + title=ChatTestBase.title, + type=ChatTestBase.type_, + username=ChatTestBase.username, is_forum=True, - active_usernames=TestChatBase.active_usernames, - emoji_status_custom_emoji_id=TestChatBase.emoji_status_custom_emoji_id, - has_aggressive_anti_spam_enabled=TestChatBase.has_aggressive_anti_spam_enabled, - has_hidden_members=TestChatBase.has_hidden_members, + first_name=ChatTestBase.first_name, + last_name=ChatTestBase.last_name, + is_direct_messages=ChatTestBase.is_direct_messages, ) chat.set_bot(bot) chat._unfreeze() return chat -class TestChatBase: +class ChatTestBase: id_ = -28767330 title = "ToledosPalaceBot - Group" type_ = "group" username = "username" - all_members_are_administrators = False - sticker_set_name = "stickers" - can_set_sticker_set = False - permissions = ChatPermissions( - can_send_messages=True, - can_change_info=False, - can_invite_users=True, - ) - slow_mode_delay = 30 - bio = "I'm a Barbie Girl in a Barbie World" - linked_chat_id = 11880 - location = ChatLocation(Location(123, 456), "Barbie World") - has_protected_content = True - has_private_forwards = True - join_to_send_messages = True - join_by_request = True - has_restricted_voice_and_video_messages = True is_forum = True - active_usernames = ["These", "Are", "Usernames!"] - emoji_status_custom_emoji_id = "VeryUniqueCustomEmojiID" - has_aggressive_anti_spam_enabled = True - has_hidden_members = True + first_name = "first" + last_name = "last" + is_direct_messages = True -class TestChatWithoutRequest(TestChatBase): +class TestChatWithoutRequest(ChatTestBase): def test_slot_behaviour(self, chat): for attr in chat.__slots__: assert getattr(chat, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(chat)) == len(set(mro_slots(chat))), "duplicate slot" - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "id": self.id_, "title": self.title, "type": self.type_, "username": self.username, - "all_members_are_administrators": self.all_members_are_administrators, - "sticker_set_name": self.sticker_set_name, - "can_set_sticker_set": self.can_set_sticker_set, - "permissions": self.permissions.to_dict(), - "slow_mode_delay": self.slow_mode_delay, - "bio": self.bio, - "has_protected_content": self.has_protected_content, - "has_private_forwards": self.has_private_forwards, - "linked_chat_id": self.linked_chat_id, - "location": self.location.to_dict(), - "join_to_send_messages": self.join_to_send_messages, - "join_by_request": self.join_by_request, - "has_restricted_voice_and_video_messages": ( - self.has_restricted_voice_and_video_messages - ), "is_forum": self.is_forum, - "active_usernames": self.active_usernames, - "emoji_status_custom_emoji_id": self.emoji_status_custom_emoji_id, - "has_aggressive_anti_spam_enabled": self.has_aggressive_anti_spam_enabled, - "has_hidden_members": self.has_hidden_members, + "first_name": self.first_name, + "last_name": self.last_name, + "is_direct_messages": self.is_direct_messages, } - chat = Chat.de_json(json_dict, bot) + chat = Chat.de_json(json_dict, offline_bot) assert chat.id == self.id_ assert chat.title == self.title assert chat.type == self.type_ assert chat.username == self.username - assert chat.sticker_set_name == self.sticker_set_name - assert chat.can_set_sticker_set == self.can_set_sticker_set - assert chat.permissions == self.permissions - assert chat.slow_mode_delay == self.slow_mode_delay - assert chat.bio == self.bio - assert chat.has_protected_content == self.has_protected_content - assert chat.has_private_forwards == self.has_private_forwards - assert chat.linked_chat_id == self.linked_chat_id - assert chat.location.location == self.location.location - assert chat.location.address == self.location.address - assert chat.join_to_send_messages == self.join_to_send_messages - assert chat.join_by_request == self.join_by_request - assert ( - chat.has_restricted_voice_and_video_messages - == self.has_restricted_voice_and_video_messages - ) - assert chat.api_kwargs == { - "all_members_are_administrators": self.all_members_are_administrators - } assert chat.is_forum == self.is_forum - assert chat.active_usernames == tuple(self.active_usernames) - assert chat.emoji_status_custom_emoji_id == self.emoji_status_custom_emoji_id - assert chat.has_aggressive_anti_spam_enabled == self.has_aggressive_anti_spam_enabled - assert chat.has_hidden_members == self.has_hidden_members + assert chat.first_name == self.first_name + assert chat.last_name == self.last_name + assert chat.is_direct_messages == self.is_direct_messages def test_to_dict(self, chat): chat_dict = chat.to_dict() @@ -161,35 +103,10 @@ def test_to_dict(self, chat): assert chat_dict["title"] == chat.title assert chat_dict["type"] == chat.type assert chat_dict["username"] == chat.username - assert chat_dict["permissions"] == chat.permissions.to_dict() - assert chat_dict["slow_mode_delay"] == chat.slow_mode_delay - assert chat_dict["bio"] == chat.bio - assert chat_dict["has_private_forwards"] == chat.has_private_forwards - assert chat_dict["has_protected_content"] == chat.has_protected_content - assert chat_dict["linked_chat_id"] == chat.linked_chat_id - assert chat_dict["location"] == chat.location.to_dict() - assert chat_dict["join_to_send_messages"] == chat.join_to_send_messages - assert chat_dict["join_by_request"] == chat.join_by_request - assert ( - chat_dict["has_restricted_voice_and_video_messages"] - == chat.has_restricted_voice_and_video_messages - ) assert chat_dict["is_forum"] == chat.is_forum - assert chat_dict["active_usernames"] == list(chat.active_usernames) - assert chat_dict["emoji_status_custom_emoji_id"] == chat.emoji_status_custom_emoji_id - assert ( - chat_dict["has_aggressive_anti_spam_enabled"] == chat.has_aggressive_anti_spam_enabled - ) - assert chat_dict["has_hidden_members"] == chat.has_hidden_members - - def test_always_tuples_attributes(self): - chat = Chat( - id=123, - title="title", - type=Chat.PRIVATE, - ) - assert isinstance(chat.active_usernames, tuple) - assert chat.active_usernames == () + assert chat_dict["first_name"] == chat.first_name + assert chat_dict["last_name"] == chat.last_name + assert chat_dict["is_direct_messages"] == chat.is_direct_messages def test_enum_init(self): chat = Chat(id=1, type="foo") @@ -229,10 +146,7 @@ def test_full_name(self): assert chat.full_name == "first\u2022name last\u2022name" chat = Chat(id=1, type=Chat.PRIVATE, first_name="first\u2022name") assert chat.full_name == "first\u2022name" - chat = Chat( - id=1, - type=Chat.PRIVATE, - ) + chat = Chat(id=1, type=Chat.PRIVATE) assert chat.full_name is None def test_effective_name(self): @@ -245,6 +159,28 @@ def test_effective_name(self): chat = Chat(id=1, type=Chat.GROUP) assert chat.effective_name is None + async def test_delete_message(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == chat.id and kwargs["message_id"] == 42 + + assert check_shortcut_signature(Chat.delete_message, Bot.delete_message, ["chat_id"], []) + assert await check_shortcut_call(chat.delete_message, chat.get_bot(), "delete_message") + assert await check_defaults_handling(chat.delete_message, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "delete_message", make_assertion) + assert await chat.delete_message(message_id=42) + + async def test_delete_messages(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == chat.id and kwargs["message_ids"] == (42, 43) + + assert check_shortcut_signature(Chat.delete_messages, Bot.delete_messages, ["chat_id"], []) + assert await check_shortcut_call(chat.delete_messages, chat.get_bot(), "delete_messages") + assert await check_defaults_handling(chat.delete_messages, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "delete_messages", make_assertion) + assert await chat.delete_messages(message_ids=(42, 43)) + async def test_send_action(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == chat.id @@ -363,7 +299,7 @@ async def test_unban_member(self, monkeypatch, chat, only_if_banned): async def make_assertion(*_, **kwargs): chat_id = kwargs["chat_id"] == chat.id user_id = kwargs["user_id"] == 42 - o_i_b = kwargs.get("only_if_banned", None) == only_if_banned + o_i_b = kwargs.get("only_if_banned") == only_if_banned return chat_id and user_id and o_i_b assert check_shortcut_signature(Chat.unban_member, Bot.unban_chat_member, ["chat_id"], []) @@ -410,7 +346,7 @@ async def test_promote_member(self, monkeypatch, chat, is_anonymous): async def make_assertion(*_, **kwargs): chat_id = kwargs["chat_id"] == chat.id user_id = kwargs["user_id"] == 42 - o_i_b = kwargs.get("is_anonymous", None) == is_anonymous + o_i_b = kwargs.get("is_anonymous") == is_anonymous return chat_id and user_id and o_i_b assert check_shortcut_signature( @@ -430,7 +366,7 @@ async def test_restrict_member(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): chat_id = kwargs["chat_id"] == chat.id user_id = kwargs["user_id"] == 42 - o_i_b = kwargs.get("permissions", None) == permissions + o_i_b = kwargs.get("permissions") == permissions return chat_id and user_id and o_i_b assert check_shortcut_signature( @@ -447,7 +383,7 @@ async def make_assertion(*_, **kwargs): async def test_set_permissions(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): chat_id = kwargs["chat_id"] == chat.id - permissions = kwargs["permissions"] == self.permissions + permissions = kwargs["permissions"] == ChatPermissions.all_permissions() return chat_id and permissions assert check_shortcut_signature( @@ -459,7 +395,7 @@ async def make_assertion(*_, **kwargs): assert await check_defaults_handling(chat.set_permissions, chat.get_bot()) monkeypatch.setattr(chat.get_bot(), "set_chat_permissions", make_assertion) - assert await chat.set_permissions(permissions=self.permissions) + assert await chat.set_permissions(permissions=ChatPermissions.all_permissions()) async def test_set_administrator_custom_title(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): @@ -468,6 +404,19 @@ async def make_assertion(*_, **kwargs): custom_title = kwargs["custom_title"] == "custom_title" return chat_id and user_id and custom_title + assert check_shortcut_signature( + Chat.set_administrator_custom_title, + Bot.set_chat_administrator_custom_title, + ["chat_id"], + [], + ) + assert await check_shortcut_call( + chat.set_administrator_custom_title, + chat.get_bot(), + "set_chat_administrator_custom_title", + ) + assert await check_defaults_handling(chat.set_administrator_custom_title, chat.get_bot()) + monkeypatch.setattr("telegram.Bot.set_chat_administrator_custom_title", make_assertion) assert await chat.set_administrator_custom_title(user_id=42, custom_title="custom_title") @@ -575,6 +524,21 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(chat.get_bot(), "send_message", make_assertion) assert await chat.send_message(text="test") + async def test_instance_method_send_message_draft(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == chat.id and kwargs["text"] == "test" + + assert check_shortcut_signature( + Chat.send_message_draft, Bot.send_message_draft, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.send_message_draft, chat.get_bot(), "send_message_draft" + ) + assert await check_defaults_handling(chat.send_message_draft, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "send_message_draft", make_assertion) + assert await chat.send_message_draft(draft_id=1, text="test") + async def test_instance_method_send_media_group(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == chat.id and kwargs["media"] == "test_media_group" @@ -643,6 +607,23 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(chat.get_bot(), "send_dice", make_assertion) assert await chat.send_dice(emoji="test_dice") + async def test_instance_method_send_checklist(self, monkeypatch, chat): + checklist = InputChecklist(title="My Checklist", tasks=[InputChecklistTask(1, "Task 1")]) + + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["business_connection_id"] == "123" + and kwargs["checklist"] == checklist + ) + + assert check_shortcut_signature(Chat.send_checklist, Bot.send_checklist, ["chat_id"], []) + assert await check_shortcut_call(chat.send_checklist, chat.get_bot(), "send_checklist") + assert await check_defaults_handling(chat.send_checklist, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "send_checklist", make_assertion) + assert await chat.send_checklist("123", checklist) + async def test_instance_method_send_game(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == chat.id and kwargs["game_short_name"] == "test_game" @@ -674,9 +655,9 @@ async def make_assertion(*_, **kwargs): "title", "description", "payload", - "provider_token", "currency", "prices", + "provider_token", ) async def test_instance_method_send_location(self, monkeypatch, chat): @@ -775,8 +756,8 @@ async def make_assertion(*_, **kwargs): return from_chat_id and message_id and chat_id assert check_shortcut_signature(Chat.send_copy, Bot.copy_message, ["chat_id"], []) - assert await check_shortcut_call(chat.copy_message, chat.get_bot(), "copy_message") - assert await check_defaults_handling(chat.copy_message, chat.get_bot()) + assert await check_shortcut_call(chat.send_copy, chat.get_bot(), "copy_message") + assert await check_defaults_handling(chat.send_copy, chat.get_bot()) monkeypatch.setattr(chat.get_bot(), "copy_message", make_assertion) assert await chat.send_copy(from_chat_id="test_copy", message_id=42) @@ -795,6 +776,36 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(chat.get_bot(), "copy_message", make_assertion) assert await chat.copy_message(chat_id="test_copy", message_id=42) + async def test_instance_method_send_copies(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + from_chat_id = kwargs["from_chat_id"] == "test_copies" + message_ids = kwargs["message_ids"] == (42, 43) + chat_id = kwargs["chat_id"] == chat.id + return from_chat_id and message_ids and chat_id + + assert check_shortcut_signature(Chat.send_copies, Bot.copy_messages, ["chat_id"], []) + assert await check_shortcut_call(chat.send_copies, chat.get_bot(), "copy_messages") + assert await check_defaults_handling(chat.send_copies, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "copy_messages", make_assertion) + assert await chat.send_copies(from_chat_id="test_copies", message_ids=(42, 43)) + + async def test_instance_method_copy_messages(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + from_chat_id = kwargs["from_chat_id"] == chat.id + message_ids = kwargs["message_ids"] == (42, 43) + chat_id = kwargs["chat_id"] == "test_copies" + return from_chat_id and message_ids and chat_id + + assert check_shortcut_signature( + Chat.copy_messages, Bot.copy_messages, ["from_chat_id"], [] + ) + assert await check_shortcut_call(chat.copy_messages, chat.get_bot(), "copy_messages") + assert await check_defaults_handling(chat.copy_messages, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "copy_messages", make_assertion) + assert await chat.copy_messages(chat_id="test_copies", message_ids=(42, 43)) + async def test_instance_method_forward_from(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): chat_id = kwargs["chat_id"] == chat.id @@ -823,6 +834,42 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(chat.get_bot(), "forward_message", make_assertion) assert await chat.forward_to(chat_id="test_forward", message_id=42) + async def test_instance_method_forward_messages_from(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + chat_id = kwargs["chat_id"] == chat.id + message_ids = kwargs["message_ids"] == (42, 43) + from_chat_id = kwargs["from_chat_id"] == "test_forwards" + return from_chat_id and message_ids and chat_id + + assert check_shortcut_signature( + Chat.forward_messages_from, Bot.forward_messages, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.forward_messages_from, chat.get_bot(), "forward_messages" + ) + assert await check_defaults_handling(chat.forward_messages_from, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "forward_messages", make_assertion) + assert await chat.forward_messages_from(from_chat_id="test_forwards", message_ids=(42, 43)) + + async def test_instance_method_forward_messages_to(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + from_chat_id = kwargs["from_chat_id"] == chat.id + message_ids = kwargs["message_ids"] == (42, 43) + chat_id = kwargs["chat_id"] == "test_forwards" + return from_chat_id and message_ids and chat_id + + assert check_shortcut_signature( + Chat.forward_messages_to, Bot.forward_messages, ["from_chat_id"], [] + ) + assert await check_shortcut_call( + chat.forward_messages_to, chat.get_bot(), "forward_messages" + ) + assert await check_defaults_handling(chat.forward_messages_to, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "forward_messages", make_assertion) + assert await chat.forward_messages_to(chat_id="test_forwards", message_ids=(42, 43)) + async def test_export_invite_link(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == chat.id @@ -887,6 +934,54 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(chat.get_bot(), "revoke_chat_invite_link", make_assertion) assert await chat.revoke_invite_link(invite_link=link) + async def test_create_subscription_invite_link(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["subscription_price"] == 42 + and kwargs["subscription_period"] == 42 + ) + + assert check_shortcut_signature( + Chat.create_subscription_invite_link, + Bot.create_chat_subscription_invite_link, + ["chat_id"], + [], + ) + assert await check_shortcut_call( + chat.create_subscription_invite_link, + chat.get_bot(), + "create_chat_subscription_invite_link", + ) + assert await check_defaults_handling(chat.create_subscription_invite_link, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "create_chat_subscription_invite_link", make_assertion) + assert await chat.create_subscription_invite_link( + subscription_price=42, subscription_period=42 + ) + + async def test_edit_subscription_invite_link(self, monkeypatch, chat): + link = "ThisIsALink" + + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == chat.id and kwargs["invite_link"] == link + + assert check_shortcut_signature( + Chat.edit_subscription_invite_link, + Bot.edit_chat_subscription_invite_link, + ["chat_id"], + [], + ) + assert await check_shortcut_call( + chat.edit_subscription_invite_link, + chat.get_bot(), + "edit_chat_subscription_invite_link", + ) + assert await check_defaults_handling(chat.edit_subscription_invite_link, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "edit_chat_subscription_invite_link", make_assertion) + assert await chat.edit_subscription_invite_link(invite_link=link) + async def test_instance_method_get_menu_button(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == chat.id @@ -1075,6 +1170,31 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(chat.get_bot(), "unpin_all_forum_topic_messages", make_assertion) assert await chat.unpin_all_forum_topic_messages(message_thread_id=42) + async def test_unpin_all_general_forum_topic_messages(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == chat.id + + assert check_shortcut_signature( + Chat.unpin_all_general_forum_topic_messages, + Bot.unpin_all_general_forum_topic_messages, + ["chat_id"], + [], + ) + assert await check_shortcut_call( + chat.unpin_all_general_forum_topic_messages, + chat.get_bot(), + "unpin_all_general_forum_topic_messages", + shortcut_kwargs=["chat_id"], + ) + assert await check_defaults_handling( + chat.unpin_all_general_forum_topic_messages, chat.get_bot() + ) + + monkeypatch.setattr( + chat.get_bot(), "unpin_all_general_forum_topic_messages", make_assertion + ) + assert await chat.unpin_all_general_forum_topic_messages() + async def test_edit_general_forum_topic(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == chat.id and kwargs["name"] == "WhatAName" @@ -1180,6 +1300,254 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(chat.get_bot(), "unhide_general_forum_topic", make_assertion) assert await chat.unhide_general_forum_topic() + async def test_instance_method_get_user_chat_boosts(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + user_id = kwargs["user_id"] == "user_id" + chat_id = kwargs["chat_id"] == chat.id + return chat_id and user_id + + assert check_shortcut_signature( + Chat.get_user_chat_boosts, Bot.get_user_chat_boosts, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.get_user_chat_boosts, chat.get_bot(), "get_user_chat_boosts" + ) + assert await check_defaults_handling(chat.get_user_chat_boosts, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "get_user_chat_boosts", make_assertion) + assert await chat.get_user_chat_boosts(user_id="user_id") + + async def test_instance_method_set_message_reaction(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + message_id = kwargs["message_id"] == 123 + chat_id = kwargs["chat_id"] == chat.id + reaction = kwargs["reaction"] == [ReactionTypeEmoji(ReactionEmoji.THUMBS_DOWN)] + return chat_id and message_id and reaction and kwargs["is_big"] + + assert check_shortcut_signature( + Chat.set_message_reaction, Bot.set_message_reaction, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.set_message_reaction, chat.get_bot(), "set_message_reaction" + ) + assert await check_defaults_handling(chat.set_message_reaction, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "set_message_reaction", make_assertion) + assert await chat.set_message_reaction( + 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" + and kwargs["payload"] == "payload" + ) + + 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", payload="payload" + ) + + async def test_instance_method_send_gift(self, monkeypatch, chat): + async def make_assertion_private(*_, **kwargs): + return ( + kwargs["user_id"] == chat.id + and kwargs["gift_id"] == "gift_id" + and kwargs["text"] == "text" + and kwargs["text_parse_mode"] == "text_parse_mode" + and kwargs["text_entities"] == "text_entities" + ) + + async def make_assertion_channel(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["gift_id"] == "gift_id" + and kwargs["text"] == "text" + and kwargs["text_parse_mode"] == "text_parse_mode" + and kwargs["text_entities"] == "text_entities" + ) + + assert check_shortcut_signature(Chat.send_gift, Bot.send_gift, ["user_id", "chat_id"], []) + assert await check_shortcut_call( + chat.send_gift, chat.get_bot(), "send_gift", ["user_id", "chat_id"] + ) + assert await check_defaults_handling(chat.send_gift, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "send_gift", make_assertion_private) + chat.type = chat.PRIVATE + assert await chat.send_gift( + gift_id="gift_id", + text="text", + text_parse_mode="text_parse_mode", + text_entities="text_entities", + ) + monkeypatch.setattr(chat.get_bot(), "send_gift", make_assertion_channel) + chat.type = chat.CHANNEL + assert await chat.send_gift( + gift_id="gift_id", + text="text", + text_parse_mode="text_parse_mode", + text_entities="text_entities", + ) + + @pytest.mark.parametrize("star_count", [100, None]) + async def test_instance_method_transfer_gift(self, monkeypatch, chat, star_count): + async def make_assertion(*_, **kwargs): + return ( + kwargs["new_owner_chat_id"] == chat.id + and kwargs["owned_gift_id"] == "owned_gift_id" + and kwargs["star_count"] == star_count + ) + + assert check_shortcut_signature( + Chat.transfer_gift, Bot.transfer_gift, ["new_owner_chat_id"], [] + ) + assert await check_shortcut_call(chat.transfer_gift, chat.get_bot(), "transfer_gift") + assert await check_defaults_handling(chat.transfer_gift, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "transfer_gift", make_assertion) + assert await chat.transfer_gift( + owned_gift_id="owned_gift_id", + star_count=star_count, + business_connection_id="business_connection_id", + ) + + async def test_instance_method_verify_chat(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["custom_description"] == "This is a custom description" + ) + + assert check_shortcut_signature(Chat.verify, Bot.verify_chat, ["chat_id"], []) + assert await check_shortcut_call(chat.verify, chat.get_bot(), "verify_chat") + assert await check_defaults_handling(chat.verify, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "verify_chat", make_assertion) + assert await chat.verify( + custom_description="This is a custom description", + ) + + async def test_instance_method_remove_chat_verification(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == chat.id + + assert check_shortcut_signature( + Chat.remove_verification, Bot.remove_chat_verification, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.remove_verification, chat.get_bot(), "remove_chat_verification" + ) + assert await check_defaults_handling(chat.remove_verification, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "remove_chat_verification", make_assertion) + assert await chat.remove_verification() + + async def test_instance_method_read_business_message(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["business_connection_id"] == "business_connection_id" + and kwargs["message_id"] == "message_id" + ) + + assert check_shortcut_signature( + Chat.read_business_message, Bot.read_business_message, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.read_business_message, chat.get_bot(), "read_business_message" + ) + assert await check_defaults_handling(chat.read_business_message, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "read_business_message", make_assertion) + assert await chat.read_business_message( + message_id="message_id", business_connection_id="business_connection_id" + ) + + async def test_instance_method_approve_suggested_post(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["message_id"] == "message_id" + and kwargs["send_date"] == "send_date" + ) + + assert check_shortcut_signature( + Chat.approve_suggested_post, Bot.approve_suggested_post, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.approve_suggested_post, chat.get_bot(), "approve_suggested_post" + ) + assert await check_defaults_handling(chat.approve_suggested_post, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "approve_suggested_post", make_assertion) + assert await chat.approve_suggested_post(message_id="message_id", send_date="send_date") + + async def test_instance_method_decline_suggested_post(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["message_id"] == "message_id" + and kwargs["comment"] == "comment" + ) + + assert check_shortcut_signature( + Chat.decline_suggested_post, Bot.decline_suggested_post, ["chat_id"], [] + ) + assert await check_shortcut_call( + chat.decline_suggested_post, chat.get_bot(), "decline_suggested_post" + ) + assert await check_defaults_handling(chat.decline_suggested_post, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "decline_suggested_post", make_assertion) + assert await chat.decline_suggested_post(message_id="message_id", comment="comment") + + async def test_instance_method_repost_story(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return kwargs["from_chat_id"] == chat.id + + assert check_shortcut_signature( + Chat.repost_story, + Bot.repost_story, + [ + "from_chat_id", + ], + additional_kwargs=[], + ) + assert await check_shortcut_call( + chat.repost_story, + chat.get_bot(), + "repost_story", + shortcut_kwargs=["from_chat_id"], + ) + assert await check_defaults_handling(chat.repost_story, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "repost_story", make_assertion) + assert await chat.repost_story( + business_connection_id="bcid", + from_story_id=123, + active_period=3600, + ) + + async def test_instance_method_get_gifts(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == chat.id + + assert check_shortcut_signature(Chat.get_gifts, Bot.get_chat_gifts, ["chat_id"], []) + assert await check_shortcut_call(chat.get_gifts, chat.get_bot(), "get_chat_gifts") + assert await check_defaults_handling(chat.get_gifts, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "get_chat_gifts", make_assertion) + assert await chat.get_gifts() + 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_chatadministratorrights.py b/tests/test_chatadministratorrights.py index a41cc351b61..f7a9ba09272 100644 --- a/tests/test_chatadministratorrights.py +++ b/tests/test_chatadministratorrights.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -37,6 +37,10 @@ def chat_admin_rights(): can_manage_video_chats=True, can_manage_topics=True, is_anonymous=True, + can_post_stories=True, + can_edit_stories=True, + can_delete_stories=True, + can_manage_direct_messages=True, ) @@ -47,7 +51,7 @@ def test_slot_behaviour(self, chat_admin_rights): 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, chat_admin_rights): + def test_de_json(self, offline_bot, chat_admin_rights): json_dict = { "can_change_info": True, "can_delete_messages": True, @@ -61,8 +65,12 @@ def test_de_json(self, bot, chat_admin_rights): "can_manage_video_chats": True, "can_manage_topics": True, "is_anonymous": True, + "can_post_stories": True, + "can_edit_stories": True, + "can_delete_stories": True, + "can_manage_direct_messages": True, } - chat_administrator_rights_de = ChatAdministratorRights.de_json(json_dict, bot) + chat_administrator_rights_de = ChatAdministratorRights.de_json(json_dict, offline_bot) assert chat_administrator_rights_de.api_kwargs == {} assert chat_admin_rights == chat_administrator_rights_de @@ -84,13 +92,33 @@ def test_to_dict(self, chat_admin_rights): assert admin_rights_dict["is_anonymous"] == car.is_anonymous assert admin_rights_dict["can_manage_video_chats"] == car.can_manage_video_chats assert admin_rights_dict["can_manage_topics"] == car.can_manage_topics + assert admin_rights_dict["can_post_stories"] == car.can_post_stories + assert admin_rights_dict["can_edit_stories"] == car.can_edit_stories + assert admin_rights_dict["can_delete_stories"] == car.can_delete_stories + assert admin_rights_dict["can_manage_direct_messages"] == car.can_manage_direct_messages def test_equality(self): - a = ChatAdministratorRights(True, False, False, False, False, False, False, False, False) - b = ChatAdministratorRights(True, False, False, False, False, False, False, False, False) - c = ChatAdministratorRights(False, False, False, False, False, False, False, False, False) - d = ChatAdministratorRights(True, True, False, False, False, False, False, False, False) - e = ChatAdministratorRights(True, True, False, False, False, False, False, False, False) + a = ChatAdministratorRights( + True, + *((False,) * 11), + ) + b = ChatAdministratorRights( + True, + *((False,) * 11), + ) + c = ChatAdministratorRights( + *(False,) * 12, + ) + d = ChatAdministratorRights( + True, + True, + *((False,) * 10), + ) + e = ChatAdministratorRights( + True, + True, + *((False,) * 10), + ) assert a == b assert hash(a) == hash(b) @@ -106,11 +134,24 @@ def test_equality(self): assert hash(d) == hash(e) def test_all_rights(self): - f = ChatAdministratorRights(True, True, True, True, True, True, True, True, True) + f = ChatAdministratorRights( + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + ) t = ChatAdministratorRights.all_rights() # if the dirs are the same, the attributes will all be there assert dir(f) == dir(t) - # now we just need to check that all attributes are True. _id_attrs returns all values, + # now we just need to check that all attributes are True. __slots__ returns all values, # if a new one is added without defaulting to True, this will fail for key in t.__slots__: assert t[key] is True @@ -118,11 +159,25 @@ def test_all_rights(self): assert f != t def test_no_rights(self): - f = ChatAdministratorRights(False, False, False, False, False, False, False, False, False) + f = ChatAdministratorRights( + False, + False, + False, + False, + False, + False, + False, + False, + False, + False, + False, + False, + False, + ) t = ChatAdministratorRights.no_rights() # if the dirs are the same, the attributes will all be there assert dir(f) == dir(t) - # now we just need to check that all attributes are True. _id_attrs returns all values, + # now we just need to check that all attributes are True. __slots__ returns all values, # if a new one is added without defaulting to True, this will fail for key in t.__slots__: assert t[key] is False diff --git a/tests/test_chatbackground.py b/tests/test_chatbackground.py new file mode 100644 index 00000000000..b5a4ebc7b43 --- /dev/null +++ b/tests/test_chatbackground.py @@ -0,0 +1,603 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 ( + BackgroundFill, + BackgroundFillFreeformGradient, + BackgroundFillGradient, + BackgroundFillSolid, + BackgroundType, + BackgroundTypeChatTheme, + BackgroundTypeFill, + BackgroundTypePattern, + BackgroundTypeWallpaper, + ChatBackground, + Dice, + Document, +) +from telegram.constants import BackgroundFillType, BackgroundTypeType +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def background_fill(): + return BackgroundFill(BackgroundFillTestBase.type) + + +class BackgroundFillTestBase: + type = BackgroundFill.SOLID + color = 42 + top_color = 43 + bottom_color = 44 + rotation_angle = 45 + colors = [46, 47, 48, 49] + + +class TestBackgroundFillWithoutRequest(BackgroundFillTestBase): + def test_slots(self, background_fill): + inst = background_fill + 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_type_enum_conversion(self, background_fill): + assert type(BackgroundFill("solid").type) is BackgroundFillType + assert BackgroundFill("unknown").type == "unknown" + + def test_de_json(self, offline_bot): + data = {"type": "unknown"} + transaction_partner = BackgroundFill.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "unknown" + + @pytest.mark.parametrize( + ("bf_type", "subclass"), + [ + ("solid", BackgroundFillSolid), + ("gradient", BackgroundFillGradient), + ("freeform_gradient", BackgroundFillFreeformGradient), + ], + ) + def test_de_json_subclass(self, offline_bot, bf_type, subclass): + json_dict = { + "type": bf_type, + "color": self.color, + "top_color": self.top_color, + "bottom_color": self.bottom_color, + "rotation_angle": self.rotation_angle, + "colors": self.colors, + } + bf = BackgroundFill.de_json(json_dict, offline_bot) + + assert type(bf) is subclass + assert set(bf.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { + "type" + } + assert bf.type == bf_type + + def test_to_dict(self, background_fill): + assert background_fill.to_dict() == {"type": background_fill.type} + + def test_equality(self, background_fill): + a = background_fill + b = BackgroundFill(self.type) + c = BackgroundFill("unknown") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def background_fill_gradient(): + return BackgroundFillGradient( + TestBackgroundFillGradientWithoutRequest.top_color, + TestBackgroundFillGradientWithoutRequest.bottom_color, + TestBackgroundFillGradientWithoutRequest.rotation_angle, + ) + + +class TestBackgroundFillGradientWithoutRequest(BackgroundFillTestBase): + type = BackgroundFill.GRADIENT + + def test_slots(self, background_fill_gradient): + inst = background_fill_gradient + 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, offline_bot): + data = { + "top_color": self.top_color, + "bottom_color": self.bottom_color, + "rotation_angle": self.rotation_angle, + } + transaction_partner = BackgroundFillGradient.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "gradient" + + def test_to_dict(self, background_fill_gradient): + assert background_fill_gradient.to_dict() == { + "type": background_fill_gradient.type, + "top_color": self.top_color, + "bottom_color": self.bottom_color, + "rotation_angle": self.rotation_angle, + } + + def test_equality(self, background_fill_gradient): + a = background_fill_gradient + b = BackgroundFillGradient( + self.top_color, + self.bottom_color, + self.rotation_angle, + ) + c = BackgroundFillGradient( + self.top_color + 1, + self.bottom_color + 1, + self.rotation_angle + 1, + ) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def background_fill_freeform_gradient(): + return BackgroundFillFreeformGradient( + TestBackgroundFillFreeformGradientWithoutRequest.colors, + ) + + +class TestBackgroundFillFreeformGradientWithoutRequest(BackgroundFillTestBase): + type = BackgroundFill.FREEFORM_GRADIENT + + def test_slots(self, background_fill_freeform_gradient): + inst = background_fill_freeform_gradient + 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, offline_bot): + data = {"colors": self.colors} + transaction_partner = BackgroundFillFreeformGradient.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "freeform_gradient" + + def test_to_dict(self, background_fill_freeform_gradient): + assert background_fill_freeform_gradient.to_dict() == { + "type": background_fill_freeform_gradient.type, + "colors": self.colors, + } + + def test_equality(self, background_fill_freeform_gradient): + a = background_fill_freeform_gradient + b = BackgroundFillFreeformGradient(self.colors) + c = BackgroundFillFreeformGradient([color + 1 for color in self.colors]) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def background_fill_solid(): + return BackgroundFillSolid(TestBackgroundFillSolidWithoutRequest.color) + + +class TestBackgroundFillSolidWithoutRequest(BackgroundFillTestBase): + type = BackgroundFill.SOLID + + def test_slots(self, background_fill_solid): + inst = background_fill_solid + 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, offline_bot): + data = {"color": self.color} + transaction_partner = BackgroundFillSolid.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "solid" + + def test_to_dict(self, background_fill_solid): + assert background_fill_solid.to_dict() == { + "type": background_fill_solid.type, + "color": self.color, + } + + def test_equality(self, background_fill_solid): + a = background_fill_solid + b = BackgroundFillSolid(self.color) + c = BackgroundFillSolid(self.color + 1) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def background_type(): + return BackgroundType(BackgroundTypeTestBase.type) + + +class BackgroundTypeTestBase: + type = BackgroundType.WALLPAPER + fill = BackgroundFillSolid(42) + dark_theme_dimming = 43 + document = Document("file_id", "file_unique_id", "file_name", 42) + is_blurred = True + is_moving = True + intensity = 45 + is_inverted = True + theme_name = "test theme name" + + +class TestBackgroundTypeWithoutRequest(BackgroundTypeTestBase): + def test_slots(self, background_type): + inst = background_type + 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_type_enum_conversion(self, background_type): + assert type(BackgroundType("wallpaper").type) is BackgroundTypeType + assert BackgroundType("unknown").type == "unknown" + + def test_de_json(self, offline_bot): + data = {"type": "unknown"} + transaction_partner = BackgroundType.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "unknown" + + @pytest.mark.parametrize( + ("bt_type", "subclass"), + [ + ("wallpaper", BackgroundTypeWallpaper), + ("fill", BackgroundTypeFill), + ("pattern", BackgroundTypePattern), + ("chat_theme", BackgroundTypeChatTheme), + ], + ) + def test_de_json_subclass(self, offline_bot, bt_type, subclass): + json_dict = { + "type": bt_type, + "fill": self.fill.to_dict(), + "dark_theme_dimming": self.dark_theme_dimming, + "document": self.document.to_dict(), + "is_blurred": self.is_blurred, + "is_moving": self.is_moving, + "intensity": self.intensity, + "is_inverted": self.is_inverted, + "theme_name": self.theme_name, + } + bt = BackgroundType.de_json(json_dict, offline_bot) + + assert type(bt) is subclass + assert set(bt.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { + "type" + } + assert bt.type == bt_type + + def test_to_dict(self, background_type): + assert background_type.to_dict() == {"type": background_type.type} + + def test_equality(self, background_type): + a = background_type + b = BackgroundType(self.type) + c = BackgroundType("unknown") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def background_type_fill(): + return BackgroundTypeFill( + fill=TestBackgroundTypeFillWithoutRequest.fill, + dark_theme_dimming=TestBackgroundTypeFillWithoutRequest.dark_theme_dimming, + ) + + +class TestBackgroundTypeFillWithoutRequest(BackgroundTypeTestBase): + type = BackgroundType.FILL + + def test_slots(self, background_type_fill): + inst = background_type_fill + 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, offline_bot): + data = {"fill": self.fill.to_dict(), "dark_theme_dimming": self.dark_theme_dimming} + transaction_partner = BackgroundTypeFill.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "fill" + + def test_to_dict(self, background_type_fill): + assert background_type_fill.to_dict() == { + "type": background_type_fill.type, + "fill": self.fill.to_dict(), + "dark_theme_dimming": self.dark_theme_dimming, + } + + def test_equality(self, background_type_fill): + a = background_type_fill + b = BackgroundTypeFill(self.fill, self.dark_theme_dimming) + c = BackgroundTypeFill(BackgroundFillSolid(43), 44) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def background_type_pattern(): + return BackgroundTypePattern( + TestBackgroundTypePatternWithoutRequest.document, + TestBackgroundTypePatternWithoutRequest.fill, + TestBackgroundTypePatternWithoutRequest.intensity, + TestBackgroundTypePatternWithoutRequest.is_inverted, + TestBackgroundTypePatternWithoutRequest.is_moving, + ) + + +class TestBackgroundTypePatternWithoutRequest(BackgroundTypeTestBase): + type = BackgroundType.PATTERN + + def test_slots(self, background_type_pattern): + inst = background_type_pattern + 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, offline_bot): + data = { + "document": self.document.to_dict(), + "fill": self.fill.to_dict(), + "intensity": self.intensity, + "is_inverted": self.is_inverted, + "is_moving": self.is_moving, + } + transaction_partner = BackgroundTypePattern.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "pattern" + + def test_to_dict(self, background_type_pattern): + assert background_type_pattern.to_dict() == { + "type": background_type_pattern.type, + "document": self.document.to_dict(), + "fill": self.fill.to_dict(), + "intensity": self.intensity, + "is_inverted": self.is_inverted, + "is_moving": self.is_moving, + } + + def test_equality(self, background_type_pattern): + a = background_type_pattern + b = BackgroundTypePattern( + self.document, + self.fill, + self.intensity, + ) + c = BackgroundTypePattern( + Document("other", "other", "file_name", 43), + False, + False, + 44, + ) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def background_type_chat_theme(): + return BackgroundTypeChatTheme( + TestBackgroundTypeChatThemeWithoutRequest.theme_name, + ) + + +class TestBackgroundTypeChatThemeWithoutRequest(BackgroundTypeTestBase): + type = BackgroundType.CHAT_THEME + + def test_slots(self, background_type_chat_theme): + inst = background_type_chat_theme + 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, offline_bot): + data = {"theme_name": self.theme_name} + transaction_partner = BackgroundTypeChatTheme.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "chat_theme" + + def test_to_dict(self, background_type_chat_theme): + assert background_type_chat_theme.to_dict() == { + "type": background_type_chat_theme.type, + "theme_name": self.theme_name, + } + + def test_equality(self, background_type_chat_theme): + a = background_type_chat_theme + b = BackgroundTypeChatTheme(self.theme_name) + c = BackgroundTypeChatTheme("other") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def background_type_wallpaper(): + return BackgroundTypeWallpaper( + TestBackgroundTypeWallpaperWithoutRequest.document, + TestBackgroundTypeWallpaperWithoutRequest.dark_theme_dimming, + TestBackgroundTypeWallpaperWithoutRequest.is_blurred, + TestBackgroundTypeWallpaperWithoutRequest.is_moving, + ) + + +class TestBackgroundTypeWallpaperWithoutRequest(BackgroundTypeTestBase): + type = BackgroundType.WALLPAPER + + def test_slots(self, background_type_wallpaper): + inst = background_type_wallpaper + 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, offline_bot): + data = { + "document": self.document.to_dict(), + "dark_theme_dimming": self.dark_theme_dimming, + "is_blurred": self.is_blurred, + "is_moving": self.is_moving, + } + transaction_partner = BackgroundTypeWallpaper.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "wallpaper" + + def test_to_dict(self, background_type_wallpaper): + assert background_type_wallpaper.to_dict() == { + "type": background_type_wallpaper.type, + "document": self.document.to_dict(), + "dark_theme_dimming": self.dark_theme_dimming, + "is_blurred": self.is_blurred, + "is_moving": self.is_moving, + } + + def test_equality(self, background_type_wallpaper): + a = background_type_wallpaper + b = BackgroundTypeWallpaper( + self.document, + self.dark_theme_dimming, + self.is_blurred, + self.is_moving, + ) + c = BackgroundTypeWallpaper( + Document("other", "other", "file_name", 43), + 44, + False, + False, + ) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def chat_background(): + return ChatBackground(ChatBackgroundTestBase.type) + + +class ChatBackgroundTestBase: + type = BackgroundTypeFill(BackgroundFillSolid(42), 43) + + +class TestChatBackgroundWithoutRequest(ChatBackgroundTestBase): + def test_slots(self, chat_background): + inst = chat_background + 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, offline_bot): + data = {"type": self.type.to_dict()} + transaction_partner = ChatBackground.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == self.type + + def test_to_dict(self, chat_background): + assert chat_background.to_dict() == {"type": chat_background.type.to_dict()} + + def test_equality(self, chat_background): + a = chat_background + b = ChatBackground(self.type) + c = ChatBackground(BackgroundTypeFill(BackgroundFillSolid(43), 44)) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_chatboost.py b/tests/test_chatboost.py new file mode 100644 index 00000000000..578bc4f0824 --- /dev/null +++ b/tests/test_chatboost.py @@ -0,0 +1,604 @@ +# python-telegram-bot - a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# by the python-telegram-bot contributors +# +# 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 as dtm + +import pytest + +from telegram import ( + Chat, + ChatBoost, + ChatBoostAdded, + ChatBoostRemoved, + ChatBoostSource, + ChatBoostSourceGiftCode, + ChatBoostSourceGiveaway, + ChatBoostSourcePremium, + ChatBoostUpdated, + Dice, + User, + UserChatBoosts, +) +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import ChatBoostSources +from telegram.request import RequestData +from tests.auxil.dummy_objects import get_dummy_object_json_dict +from tests.auxil.slots import mro_slots + + +class ChatBoostDefaults: + source = ChatBoostSource.PREMIUM + chat_id = 1 + boost_id = "2" + giveaway_message_id = 3 + is_unclaimed = False + chat = Chat(1, "group") + user = User(1, "user", False) + date = dtm.datetime.now(dtm.timezone.utc).replace(microsecond=0) + default_source = ChatBoostSourcePremium(user) + prize_star_count = 99 + boost = ChatBoost( + boost_id=boost_id, + add_date=date, + expiration_date=date, + source=default_source, + ) + + +@pytest.fixture(scope="module") +def chat_boost_source(): + return ChatBoostSource( + source=ChatBoostDefaults.source, + ) + + +class TestChatBoostSourceWithoutRequest(ChatBoostDefaults): + def test_slot_behaviour(self, chat_boost_source): + inst = chat_boost_source + 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_type_enum_conversion(self, chat_boost_source): + assert type(ChatBoostSource("premium").source) is ChatBoostSources + assert ChatBoostSource("unknown").source == "unknown" + + def test_de_json(self, offline_bot): + json_dict = { + "source": "unknown", + } + cbs = ChatBoostSource.de_json(json_dict, offline_bot) + + assert cbs.api_kwargs == {} + assert cbs.source == "unknown" + + @pytest.mark.parametrize( + ("cb_source", "subclass"), + [ + ("premium", ChatBoostSourcePremium), + ("gift_code", ChatBoostSourceGiftCode), + ("giveaway", ChatBoostSourceGiveaway), + ], + ) + def test_de_json_subclass(self, offline_bot, cb_source, subclass): + json_dict = { + "source": cb_source, + "user": ChatBoostDefaults.user.to_dict(), + "giveaway_message_id": ChatBoostDefaults.giveaway_message_id, + } + cbs = ChatBoostSource.de_json(json_dict, offline_bot) + + assert type(cbs) is subclass + assert set(cbs.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { + "source" + } + assert cbs.source == cb_source + + def test_to_dict(self, chat_boost_source): + chat_boost_source_dict = chat_boost_source.to_dict() + + assert isinstance(chat_boost_source_dict, dict) + assert chat_boost_source_dict["source"] == chat_boost_source.source + + def test_equality(self, chat_boost_source): + a = chat_boost_source + b = ChatBoostSource(source=ChatBoostDefaults.source) + c = ChatBoostSource(source="unknown") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture(scope="module") +def chat_boost_source_premium(): + return ChatBoostSourcePremium( + user=TestChatBoostSourcePremiumWithoutRequest.user, + ) + + +class TestChatBoostSourcePremiumWithoutRequest(ChatBoostDefaults): + source = ChatBoostSources.PREMIUM + + def test_slot_behaviour(self, chat_boost_source_premium): + inst = chat_boost_source_premium + 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, offline_bot): + json_dict = { + "user": self.user.to_dict(), + } + cbsp = ChatBoostSourcePremium.de_json(json_dict, offline_bot) + + assert cbsp.api_kwargs == {} + assert cbsp.user == self.user + + def test_to_dict(self, chat_boost_source_premium): + chat_boost_source_premium_dict = chat_boost_source_premium.to_dict() + + assert isinstance(chat_boost_source_premium_dict, dict) + assert chat_boost_source_premium_dict["source"] == self.source + assert chat_boost_source_premium_dict["user"] == self.user.to_dict() + + def test_equality(self, chat_boost_source_premium): + a = chat_boost_source_premium + b = ChatBoostSourcePremium(user=self.user) + c = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + +@pytest.fixture(scope="module") +def chat_boost_source_gift_code(): + return ChatBoostSourceGiftCode( + user=TestChatBoostSourceGiftCodeWithoutRequest.user, + ) + + +class TestChatBoostSourceGiftCodeWithoutRequest(ChatBoostDefaults): + source = ChatBoostSources.GIFT_CODE + + def test_slot_behaviour(self, chat_boost_source_gift_code): + inst = chat_boost_source_gift_code + 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, offline_bot): + json_dict = { + "user": self.user.to_dict(), + } + cbsgc = ChatBoostSourceGiftCode.de_json(json_dict, offline_bot) + + assert cbsgc.api_kwargs == {} + assert cbsgc.user == self.user + + def test_to_dict(self, chat_boost_source_gift_code): + chat_boost_source_gift_code_dict = chat_boost_source_gift_code.to_dict() + + assert isinstance(chat_boost_source_gift_code_dict, dict) + assert chat_boost_source_gift_code_dict["source"] == self.source + assert chat_boost_source_gift_code_dict["user"] == self.user.to_dict() + + def test_equality(self, chat_boost_source_gift_code): + a = chat_boost_source_gift_code + b = ChatBoostSourceGiftCode(user=self.user) + c = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + +@pytest.fixture(scope="module") +def chat_boost_source_giveaway(): + return ChatBoostSourceGiveaway( + user=TestChatBoostSourceGiveawayWithoutRequest.user, + giveaway_message_id=TestChatBoostSourceGiveawayWithoutRequest.giveaway_message_id, + ) + + +class TestChatBoostSourceGiveawayWithoutRequest(ChatBoostDefaults): + source = ChatBoostSources.GIVEAWAY + + def test_slot_behaviour(self, chat_boost_source_giveaway): + inst = chat_boost_source_giveaway + 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, offline_bot): + json_dict = { + "user": self.user.to_dict(), + "giveaway_message_id": self.giveaway_message_id, + } + cbsg = ChatBoostSourceGiveaway.de_json(json_dict, offline_bot) + + assert cbsg.api_kwargs == {} + assert cbsg.user == self.user + assert cbsg.giveaway_message_id == self.giveaway_message_id + + def test_to_dict(self, chat_boost_source_giveaway): + chat_boost_source_giveaway_dict = chat_boost_source_giveaway.to_dict() + + assert isinstance(chat_boost_source_giveaway_dict, dict) + assert chat_boost_source_giveaway_dict["source"] == self.source + assert chat_boost_source_giveaway_dict["user"] == self.user.to_dict() + assert chat_boost_source_giveaway_dict["giveaway_message_id"] == self.giveaway_message_id + + def test_equality(self, chat_boost_source_giveaway): + a = chat_boost_source_giveaway + b = ChatBoostSourceGiveaway(user=self.user, giveaway_message_id=self.giveaway_message_id) + c = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + +@pytest.fixture(scope="module") +def chat_boost(): + return ChatBoost( + boost_id=ChatBoostDefaults.boost_id, + add_date=ChatBoostDefaults.date, + expiration_date=ChatBoostDefaults.date, + source=ChatBoostDefaults.default_source, + ) + + +class TestChatBoostWithoutRequest(ChatBoostDefaults): + def test_slot_behaviour(self, chat_boost): + inst = chat_boost + 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, offline_bot, chat_boost): + json_dict = { + "boost_id": self.boost_id, + "add_date": to_timestamp(self.date), + "expiration_date": to_timestamp(self.date), + "source": self.default_source.to_dict(), + } + cb = ChatBoost.de_json(json_dict, offline_bot) + + assert cb.api_kwargs == {} + assert cb.boost_id == self.boost_id + assert (cb.add_date) == self.date + assert (cb.expiration_date) == self.date + assert cb.source == self.default_source + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): + json_dict = { + "boost_id": "2", + "add_date": to_timestamp(self.date), + "expiration_date": to_timestamp(self.date), + "source": self.default_source.to_dict(), + } + + cb_bot = ChatBoost.de_json(json_dict, offline_bot) + cb_raw = ChatBoost.de_json(json_dict, raw_bot) + cb_tz = ChatBoost.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + message_offset = cb_tz.add_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(cb_tz.add_date.replace(tzinfo=None)) + + assert cb_raw.add_date.tzinfo == UTC + assert cb_bot.add_date.tzinfo == UTC + assert message_offset == tz_bot_offset + + def test_to_dict(self, chat_boost): + chat_boost_dict = chat_boost.to_dict() + + assert isinstance(chat_boost_dict, dict) + assert chat_boost_dict["boost_id"] == chat_boost.boost_id + assert chat_boost_dict["add_date"] == to_timestamp(chat_boost.add_date) + assert chat_boost_dict["expiration_date"] == to_timestamp(chat_boost.expiration_date) + assert chat_boost_dict["source"] == chat_boost.source.to_dict() + + def test_equality(self): + a = ChatBoost( + boost_id="2", + add_date=self.date, + expiration_date=self.date, + source=self.default_source, + ) + b = ChatBoost( + boost_id="2", + add_date=self.date, + expiration_date=self.date, + source=self.default_source, + ) + c = ChatBoost( + boost_id="3", + add_date=self.date, + expiration_date=self.date, + source=self.default_source, + ) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + +@pytest.fixture(scope="module") +def chat_boost_updated(chat_boost): + return ChatBoostUpdated( + chat=ChatBoostDefaults.chat, + boost=chat_boost, + ) + + +class TestChatBoostUpdatedWithoutRequest(ChatBoostDefaults): + def test_slot_behaviour(self, chat_boost_updated): + inst = chat_boost_updated + 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, offline_bot, chat_boost): + json_dict = { + "chat": self.chat.to_dict(), + "boost": self.boost.to_dict(), + } + cbu = ChatBoostUpdated.de_json(json_dict, offline_bot) + + assert cbu.api_kwargs == {} + assert cbu.chat == self.chat + assert cbu.boost == self.boost + + def test_to_dict(self, chat_boost_updated): + chat_boost_updated_dict = chat_boost_updated.to_dict() + + assert isinstance(chat_boost_updated_dict, dict) + assert chat_boost_updated_dict["chat"] == chat_boost_updated.chat.to_dict() + assert chat_boost_updated_dict["boost"] == chat_boost_updated.boost.to_dict() + + def test_equality(self): + a = ChatBoostUpdated( + chat=Chat(1, "group"), + boost=ChatBoost( + boost_id="2", + add_date=self.date, + expiration_date=self.date, + source=self.default_source, + ), + ) + b = ChatBoostUpdated( + chat=Chat(1, "group"), + boost=ChatBoost( + boost_id="2", + add_date=self.date, + expiration_date=self.date, + source=self.default_source, + ), + ) + c = ChatBoostUpdated( + chat=Chat(2, "group"), + boost=ChatBoost( + boost_id="3", + add_date=self.date, + expiration_date=self.date, + source=self.default_source, + ), + ) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + +@pytest.fixture(scope="module") +def chat_boost_removed(): + return ChatBoostRemoved( + chat=ChatBoostDefaults.chat, + boost_id=ChatBoostDefaults.boost_id, + remove_date=ChatBoostDefaults.date, + source=ChatBoostDefaults.default_source, + ) + + +class TestChatBoostRemovedWithoutRequest(ChatBoostDefaults): + def test_slot_behaviour(self, chat_boost_removed): + inst = chat_boost_removed + 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, offline_bot, chat_boost_removed): + json_dict = { + "chat": self.chat.to_dict(), + "boost_id": self.boost_id, + "remove_date": to_timestamp(self.date), + "source": self.default_source.to_dict(), + } + cbr = ChatBoostRemoved.de_json(json_dict, offline_bot) + + assert cbr.api_kwargs == {} + assert cbr.chat == self.chat + assert cbr.boost_id == self.boost_id + assert cbr.remove_date == self.date + assert cbr.source == self.default_source + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): + json_dict = { + "chat": self.chat.to_dict(), + "boost_id": self.boost_id, + "remove_date": to_timestamp(self.date), + "source": self.default_source.to_dict(), + } + + cbr_bot = ChatBoostRemoved.de_json(json_dict, offline_bot) + cbr_raw = ChatBoostRemoved.de_json(json_dict, raw_bot) + cbr_tz = ChatBoostRemoved.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + message_offset = cbr_tz.remove_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(cbr_tz.remove_date.replace(tzinfo=None)) + + assert cbr_raw.remove_date.tzinfo == UTC + assert cbr_bot.remove_date.tzinfo == UTC + assert message_offset == tz_bot_offset + + def test_to_dict(self, chat_boost_removed): + chat_boost_removed_dict = chat_boost_removed.to_dict() + + assert isinstance(chat_boost_removed_dict, dict) + assert chat_boost_removed_dict["chat"] == chat_boost_removed.chat.to_dict() + assert chat_boost_removed_dict["boost_id"] == chat_boost_removed.boost_id + assert chat_boost_removed_dict["remove_date"] == to_timestamp( + chat_boost_removed.remove_date + ) + assert chat_boost_removed_dict["source"] == chat_boost_removed.source.to_dict() + + def test_equality(self): + a = ChatBoostRemoved( + chat=Chat(1, "group"), + boost_id="2", + remove_date=self.date, + source=self.default_source, + ) + b = ChatBoostRemoved( + chat=Chat(1, "group"), + boost_id="2", + remove_date=self.date, + source=self.default_source, + ) + c = ChatBoostRemoved( + chat=Chat(2, "group"), + boost_id="3", + remove_date=self.date, + source=self.default_source, + ) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + +@pytest.fixture(scope="module") +def user_chat_boosts(chat_boost): + return UserChatBoosts( + boosts=[chat_boost], + ) + + +class TestUserChatBoostsWithoutRequest(ChatBoostDefaults): + def test_slot_behaviour(self, user_chat_boosts): + inst = user_chat_boosts + 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, offline_bot, user_chat_boosts): + json_dict = { + "boosts": [ + self.boost.to_dict(), + ] + } + ucb = UserChatBoosts.de_json(json_dict, offline_bot) + + assert ucb.api_kwargs == {} + assert ucb.boosts[0] == self.boost + + def test_to_dict(self, user_chat_boosts): + user_chat_boosts_dict = user_chat_boosts.to_dict() + + assert isinstance(user_chat_boosts_dict, dict) + assert isinstance(user_chat_boosts_dict["boosts"], list) + assert user_chat_boosts_dict["boosts"][0] == user_chat_boosts.boosts[0].to_dict() + + async def test_get_user_chat_boosts(self, monkeypatch, offline_bot): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + data = request_data.json_parameters + chat_id = data["chat_id"] == "3" + user_id = data["user_id"] == "2" + if not all((chat_id, user_id)): + pytest.fail("I got wrong parameters in post") + return get_dummy_object_json_dict(UserChatBoosts) + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + assert await offline_bot.get_user_chat_boosts("3", 2) + + +class TestUserChatBoostsWithRequest(ChatBoostDefaults): + async def test_get_user_chat_boosts(self, bot, channel_id, chat_id): + chat_boosts = await bot.get_user_chat_boosts(channel_id, chat_id) + assert isinstance(chat_boosts, UserChatBoosts) + + +class TestChatBoostAddedWithoutRequest: + boost_count = 100 + + def test_slot_behaviour(self): + action = ChatBoostAdded(8) + for attr in action.__slots__: + assert getattr(action, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" + + def test_de_json(self): + json_dict = {"boost_count": self.boost_count} + chat_boost_added = ChatBoostAdded.de_json(json_dict, None) + assert chat_boost_added.api_kwargs == {} + + assert chat_boost_added.boost_count == self.boost_count + + def test_to_dict(self): + chat_boost_added = ChatBoostAdded(self.boost_count) + chat_boost_added_dict = chat_boost_added.to_dict() + + assert isinstance(chat_boost_added_dict, dict) + assert chat_boost_added_dict["boost_count"] == self.boost_count + + def test_equality(self): + a = ChatBoostAdded(100) + b = ChatBoostAdded(100) + c = ChatBoostAdded(50) + d = Chat(1, "") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_chatfullinfo.py b/tests/test_chatfullinfo.py new file mode 100644 index 00000000000..e52a71b9999 --- /dev/null +++ b/tests/test_chatfullinfo.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm + +import pytest + +from telegram import ( + Birthdate, + BusinessIntro, + BusinessLocation, + BusinessOpeningHours, + BusinessOpeningHoursInterval, + Chat, + ChatFullInfo, + ChatLocation, + ChatPermissions, + Location, + ReactionTypeCustomEmoji, + ReactionTypeEmoji, + UniqueGiftColors, + UserRating, +) +from telegram._gifts import AcceptedGiftTypes +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import ReactionEmoji +from telegram.warnings import PTBDeprecationWarning +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def chat_full_info(bot): + chat = ChatFullInfo( + ChatFullInfoTestBase.id_, + type=ChatFullInfoTestBase.type_, + accent_color_id=ChatFullInfoTestBase.accent_color_id, + max_reaction_count=ChatFullInfoTestBase.max_reaction_count, + accepted_gift_types=ChatFullInfoTestBase.accepted_gift_types, + title=ChatFullInfoTestBase.title, + username=ChatFullInfoTestBase.username, + sticker_set_name=ChatFullInfoTestBase.sticker_set_name, + can_set_sticker_set=ChatFullInfoTestBase.can_set_sticker_set, + permissions=ChatFullInfoTestBase.permissions, + slow_mode_delay=ChatFullInfoTestBase.slow_mode_delay, + message_auto_delete_time=ChatFullInfoTestBase.message_auto_delete_time, + bio=ChatFullInfoTestBase.bio, + linked_chat_id=ChatFullInfoTestBase.linked_chat_id, + location=ChatFullInfoTestBase.location, + has_private_forwards=ChatFullInfoTestBase.has_private_forwards, + has_protected_content=ChatFullInfoTestBase.has_protected_content, + has_visible_history=ChatFullInfoTestBase.has_visible_history, + join_to_send_messages=ChatFullInfoTestBase.join_to_send_messages, + join_by_request=ChatFullInfoTestBase.join_by_request, + has_restricted_voice_and_video_messages=( + ChatFullInfoTestBase.has_restricted_voice_and_video_messages + ), + is_forum=ChatFullInfoTestBase.is_forum, + active_usernames=ChatFullInfoTestBase.active_usernames, + emoji_status_custom_emoji_id=ChatFullInfoTestBase.emoji_status_custom_emoji_id, + emoji_status_expiration_date=ChatFullInfoTestBase.emoji_status_expiration_date, + has_aggressive_anti_spam_enabled=ChatFullInfoTestBase.has_aggressive_anti_spam_enabled, + has_hidden_members=ChatFullInfoTestBase.has_hidden_members, + available_reactions=ChatFullInfoTestBase.available_reactions, + background_custom_emoji_id=ChatFullInfoTestBase.background_custom_emoji_id, + profile_accent_color_id=ChatFullInfoTestBase.profile_accent_color_id, + profile_background_custom_emoji_id=ChatFullInfoTestBase.profile_background_custom_emoji_id, + unrestrict_boost_count=ChatFullInfoTestBase.unrestrict_boost_count, + custom_emoji_sticker_set_name=ChatFullInfoTestBase.custom_emoji_sticker_set_name, + business_intro=ChatFullInfoTestBase.business_intro, + business_location=ChatFullInfoTestBase.business_location, + business_opening_hours=ChatFullInfoTestBase.business_opening_hours, + birthdate=ChatFullInfoTestBase.birthdate, + personal_chat=ChatFullInfoTestBase.personal_chat, + first_name=ChatFullInfoTestBase.first_name, + last_name=ChatFullInfoTestBase.last_name, + can_send_paid_media=ChatFullInfoTestBase.can_send_paid_media, + is_direct_messages=ChatFullInfoTestBase.is_direct_messages, + parent_chat=ChatFullInfoTestBase.parent_chat, + rating=ChatFullInfoTestBase.rating, + unique_gift_colors=ChatFullInfoTestBase.unique_gift_colors, + paid_message_star_count=ChatFullInfoTestBase.paid_message_star_count, + ) + chat.set_bot(bot) + chat._unfreeze() + return chat + + +# Shortcut methods are tested in test_chat.py. +class ChatFullInfoTestBase: + id_ = -28767330 + max_reaction_count = 2 + title = "ToledosPalaceBot - Group" + type_ = "group" + username = "username" + sticker_set_name = "stickers" + can_set_sticker_set = False + permissions = ChatPermissions( + can_send_messages=True, + can_change_info=False, + can_invite_users=True, + ) + slow_mode_delay = dtm.timedelta(seconds=30) + message_auto_delete_time = dtm.timedelta(60) + bio = "I'm a Barbie Girl in a Barbie World" + linked_chat_id = 11880 + location = ChatLocation(Location(123, 456), "Barbie World") + has_protected_content = True + has_visible_history = True + has_private_forwards = True + join_to_send_messages = True + join_by_request = True + has_restricted_voice_and_video_messages = True + is_forum = True + active_usernames = ["These", "Are", "Usernames!"] + emoji_status_custom_emoji_id = "VeryUniqueCustomEmojiID" + emoji_status_expiration_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + has_aggressive_anti_spam_enabled = True + has_hidden_members = True + available_reactions = [ + ReactionTypeEmoji(ReactionEmoji.THUMBS_DOWN), + ReactionTypeCustomEmoji("custom_emoji_id"), + ] + business_intro = BusinessIntro("Title", "Description", None) + business_location = BusinessLocation("Address", Location(123, 456)) + business_opening_hours = BusinessOpeningHours( + "Country/City", + [BusinessOpeningHoursInterval(opening, opening + 60) for opening in (0, 24 * 60)], + ) + accent_color_id = 1 + background_custom_emoji_id = "background_custom_emoji_id" + profile_accent_color_id = 2 + profile_background_custom_emoji_id = "profile_background_custom_emoji_id" + unrestrict_boost_count = 100 + custom_emoji_sticker_set_name = "custom_emoji_sticker_set_name" + birthdate = Birthdate(1, 1) + personal_chat = Chat(3, "private", "private") + first_name = "first_name" + last_name = "last_name" + can_send_paid_media = True + accepted_gift_types = AcceptedGiftTypes(True, True, True, True, True) + is_direct_messages = True + parent_chat = Chat(4, "channel", "channel") + rating = UserRating(level=1, rating=2, current_level_rating=3, next_level_rating=4) + unique_gift_colors = UniqueGiftColors( + model_custom_emoji_id="model_custom_emoji_id", + symbol_custom_emoji_id="symbol_custom_emoji_id", + light_theme_main_color=0xFF5733, + light_theme_other_colors=[0x33FF57, 0x3357FF], + dark_theme_main_color=0xC70039, + dark_theme_other_colors=[0x900C3F, 0x581845], + ) + paid_message_star_count = 1234 + + +class TestChatFullInfoWithoutRequest(ChatFullInfoTestBase): + def test_slot_behaviour(self, chat_full_info): + cfi = chat_full_info + for attr in cfi.__slots__: + assert getattr(cfi, attr, "err") != "err", f"got extra slot '{attr}'" + + assert len(mro_slots(cfi)) == len(set(mro_slots(cfi))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "id": self.id_, + "title": self.title, + "type": self.type_, + "accent_color_id": self.accent_color_id, + "max_reaction_count": self.max_reaction_count, + "username": self.username, + "accepted_gift_types": self.accepted_gift_types.to_dict(), + "sticker_set_name": self.sticker_set_name, + "can_set_sticker_set": self.can_set_sticker_set, + "permissions": self.permissions.to_dict(), + "slow_mode_delay": self.slow_mode_delay.total_seconds(), + "message_auto_delete_time": self.message_auto_delete_time.total_seconds(), + "bio": self.bio, + "business_intro": self.business_intro.to_dict(), + "business_location": self.business_location.to_dict(), + "business_opening_hours": self.business_opening_hours.to_dict(), + "has_protected_content": self.has_protected_content, + "has_visible_history": self.has_visible_history, + "has_private_forwards": self.has_private_forwards, + "linked_chat_id": self.linked_chat_id, + "location": self.location.to_dict(), + "join_to_send_messages": self.join_to_send_messages, + "join_by_request": self.join_by_request, + "has_restricted_voice_and_video_messages": ( + self.has_restricted_voice_and_video_messages + ), + "is_forum": self.is_forum, + "active_usernames": self.active_usernames, + "emoji_status_custom_emoji_id": self.emoji_status_custom_emoji_id, + "emoji_status_expiration_date": to_timestamp(self.emoji_status_expiration_date), + "has_aggressive_anti_spam_enabled": self.has_aggressive_anti_spam_enabled, + "has_hidden_members": self.has_hidden_members, + "available_reactions": [reaction.to_dict() for reaction in self.available_reactions], + "background_custom_emoji_id": self.background_custom_emoji_id, + "profile_accent_color_id": self.profile_accent_color_id, + "profile_background_custom_emoji_id": self.profile_background_custom_emoji_id, + "unrestrict_boost_count": self.unrestrict_boost_count, + "custom_emoji_sticker_set_name": self.custom_emoji_sticker_set_name, + "birthdate": self.birthdate.to_dict(), + "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, + "is_direct_messages": self.is_direct_messages, + "parent_chat": self.parent_chat.to_dict(), + "rating": self.rating.to_dict(), + "unique_gift_colors": self.unique_gift_colors.to_dict(), + "paid_message_star_count": self.paid_message_star_count, + } + + cfi = ChatFullInfo.de_json(json_dict, offline_bot) + assert cfi.api_kwargs == {} + assert cfi.id == self.id_ + assert cfi.title == self.title + assert cfi.type == self.type_ + assert cfi.username == self.username + assert cfi.accepted_gift_types == self.accepted_gift_types + assert cfi.sticker_set_name == self.sticker_set_name + assert cfi.can_set_sticker_set == self.can_set_sticker_set + assert cfi.permissions == self.permissions + assert cfi._slow_mode_delay == self.slow_mode_delay + assert cfi._message_auto_delete_time == self.message_auto_delete_time + assert cfi.bio == self.bio + assert cfi.business_intro == self.business_intro + assert cfi.business_location == self.business_location + assert cfi.business_opening_hours == self.business_opening_hours + assert cfi.has_protected_content == self.has_protected_content + assert cfi.has_visible_history == self.has_visible_history + assert cfi.has_private_forwards == self.has_private_forwards + assert cfi.linked_chat_id == self.linked_chat_id + assert cfi.location.location == self.location.location + assert cfi.location.address == self.location.address + assert cfi.join_to_send_messages == self.join_to_send_messages + assert cfi.join_by_request == self.join_by_request + assert ( + cfi.has_restricted_voice_and_video_messages + == self.has_restricted_voice_and_video_messages + ) + assert cfi.is_forum == self.is_forum + assert cfi.active_usernames == tuple(self.active_usernames) + assert cfi.emoji_status_custom_emoji_id == self.emoji_status_custom_emoji_id + assert cfi.emoji_status_expiration_date == (self.emoji_status_expiration_date) + assert cfi.has_aggressive_anti_spam_enabled == self.has_aggressive_anti_spam_enabled + assert cfi.has_hidden_members == self.has_hidden_members + assert cfi.available_reactions == tuple(self.available_reactions) + assert cfi.accent_color_id == self.accent_color_id + assert cfi.background_custom_emoji_id == self.background_custom_emoji_id + assert cfi.profile_accent_color_id == self.profile_accent_color_id + assert cfi.profile_background_custom_emoji_id == self.profile_background_custom_emoji_id + assert cfi.unrestrict_boost_count == self.unrestrict_boost_count + assert cfi.custom_emoji_sticker_set_name == self.custom_emoji_sticker_set_name + assert cfi.birthdate == self.birthdate + assert cfi.personal_chat == self.personal_chat + 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 + assert cfi.is_direct_messages == self.is_direct_messages + assert cfi.parent_chat == self.parent_chat + assert cfi.rating == self.rating + assert cfi.unique_gift_colors == self.unique_gift_colors + assert cfi.paid_message_star_count == self.paid_message_star_count + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): + json_dict = { + "id": self.id_, + "type": self.type_, + "accent_color_id": self.accent_color_id, + "max_reaction_count": self.max_reaction_count, + "accepted_gift_types": self.accepted_gift_types.to_dict(), + "emoji_status_expiration_date": to_timestamp(self.emoji_status_expiration_date), + } + cfi_bot = ChatFullInfo.de_json(json_dict, offline_bot) + cfi_bot_raw = ChatFullInfo.de_json(json_dict, raw_bot) + cfi_bot_tz = ChatFullInfo.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing tzinfo objects is not reliable + emoji_expire_offset = cfi_bot_tz.emoji_status_expiration_date.utcoffset() + emoji_expire_offset_tz = tz_bot.defaults.tzinfo.utcoffset( + cfi_bot_tz.emoji_status_expiration_date.replace(tzinfo=None) + ) + + assert cfi_bot.emoji_status_expiration_date.tzinfo == UTC + assert cfi_bot_raw.emoji_status_expiration_date.tzinfo == UTC + assert emoji_expire_offset_tz == emoji_expire_offset + + def test_to_dict(self, chat_full_info): + cfi = chat_full_info + cfi_dict = cfi.to_dict() + + assert isinstance(cfi_dict, dict) + assert cfi_dict["id"] == cfi.id + assert cfi_dict["title"] == cfi.title + assert cfi_dict["type"] == cfi.type + assert cfi_dict["username"] == cfi.username + assert cfi_dict["permissions"] == cfi.permissions.to_dict() + assert cfi_dict["slow_mode_delay"] == int(self.slow_mode_delay.total_seconds()) + assert cfi_dict["message_auto_delete_time"] == int( + self.message_auto_delete_time.total_seconds() + ) + assert cfi_dict["bio"] == cfi.bio + assert cfi_dict["business_intro"] == cfi.business_intro.to_dict() + assert cfi_dict["business_location"] == cfi.business_location.to_dict() + assert cfi_dict["business_opening_hours"] == cfi.business_opening_hours.to_dict() + assert cfi_dict["has_private_forwards"] == cfi.has_private_forwards + assert cfi_dict["has_protected_content"] == cfi.has_protected_content + assert cfi_dict["has_visible_history"] == cfi.has_visible_history + assert cfi_dict["linked_chat_id"] == cfi.linked_chat_id + assert cfi_dict["location"] == cfi.location.to_dict() + assert cfi_dict["join_to_send_messages"] == cfi.join_to_send_messages + assert cfi_dict["join_by_request"] == cfi.join_by_request + assert ( + cfi_dict["has_restricted_voice_and_video_messages"] + == cfi.has_restricted_voice_and_video_messages + ) + assert cfi_dict["is_forum"] == cfi.is_forum + assert cfi_dict["active_usernames"] == list(cfi.active_usernames) + assert cfi_dict["emoji_status_custom_emoji_id"] == cfi.emoji_status_custom_emoji_id + assert cfi_dict["emoji_status_expiration_date"] == to_timestamp( + cfi.emoji_status_expiration_date + ) + assert cfi_dict["has_aggressive_anti_spam_enabled"] == cfi.has_aggressive_anti_spam_enabled + assert cfi_dict["has_hidden_members"] == cfi.has_hidden_members + assert cfi_dict["available_reactions"] == [ + reaction.to_dict() for reaction in cfi.available_reactions + ] + assert cfi_dict["accent_color_id"] == cfi.accent_color_id + assert cfi_dict["background_custom_emoji_id"] == cfi.background_custom_emoji_id + assert cfi_dict["profile_accent_color_id"] == cfi.profile_accent_color_id + assert ( + cfi_dict["profile_background_custom_emoji_id"] + == cfi.profile_background_custom_emoji_id + ) + assert cfi_dict["custom_emoji_sticker_set_name"] == cfi.custom_emoji_sticker_set_name + assert cfi_dict["unrestrict_boost_count"] == cfi.unrestrict_boost_count + assert cfi_dict["birthdate"] == cfi.birthdate.to_dict() + 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["accepted_gift_types"] == cfi.accepted_gift_types.to_dict() + + assert cfi_dict["max_reaction_count"] == cfi.max_reaction_count + assert cfi_dict["is_direct_messages"] == cfi.is_direct_messages + assert cfi_dict["parent_chat"] == cfi.parent_chat.to_dict() + assert cfi_dict["rating"] == cfi.rating.to_dict() + assert cfi_dict["unique_gift_colors"] == cfi.unique_gift_colors.to_dict() + assert cfi_dict["paid_message_star_count"] == cfi.paid_message_star_count + + def test_time_period_properties(self, PTB_TIMEDELTA, chat_full_info): + cfi = chat_full_info + if PTB_TIMEDELTA: + assert cfi.slow_mode_delay == self.slow_mode_delay + assert isinstance(cfi.slow_mode_delay, dtm.timedelta) + + assert cfi.message_auto_delete_time == self.message_auto_delete_time + assert isinstance(cfi.message_auto_delete_time, dtm.timedelta) + else: + assert cfi.slow_mode_delay == int(self.slow_mode_delay.total_seconds()) + assert isinstance(cfi.slow_mode_delay, int) + + assert cfi.message_auto_delete_time == int( + self.message_auto_delete_time.total_seconds() + ) + assert isinstance(cfi.message_auto_delete_time, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, chat_full_info): + chat_full_info.slow_mode_delay + chat_full_info.message_auto_delete_time + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 2 + for i, attr in enumerate(["slow_mode_delay", "message_auto_delete_time"]): + assert f"`{attr}` will be of type `datetime.timedelta`" in str(recwarn[i].message) + assert recwarn[i].category is PTBDeprecationWarning + + def test_always_tuples_attributes(self): + cfi = ChatFullInfo( + id=123, + type=Chat.PRIVATE, + accent_color_id=1, + max_reaction_count=2, + accepted_gift_types=self.accepted_gift_types, + ) + assert isinstance(cfi.active_usernames, tuple) + assert cfi.active_usernames == () diff --git a/tests/test_chatinvitelink.py b/tests/test_chatinvitelink.py index 0cbe9b41289..36acfbffbab 100644 --- a/tests/test_chatinvitelink.py +++ b/tests/test_chatinvitelink.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,12 +16,13 @@ # # 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 +import datetime as dtm import pytest from telegram import ChatInviteLink, User from telegram._utils.datetime import UTC, to_timestamp +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -33,36 +34,40 @@ def creator(): @pytest.fixture(scope="module") def invite_link(creator): return ChatInviteLink( - TestChatInviteLinkBase.link, + ChatInviteLinkTestBase.link, creator, - TestChatInviteLinkBase.creates_join_request, - TestChatInviteLinkBase.primary, - TestChatInviteLinkBase.revoked, - expire_date=TestChatInviteLinkBase.expire_date, - member_limit=TestChatInviteLinkBase.member_limit, - name=TestChatInviteLinkBase.name, - pending_join_request_count=TestChatInviteLinkBase.pending_join_request_count, + ChatInviteLinkTestBase.creates_join_request, + ChatInviteLinkTestBase.primary, + ChatInviteLinkTestBase.revoked, + expire_date=ChatInviteLinkTestBase.expire_date, + member_limit=ChatInviteLinkTestBase.member_limit, + name=ChatInviteLinkTestBase.name, + pending_join_request_count=ChatInviteLinkTestBase.pending_join_request_count, + subscription_period=ChatInviteLinkTestBase.subscription_period, + subscription_price=ChatInviteLinkTestBase.subscription_price, ) -class TestChatInviteLinkBase: +class ChatInviteLinkTestBase: link = "thisialink" creates_join_request = False primary = True revoked = False - expire_date = datetime.datetime.now(datetime.timezone.utc) + expire_date = dtm.datetime.now(dtm.timezone.utc) member_limit = 42 name = "LinkName" pending_join_request_count = 42 + subscription_period = dtm.timedelta(seconds=43) + subscription_price = 44 -class TestChatInviteLinkWithoutRequest(TestChatInviteLinkBase): +class TestChatInviteLinkWithoutRequest(ChatInviteLinkTestBase): def test_slot_behaviour(self, invite_link): for attr in invite_link.__slots__: assert getattr(invite_link, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(invite_link)) == len(set(mro_slots(invite_link))), "duplicate slot" - def test_de_json_required_args(self, bot, creator): + def test_de_json_required_args(self, offline_bot, creator): json_dict = { "invite_link": self.link, "creator": creator.to_dict(), @@ -71,7 +76,7 @@ def test_de_json_required_args(self, bot, creator): "is_revoked": self.revoked, } - invite_link = ChatInviteLink.de_json(json_dict, bot) + invite_link = ChatInviteLink.de_json(json_dict, offline_bot) assert invite_link.api_kwargs == {} assert invite_link.invite_link == self.link @@ -80,7 +85,7 @@ def test_de_json_required_args(self, bot, creator): assert invite_link.is_primary == self.primary assert invite_link.is_revoked == self.revoked - def test_de_json_all_args(self, bot, creator): + def test_de_json_all_args(self, offline_bot, creator): json_dict = { "invite_link": self.link, "creator": creator.to_dict(), @@ -91,9 +96,11 @@ def test_de_json_all_args(self, bot, creator): "member_limit": self.member_limit, "name": self.name, "pending_join_request_count": str(self.pending_join_request_count), + "subscription_period": int(self.subscription_period.total_seconds()), + "subscription_price": self.subscription_price, } - invite_link = ChatInviteLink.de_json(json_dict, bot) + invite_link = ChatInviteLink.de_json(json_dict, offline_bot) assert invite_link.api_kwargs == {} assert invite_link.invite_link == self.link @@ -101,13 +108,15 @@ def test_de_json_all_args(self, bot, creator): assert invite_link.creates_join_request == self.creates_join_request assert invite_link.is_primary == self.primary assert invite_link.is_revoked == self.revoked - assert abs(invite_link.expire_date - self.expire_date) < datetime.timedelta(seconds=1) + assert abs(invite_link.expire_date - self.expire_date) < dtm.timedelta(seconds=1) assert to_timestamp(invite_link.expire_date) == to_timestamp(self.expire_date) assert invite_link.member_limit == self.member_limit assert invite_link.name == self.name assert invite_link.pending_join_request_count == self.pending_join_request_count + assert invite_link._subscription_period == self.subscription_period + assert invite_link.subscription_price == self.subscription_price - def test_de_json_localization(self, tz_bot, bot, raw_bot, creator): + def test_de_json_localization(self, tz_bot, offline_bot, raw_bot, creator): json_dict = { "invite_link": self.link, "creator": creator.to_dict(), @@ -121,7 +130,7 @@ def test_de_json_localization(self, tz_bot, bot, raw_bot, creator): } invite_link_raw = ChatInviteLink.de_json(json_dict, raw_bot) - invite_link_bot = ChatInviteLink.de_json(json_dict, bot) + invite_link_bot = ChatInviteLink.de_json(json_dict, offline_bot) invite_link_tz = ChatInviteLink.de_json(json_dict, tz_bot) # comparing utcoffsets because comparing timezones is unpredicatable @@ -146,6 +155,31 @@ def test_to_dict(self, invite_link): assert invite_link_dict["member_limit"] == self.member_limit assert invite_link_dict["name"] == self.name assert invite_link_dict["pending_join_request_count"] == self.pending_join_request_count + assert invite_link_dict["subscription_period"] == int( + self.subscription_period.total_seconds() + ) + assert isinstance(invite_link_dict["subscription_period"], int) + assert invite_link_dict["subscription_price"] == self.subscription_price + + def test_time_period_properties(self, PTB_TIMEDELTA, invite_link): + if PTB_TIMEDELTA: + assert invite_link.subscription_period == self.subscription_period + assert isinstance(invite_link.subscription_period, dtm.timedelta) + else: + assert invite_link.subscription_period == int(self.subscription_period.total_seconds()) + assert isinstance(invite_link.subscription_period, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, invite_link): + invite_link.subscription_period + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`subscription_period` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) + assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): a = ChatInviteLink("link", User(1, "", False), True, True, True) diff --git a/tests/test_chatjoinrequest.py b/tests/test_chatjoinrequest.py index 3f5d1e4ef4c..85c94e5852f 100644 --- a/tests/test_chatjoinrequest.py +++ b/tests/test_chatjoinrequest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,7 +16,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/]. -import datetime +import datetime as dtm import pytest @@ -32,24 +32,24 @@ @pytest.fixture(scope="module") def time(): - return datetime.datetime.now(tz=UTC) + return dtm.datetime.now(tz=UTC) @pytest.fixture(scope="module") def chat_join_request(bot, time): cjr = ChatJoinRequest( - chat=TestChatJoinRequestBase.chat, - from_user=TestChatJoinRequestBase.from_user, + chat=ChatJoinRequestTestBase.chat, + from_user=ChatJoinRequestTestBase.from_user, date=time, - bio=TestChatJoinRequestBase.bio, - invite_link=TestChatJoinRequestBase.invite_link, - user_chat_id=TestChatJoinRequestBase.from_user.id, + bio=ChatJoinRequestTestBase.bio, + invite_link=ChatJoinRequestTestBase.invite_link, + user_chat_id=ChatJoinRequestTestBase.from_user.id, ) cjr.set_bot(bot) return cjr -class TestChatJoinRequestBase: +class ChatJoinRequestTestBase: chat = Chat(1, Chat.SUPERGROUP) from_user = User(2, "first_name", False) bio = "bio" @@ -63,42 +63,42 @@ class TestChatJoinRequestBase: ) -class TestChatJoinRequestWithoutRequest(TestChatJoinRequestBase): +class TestChatJoinRequestWithoutRequest(ChatJoinRequestTestBase): def test_slot_behaviour(self, chat_join_request): inst = chat_join_request 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, time): + def test_de_json(self, offline_bot, time): json_dict = { "chat": self.chat.to_dict(), "from": self.from_user.to_dict(), "date": to_timestamp(time), "user_chat_id": self.from_user.id, } - chat_join_request = ChatJoinRequest.de_json(json_dict, bot) + chat_join_request = ChatJoinRequest.de_json(json_dict, offline_bot) assert chat_join_request.api_kwargs == {} assert chat_join_request.chat == self.chat assert chat_join_request.from_user == self.from_user - assert abs(chat_join_request.date - time) < datetime.timedelta(seconds=1) + assert abs(chat_join_request.date - time) < dtm.timedelta(seconds=1) assert to_timestamp(chat_join_request.date) == to_timestamp(time) assert chat_join_request.user_chat_id == self.from_user.id json_dict.update({"bio": self.bio, "invite_link": self.invite_link.to_dict()}) - chat_join_request = ChatJoinRequest.de_json(json_dict, bot) + chat_join_request = ChatJoinRequest.de_json(json_dict, offline_bot) assert chat_join_request.api_kwargs == {} assert chat_join_request.chat == self.chat assert chat_join_request.from_user == self.from_user - assert abs(chat_join_request.date - time) < datetime.timedelta(seconds=1) + assert abs(chat_join_request.date - time) < dtm.timedelta(seconds=1) assert to_timestamp(chat_join_request.date) == to_timestamp(time) assert chat_join_request.user_chat_id == self.from_user.id assert chat_join_request.bio == self.bio assert chat_join_request.invite_link == self.invite_link - def test_de_json_localization(self, tz_bot, bot, raw_bot, time): + def test_de_json_localization(self, tz_bot, offline_bot, raw_bot, time): json_dict = { "chat": self.chat.to_dict(), "from": self.from_user.to_dict(), @@ -107,7 +107,7 @@ def test_de_json_localization(self, tz_bot, bot, raw_bot, time): } chatjoin_req_raw = ChatJoinRequest.de_json(json_dict, raw_bot) - chatjoin_req_bot = ChatJoinRequest.de_json(json_dict, bot) + chatjoin_req_bot = ChatJoinRequest.de_json(json_dict, offline_bot) chatjoin_req_tz = ChatJoinRequest.de_json(json_dict, tz_bot) # comparing utcoffsets because comparing timezones is unpredicatable @@ -133,9 +133,7 @@ def test_equality(self, chat_join_request, time): a = chat_join_request b = ChatJoinRequest(self.chat, self.from_user, time, self.from_user.id) c = ChatJoinRequest(self.chat, self.from_user, time, self.from_user.id, bio="bio") - d = ChatJoinRequest( - self.chat, self.from_user, time + datetime.timedelta(1), self.from_user.id - ) + d = ChatJoinRequest(self.chat, self.from_user, time + dtm.timedelta(1), self.from_user.id) e = ChatJoinRequest(self.chat, User(-1, "last_name", True), time, -1) f = User(456, "", False) diff --git a/tests/test_chatlocation.py b/tests/test_chatlocation.py index a7858c637a4..e0ba62d3cd3 100644 --- a/tests/test_chatlocation.py +++ b/tests/test_chatlocation.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,27 +25,27 @@ @pytest.fixture(scope="module") def chat_location(): - return ChatLocation(TestChatLocationBase.location, TestChatLocationBase.address) + return ChatLocation(ChatLocationTestBase.location, ChatLocationTestBase.address) -class TestChatLocationBase: +class ChatLocationTestBase: location = Location(123, 456) address = "The Shire" -class TestChatLocationWithoutRequest(TestChatLocationBase): +class TestChatLocationWithoutRequest(ChatLocationTestBase): def test_slot_behaviour(self, chat_location): inst = chat_location 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): + def test_de_json(self, offline_bot): json_dict = { "location": self.location.to_dict(), "address": self.address, } - chat_location = ChatLocation.de_json(json_dict, bot) + chat_location = ChatLocation.de_json(json_dict, offline_bot) assert chat_location.api_kwargs == {} assert chat_location.location == self.location diff --git a/tests/test_chatmember.py b/tests/test_chatmember.py index 265ac2a9590..2ef55b067f5 100644 --- a/tests/test_chatmember.py +++ b/tests/test_chatmember.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,7 +16,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/]. -import datetime +import datetime as dtm import inspect from copy import deepcopy @@ -34,238 +34,384 @@ User, ) from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import ChatMemberStatus from tests.auxil.slots import mro_slots -ignored = ["self", "api_kwargs"] - - -class CMDefaults: - user = User(1, "First name", False) - custom_title: str = "PTB" - is_anonymous: bool = True - until_date: datetime.datetime = to_timestamp(datetime.datetime.utcnow()) - can_be_edited: bool = False - can_change_info: bool = True - can_post_messages: bool = True - can_edit_messages: bool = True - can_delete_messages: bool = True - can_invite_users: bool = True - can_restrict_members: bool = True - can_pin_messages: bool = True - can_promote_members: bool = True - can_send_messages: bool = True - can_send_media_messages: bool = True - can_send_polls: bool = True - can_send_other_messages: bool = True - can_add_web_page_previews: bool = True - is_member: bool = True - can_manage_chat: bool = True - can_manage_video_chats: bool = True - can_manage_topics: bool = True - can_send_audios: bool = True - can_send_documents: bool = True - can_send_photos: bool = True - can_send_videos: bool = True - can_send_video_notes: bool = True - can_send_voice_notes: bool = True +@pytest.fixture +def chat_member(): + return ChatMember(ChatMemberTestBase.user, ChatMemberTestBase.status) + + +class ChatMemberTestBase: + status = ChatMemberStatus.MEMBER + user = User(1, "test_user", is_bot=False) + is_anonymous = True + custom_title = "test_title" + can_be_edited = True + can_manage_chat = True + can_delete_messages = True + can_manage_video_chats = True + can_restrict_members = True + can_promote_members = True + can_change_info = True + can_invite_users = True + can_post_messages = True + can_edit_messages = True + can_pin_messages = True + can_post_stories = True + can_edit_stories = True + can_delete_stories = True + can_manage_topics = True + until_date = dtm.datetime.now(UTC).replace(microsecond=0) + can_send_polls = True + can_send_other_messages = True + can_add_web_page_previews = True + can_send_audios = True + can_send_documents = True + can_send_photos = True + can_send_videos = True + can_send_video_notes = True + can_send_voice_notes = True + can_send_messages = True + is_member = True + can_manage_direct_messages = True + + +class TestChatMemberWithoutRequest(ChatMemberTestBase): + def test_slot_behaviour(self, chat_member): + inst = chat_member + 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 chat_member_owner(): - return ChatMemberOwner(CMDefaults.user, CMDefaults.is_anonymous, CMDefaults.custom_title) + def test_status_enum_conversion(self, chat_member): + assert type(ChatMember(ChatMemberTestBase.user, "member").status) is ChatMemberStatus + assert ChatMember(ChatMemberTestBase.user, "unknown").status == "unknown" + + def test_de_json(self, offline_bot): + data = {"status": "unknown", "user": self.user.to_dict()} + chat_member = ChatMember.de_json(data, offline_bot) + assert chat_member.api_kwargs == {} + assert chat_member.status == "unknown" + assert chat_member.user == self.user + + @pytest.mark.parametrize( + ("status", "subclass"), + [ + ("administrator", ChatMemberAdministrator), + ("kicked", ChatMemberBanned), + ("left", ChatMemberLeft), + ("member", ChatMemberMember), + ("creator", ChatMemberOwner), + ("restricted", ChatMemberRestricted), + ], + ) + def test_de_json_subclass(self, offline_bot, status, subclass): + json_dict = { + "status": status, + "user": self.user.to_dict(), + "is_anonymous": self.is_anonymous, + "is_member": self.is_member, + "until_date": to_timestamp(self.until_date), + **{name: value for name, value in inspect.getmembers(self) if name.startswith("can_")}, + } + chat_member = ChatMember.de_json(json_dict, offline_bot) + + assert type(chat_member) is subclass + assert set(chat_member.api_kwargs.keys()) == set(json_dict.keys()) - set( + subclass.__slots__ + ) - {"status", "user"} + assert chat_member.user == self.user + + def test_to_dict(self, chat_member): + assert chat_member.to_dict() == { + "status": chat_member.status, + "user": chat_member.user.to_dict(), + } + + def test_equality(self, chat_member): + a = chat_member + b = ChatMember(self.user, self.status) + c = ChatMember(self.user, "unknown") + d = ChatMember(User(2, "test_bot", is_bot=True), self.status) + e = Dice(5, "test") + + 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) +@pytest.fixture def chat_member_administrator(): return ChatMemberAdministrator( - CMDefaults.user, - CMDefaults.can_be_edited, - CMDefaults.is_anonymous, - CMDefaults.can_manage_chat, - CMDefaults.can_delete_messages, - CMDefaults.can_manage_video_chats, - CMDefaults.can_restrict_members, - CMDefaults.can_promote_members, - CMDefaults.can_change_info, - CMDefaults.can_invite_users, - CMDefaults.can_post_messages, - CMDefaults.can_edit_messages, - CMDefaults.can_pin_messages, - CMDefaults.can_manage_topics, - CMDefaults.custom_title, + TestChatMemberAdministratorWithoutRequest.user, + TestChatMemberAdministratorWithoutRequest.can_be_edited, + TestChatMemberAdministratorWithoutRequest.can_change_info, + TestChatMemberAdministratorWithoutRequest.can_delete_messages, + TestChatMemberAdministratorWithoutRequest.can_delete_stories, + TestChatMemberAdministratorWithoutRequest.can_edit_messages, + TestChatMemberAdministratorWithoutRequest.can_edit_stories, + TestChatMemberAdministratorWithoutRequest.can_invite_users, + TestChatMemberAdministratorWithoutRequest.can_manage_chat, + TestChatMemberAdministratorWithoutRequest.can_manage_topics, + TestChatMemberAdministratorWithoutRequest.can_manage_video_chats, + TestChatMemberAdministratorWithoutRequest.can_pin_messages, + TestChatMemberAdministratorWithoutRequest.can_post_messages, + TestChatMemberAdministratorWithoutRequest.can_post_stories, + TestChatMemberAdministratorWithoutRequest.can_promote_members, + TestChatMemberAdministratorWithoutRequest.can_restrict_members, + TestChatMemberAdministratorWithoutRequest.custom_title, + TestChatMemberAdministratorWithoutRequest.is_anonymous, + TestChatMemberAdministratorWithoutRequest.can_manage_direct_messages, ) -def chat_member_member(): - return ChatMemberMember(CMDefaults.user) +class TestChatMemberAdministratorWithoutRequest(ChatMemberTestBase): + status = ChatMemberStatus.ADMINISTRATOR + def test_slot_behaviour(self, chat_member_administrator): + inst = chat_member_administrator + 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 chat_member_restricted(): - return ChatMemberRestricted( - CMDefaults.user, - CMDefaults.is_member, - CMDefaults.can_change_info, - CMDefaults.can_invite_users, - CMDefaults.can_pin_messages, - CMDefaults.can_send_messages, - CMDefaults.can_send_media_messages, - CMDefaults.can_send_polls, - CMDefaults.can_send_other_messages, - CMDefaults.can_add_web_page_previews, - CMDefaults.can_manage_topics, - CMDefaults.until_date, - CMDefaults.can_send_audios, - CMDefaults.can_send_documents, - CMDefaults.can_send_photos, - CMDefaults.can_send_videos, - CMDefaults.can_send_video_notes, - CMDefaults.can_send_voice_notes, + def test_de_json(self, offline_bot): + data = { + "user": self.user.to_dict(), + "can_be_edited": self.can_be_edited, + "can_change_info": self.can_change_info, + "can_delete_messages": self.can_delete_messages, + "can_delete_stories": self.can_delete_stories, + "can_edit_messages": self.can_edit_messages, + "can_edit_stories": self.can_edit_stories, + "can_invite_users": self.can_invite_users, + "can_manage_chat": self.can_manage_chat, + "can_manage_topics": self.can_manage_topics, + "can_manage_video_chats": self.can_manage_video_chats, + "can_pin_messages": self.can_pin_messages, + "can_post_messages": self.can_post_messages, + "can_post_stories": self.can_post_stories, + "can_promote_members": self.can_promote_members, + "can_restrict_members": self.can_restrict_members, + "custom_title": self.custom_title, + "is_anonymous": self.is_anonymous, + "can_manage_direct_messages": self.can_manage_direct_messages, + } + chat_member = ChatMemberAdministrator.de_json(data, offline_bot) + + assert type(chat_member) is ChatMemberAdministrator + assert chat_member.api_kwargs == {} + + assert chat_member.user == self.user + assert chat_member.can_be_edited == self.can_be_edited + assert chat_member.can_change_info == self.can_change_info + assert chat_member.can_delete_messages == self.can_delete_messages + assert chat_member.can_delete_stories == self.can_delete_stories + assert chat_member.can_edit_messages == self.can_edit_messages + assert chat_member.can_edit_stories == self.can_edit_stories + assert chat_member.can_invite_users == self.can_invite_users + assert chat_member.can_manage_chat == self.can_manage_chat + assert chat_member.can_manage_topics == self.can_manage_topics + assert chat_member.can_manage_video_chats == self.can_manage_video_chats + assert chat_member.can_pin_messages == self.can_pin_messages + assert chat_member.can_post_messages == self.can_post_messages + assert chat_member.can_post_stories == self.can_post_stories + assert chat_member.can_promote_members == self.can_promote_members + assert chat_member.can_restrict_members == self.can_restrict_members + assert chat_member.custom_title == self.custom_title + assert chat_member.is_anonymous == self.is_anonymous + assert chat_member.can_manage_direct_messages == self.can_manage_direct_messages + + def test_to_dict(self, chat_member_administrator): + assert chat_member_administrator.to_dict() == { + "status": chat_member_administrator.status, + "user": chat_member_administrator.user.to_dict(), + "can_be_edited": chat_member_administrator.can_be_edited, + "can_change_info": chat_member_administrator.can_change_info, + "can_delete_messages": chat_member_administrator.can_delete_messages, + "can_delete_stories": chat_member_administrator.can_delete_stories, + "can_edit_messages": chat_member_administrator.can_edit_messages, + "can_edit_stories": chat_member_administrator.can_edit_stories, + "can_invite_users": chat_member_administrator.can_invite_users, + "can_manage_chat": chat_member_administrator.can_manage_chat, + "can_manage_topics": chat_member_administrator.can_manage_topics, + "can_manage_video_chats": chat_member_administrator.can_manage_video_chats, + "can_pin_messages": chat_member_administrator.can_pin_messages, + "can_post_messages": chat_member_administrator.can_post_messages, + "can_post_stories": chat_member_administrator.can_post_stories, + "can_promote_members": chat_member_administrator.can_promote_members, + "can_restrict_members": chat_member_administrator.can_restrict_members, + "custom_title": chat_member_administrator.custom_title, + "is_anonymous": chat_member_administrator.is_anonymous, + "can_manage_direct_messages": chat_member_administrator.can_manage_direct_messages, + } + + def test_equality(self, chat_member_administrator): + a = chat_member_administrator + b = ChatMemberAdministrator( + User(1, "test_user", is_bot=False), + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + True, + ) + c = ChatMemberAdministrator( + User(1, "test_user", is_bot=False), + False, + False, + False, + False, + False, + False, + False, + False, + False, + False, + False, + False, + False, + ) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def chat_member_banned(): + return ChatMemberBanned( + TestChatMemberBannedWithoutRequest.user, + TestChatMemberBannedWithoutRequest.until_date, ) +class TestChatMemberBannedWithoutRequest(ChatMemberTestBase): + status = ChatMemberStatus.BANNED + + def test_slot_behaviour(self, chat_member_banned): + inst = chat_member_banned + 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, offline_bot): + data = { + "user": self.user.to_dict(), + "until_date": to_timestamp(self.until_date), + } + chat_member = ChatMemberBanned.de_json(data, offline_bot) + + assert type(chat_member) is ChatMemberBanned + assert chat_member.api_kwargs == {} + + assert chat_member.user == self.user + assert chat_member.until_date == self.until_date + + def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): + json_dict = { + "user": self.user.to_dict(), + "until_date": to_timestamp(self.until_date), + } + + cmb_raw = ChatMemberBanned.de_json(json_dict, raw_bot) + cmb_bot = ChatMemberBanned.de_json(json_dict, offline_bot) + cmb_bot_tz = ChatMemberBanned.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + cmb_bot_tz_offset = cmb_bot_tz.until_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + cmb_bot_tz.until_date.replace(tzinfo=None) + ) + + assert cmb_raw.until_date.tzinfo == UTC + assert cmb_bot.until_date.tzinfo == UTC + assert cmb_bot_tz_offset == tz_bot_offset + + def test_to_dict(self, chat_member_banned): + assert chat_member_banned.to_dict() == { + "status": chat_member_banned.status, + "user": chat_member_banned.user.to_dict(), + "until_date": to_timestamp(chat_member_banned.until_date), + } + + def test_equality(self, chat_member_banned): + a = chat_member_banned + b = ChatMemberBanned( + User(1, "test_user", is_bot=False), dtm.datetime.now(UTC).replace(microsecond=0) + ) + c = ChatMemberBanned( + User(2, "test_bot", is_bot=True), dtm.datetime.now(UTC).replace(microsecond=0) + ) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture def chat_member_left(): - return ChatMemberLeft(CMDefaults.user) + return ChatMemberLeft(TestChatMemberLeftWithoutRequest.user) -def chat_member_banned(): - return ChatMemberBanned(CMDefaults.user, CMDefaults.until_date) - - -def make_json_dict(instance: ChatMember, include_optional_args: bool = False) -> dict: - """Used to make the json dict which we use for testing de_json. Similar to iter_args()""" - json_dict = {"status": instance.status} - sig = inspect.signature(instance.__class__.__init__) - - for param in sig.parameters.values(): - if param.name in ignored: # ignore irrelevant params - continue - - val = getattr(instance, param.name) - # Compulsory args- - if param.default is inspect.Parameter.empty: - if hasattr(val, "to_dict"): # convert the user object or any future ones to dict. - val = val.to_dict() - json_dict[param.name] = val - - # If we want to test all args (for de_json)- - elif param.default is not inspect.Parameter.empty and include_optional_args: - json_dict[param.name] = val - return json_dict - - -def iter_args(instance: ChatMember, de_json_inst: ChatMember, include_optional: bool = False): - """ - We accept both the regular instance and de_json created instance and iterate over them for - easy one line testing later one. - """ - yield instance.status, de_json_inst.status # yield this here cause it's not available in sig. - - sig = inspect.signature(instance.__class__.__init__) - for param in sig.parameters.values(): - if param.name in ignored: - continue - inst_at, json_at = getattr(instance, param.name), getattr(de_json_inst, param.name) - if isinstance(json_at, datetime.datetime): # Convert datetime to int - json_at = to_timestamp(json_at) - if ( - param.default is not inspect.Parameter.empty and include_optional - ) or param.default is inspect.Parameter.empty: - yield inst_at, json_at - - -@pytest.fixture() -def chat_member_type(request): - return request.param() - - -@pytest.mark.parametrize( - "chat_member_type", - [ - chat_member_owner, - chat_member_administrator, - chat_member_member, - chat_member_restricted, - chat_member_left, - chat_member_banned, - ], - indirect=True, -) -class TestChatMemberTypesWithoutRequest: - def test_slot_behaviour(self, chat_member_type): - inst = chat_member_type +class TestChatMemberLeftWithoutRequest(ChatMemberTestBase): + status = ChatMemberStatus.LEFT + + def test_slot_behaviour(self, chat_member_left): + inst = chat_member_left 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_required_args(self, bot, chat_member_type): - cls = chat_member_type.__class__ - assert cls.de_json({}, bot) is None - - json_dict = make_json_dict(chat_member_type) - const_chat_member = ChatMember.de_json(json_dict, bot) - assert const_chat_member.api_kwargs == {} - - assert isinstance(const_chat_member, ChatMember) - assert isinstance(const_chat_member, cls) - for chat_mem_type_at, const_chat_mem_at in iter_args(chat_member_type, const_chat_member): - assert chat_mem_type_at == const_chat_mem_at - - def test_de_json_all_args(self, bot, chat_member_type): - json_dict = make_json_dict(chat_member_type, include_optional_args=True) - const_chat_member = ChatMember.de_json(json_dict, bot) - assert const_chat_member.api_kwargs == {} - - assert isinstance(const_chat_member, ChatMember) - assert isinstance(const_chat_member, chat_member_type.__class__) - for c_mem_type_at, const_c_mem_at in iter_args(chat_member_type, const_chat_member, True): - assert c_mem_type_at == const_c_mem_at - - def test_de_json_chatmemberbanned_localization(self, chat_member_type, tz_bot, bot, raw_bot): - # We only test two classes because the other three don't have datetimes in them. - if isinstance(chat_member_type, (ChatMemberBanned, ChatMemberRestricted)): - json_dict = make_json_dict(chat_member_type, include_optional_args=True) - chatmember_raw = ChatMember.de_json(json_dict, raw_bot) - chatmember_bot = ChatMember.de_json(json_dict, bot) - chatmember_tz = ChatMember.de_json(json_dict, tz_bot) - - # comparing utcoffsets because comparing timezones is unpredicatable - chatmember_offset = chatmember_tz.until_date.utcoffset() - tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( - chatmember_tz.until_date.replace(tzinfo=None) - ) - - assert chatmember_raw.until_date.tzinfo == UTC - assert chatmember_bot.until_date.tzinfo == UTC - assert chatmember_offset == tz_bot_offset - - def test_de_json_invalid_status(self, chat_member_type, bot): - json_dict = {"status": "invalid", "user": CMDefaults.user.to_dict()} - chat_member_type = ChatMember.de_json(json_dict, bot) - - assert type(chat_member_type) is ChatMember - assert chat_member_type.status == "invalid" - - def test_de_json_subclass(self, chat_member_type, bot, chat_id): - """This makes sure that e.g. ChatMemberAdministrator(data, bot) never returns a - ChatMemberBanned instance.""" - cls = chat_member_type.__class__ - json_dict = make_json_dict(chat_member_type, True) - assert type(cls.de_json(json_dict, bot)) is cls - - def test_to_dict(self, chat_member_type): - chat_member_dict = chat_member_type.to_dict() - - assert isinstance(chat_member_dict, dict) - assert chat_member_dict["status"] == chat_member_type.status - assert chat_member_dict["user"] == chat_member_type.user.to_dict() - - for slot in chat_member_type.__slots__: # additional verification for the optional args - assert getattr(chat_member_type, slot) == chat_member_dict[slot] - - def test_equality(self, chat_member_type): - a = ChatMember(status="status", user=CMDefaults.user) - b = ChatMember(status="status", user=CMDefaults.user) - c = chat_member_type - d = deepcopy(chat_member_type) - e = Dice(4, "emoji") + def test_de_json(self, offline_bot): + data = {"user": self.user.to_dict()} + chat_member = ChatMemberLeft.de_json(data, offline_bot) + + assert type(chat_member) is ChatMemberLeft + assert chat_member.api_kwargs == {} + + assert chat_member.user == self.user + + def test_to_dict(self, chat_member_left): + assert chat_member_left.to_dict() == { + "status": chat_member_left.status, + "user": chat_member_left.user.to_dict(), + } + + def test_equality(self, chat_member_left): + a = chat_member_left + b = ChatMemberLeft(User(1, "test_user", is_bot=False)) + c = ChatMemberLeft(User(2, "test_bot", is_bot=True)) + d = Dice(5, "test") assert a == b assert hash(a) == hash(b) @@ -276,11 +422,275 @@ def test_equality(self, chat_member_type): assert a != d assert hash(a) != hash(d) - assert a != e - assert hash(a) != hash(e) - assert c == d - assert hash(c) == hash(d) +@pytest.fixture +def chat_member_member(): + return ChatMemberMember(TestChatMemberMemberWithoutRequest.user) + - assert c != e - assert hash(c) != hash(e) +class TestChatMemberMemberWithoutRequest(ChatMemberTestBase): + status = ChatMemberStatus.MEMBER + + def test_slot_behaviour(self, chat_member_member): + inst = chat_member_member + 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, offline_bot): + data = {"user": self.user.to_dict(), "until_date": to_timestamp(self.until_date)} + chat_member = ChatMemberMember.de_json(data, offline_bot) + + assert type(chat_member) is ChatMemberMember + assert chat_member.api_kwargs == {} + + assert chat_member.user == self.user + assert chat_member.until_date == self.until_date + + def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): + json_dict = { + "user": self.user.to_dict(), + "until_date": to_timestamp(self.until_date), + } + + cmm_raw = ChatMemberMember.de_json(json_dict, raw_bot) + cmm_bot = ChatMemberMember.de_json(json_dict, offline_bot) + cmm_bot_tz = ChatMemberMember.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + cmm_bot_tz_offset = cmm_bot_tz.until_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + cmm_bot_tz.until_date.replace(tzinfo=None) + ) + + assert cmm_raw.until_date.tzinfo == UTC + assert cmm_bot.until_date.tzinfo == UTC + assert cmm_bot_tz_offset == tz_bot_offset + + def test_to_dict(self, chat_member_member): + assert chat_member_member.to_dict() == { + "status": chat_member_member.status, + "user": chat_member_member.user.to_dict(), + } + + def test_equality(self, chat_member_member): + a = chat_member_member + b = ChatMemberMember(User(1, "test_user", is_bot=False)) + c = ChatMemberMember(User(2, "test_bot", is_bot=True)) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def chat_member_owner(): + return ChatMemberOwner( + TestChatMemberOwnerWithoutRequest.user, + TestChatMemberOwnerWithoutRequest.is_anonymous, + TestChatMemberOwnerWithoutRequest.custom_title, + ) + + +class TestChatMemberOwnerWithoutRequest(ChatMemberTestBase): + status = ChatMemberStatus.OWNER + + def test_slot_behaviour(self, chat_member_owner): + inst = chat_member_owner + 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, offline_bot): + data = { + "user": self.user.to_dict(), + "is_anonymous": self.is_anonymous, + "custom_title": self.custom_title, + } + chat_member = ChatMemberOwner.de_json(data, offline_bot) + + assert type(chat_member) is ChatMemberOwner + assert chat_member.api_kwargs == {} + + assert chat_member.user == self.user + assert chat_member.is_anonymous == self.is_anonymous + assert chat_member.custom_title == self.custom_title + + def test_to_dict(self, chat_member_owner): + assert chat_member_owner.to_dict() == { + "status": chat_member_owner.status, + "user": chat_member_owner.user.to_dict(), + "is_anonymous": chat_member_owner.is_anonymous, + "custom_title": chat_member_owner.custom_title, + } + + def test_equality(self, chat_member_owner): + a = chat_member_owner + b = ChatMemberOwner(User(1, "test_user", is_bot=False), True, "test_title") + c = ChatMemberOwner(User(1, "test_user", is_bot=False), False, "test_title") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def chat_member_restricted(): + return ChatMemberRestricted( + user=TestChatMemberRestrictedWithoutRequest.user, + can_add_web_page_previews=TestChatMemberRestrictedWithoutRequest.can_add_web_page_previews, + can_change_info=TestChatMemberRestrictedWithoutRequest.can_change_info, + can_invite_users=TestChatMemberRestrictedWithoutRequest.can_invite_users, + can_manage_topics=TestChatMemberRestrictedWithoutRequest.can_manage_topics, + can_pin_messages=TestChatMemberRestrictedWithoutRequest.can_pin_messages, + can_send_audios=TestChatMemberRestrictedWithoutRequest.can_send_audios, + can_send_documents=TestChatMemberRestrictedWithoutRequest.can_send_documents, + can_send_messages=TestChatMemberRestrictedWithoutRequest.can_send_messages, + can_send_other_messages=TestChatMemberRestrictedWithoutRequest.can_send_other_messages, + can_send_photos=TestChatMemberRestrictedWithoutRequest.can_send_photos, + can_send_polls=TestChatMemberRestrictedWithoutRequest.can_send_polls, + can_send_video_notes=TestChatMemberRestrictedWithoutRequest.can_send_video_notes, + can_send_videos=TestChatMemberRestrictedWithoutRequest.can_send_videos, + can_send_voice_notes=TestChatMemberRestrictedWithoutRequest.can_send_voice_notes, + is_member=TestChatMemberRestrictedWithoutRequest.is_member, + until_date=TestChatMemberRestrictedWithoutRequest.until_date, + ) + + +class TestChatMemberRestrictedWithoutRequest(ChatMemberTestBase): + status = ChatMemberStatus.RESTRICTED + + def test_slot_behaviour(self, chat_member_restricted): + inst = chat_member_restricted + 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, offline_bot): + data = { + "user": self.user.to_dict(), + "can_add_web_page_previews": self.can_add_web_page_previews, + "can_change_info": self.can_change_info, + "can_invite_users": self.can_invite_users, + "can_manage_topics": self.can_manage_topics, + "can_pin_messages": self.can_pin_messages, + "can_send_audios": self.can_send_audios, + "can_send_documents": self.can_send_documents, + "can_send_messages": self.can_send_messages, + "can_send_other_messages": self.can_send_other_messages, + "can_send_photos": self.can_send_photos, + "can_send_polls": self.can_send_polls, + "can_send_video_notes": self.can_send_video_notes, + "can_send_videos": self.can_send_videos, + "can_send_voice_notes": self.can_send_voice_notes, + "is_member": self.is_member, + "until_date": to_timestamp(self.until_date), + # legacy argument + "can_send_media_messages": False, + } + chat_member = ChatMemberRestricted.de_json(data, offline_bot) + + assert type(chat_member) is ChatMemberRestricted + assert chat_member.api_kwargs == {"can_send_media_messages": False} + + assert chat_member.user == self.user + assert chat_member.can_add_web_page_previews == self.can_add_web_page_previews + assert chat_member.can_change_info == self.can_change_info + assert chat_member.can_invite_users == self.can_invite_users + assert chat_member.can_manage_topics == self.can_manage_topics + assert chat_member.can_pin_messages == self.can_pin_messages + assert chat_member.can_send_audios == self.can_send_audios + assert chat_member.can_send_documents == self.can_send_documents + assert chat_member.can_send_messages == self.can_send_messages + assert chat_member.can_send_other_messages == self.can_send_other_messages + assert chat_member.can_send_photos == self.can_send_photos + assert chat_member.can_send_polls == self.can_send_polls + assert chat_member.can_send_video_notes == self.can_send_video_notes + assert chat_member.can_send_videos == self.can_send_videos + assert chat_member.can_send_voice_notes == self.can_send_voice_notes + assert chat_member.is_member == self.is_member + assert chat_member.until_date == self.until_date + + def test_de_json_localization(self, tz_bot, offline_bot, raw_bot, chat_member_restricted): + json_dict = chat_member_restricted.to_dict() + + cmr_raw = ChatMemberRestricted.de_json(json_dict, raw_bot) + cmr_bot = ChatMemberRestricted.de_json(json_dict, offline_bot) + cmr_bot_tz = ChatMemberRestricted.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + cmr_bot_tz_offset = cmr_bot_tz.until_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + cmr_bot_tz.until_date.replace(tzinfo=None) + ) + + assert cmr_raw.until_date.tzinfo == UTC + assert cmr_bot.until_date.tzinfo == UTC + assert cmr_bot_tz_offset == tz_bot_offset + + def test_to_dict(self, chat_member_restricted): + assert chat_member_restricted.to_dict() == { + "status": chat_member_restricted.status, + "user": chat_member_restricted.user.to_dict(), + "can_add_web_page_previews": chat_member_restricted.can_add_web_page_previews, + "can_change_info": chat_member_restricted.can_change_info, + "can_invite_users": chat_member_restricted.can_invite_users, + "can_manage_topics": chat_member_restricted.can_manage_topics, + "can_pin_messages": chat_member_restricted.can_pin_messages, + "can_send_audios": chat_member_restricted.can_send_audios, + "can_send_documents": chat_member_restricted.can_send_documents, + "can_send_messages": chat_member_restricted.can_send_messages, + "can_send_other_messages": chat_member_restricted.can_send_other_messages, + "can_send_photos": chat_member_restricted.can_send_photos, + "can_send_polls": chat_member_restricted.can_send_polls, + "can_send_video_notes": chat_member_restricted.can_send_video_notes, + "can_send_videos": chat_member_restricted.can_send_videos, + "can_send_voice_notes": chat_member_restricted.can_send_voice_notes, + "is_member": chat_member_restricted.is_member, + "until_date": to_timestamp(chat_member_restricted.until_date), + } + + def test_equality(self, chat_member_restricted): + a = chat_member_restricted + b = deepcopy(chat_member_restricted) + c = ChatMemberRestricted( + User(1, "test_user", is_bot=False), + False, + False, + False, + False, + False, + False, + False, + False, + False, + self.until_date, + False, + False, + False, + False, + False, + False, + ) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_chatmemberupdated.py b/tests/test_chatmemberupdated.py index f0baf88e038..ec76fd2fce7 100644 --- a/tests/test_chatmemberupdated.py +++ b/tests/test_chatmemberupdated.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,7 +16,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/]. -import datetime +import datetime as dtm import inspect import pytest @@ -47,14 +47,13 @@ def chat(): @pytest.fixture(scope="module") def old_chat_member(user): - return ChatMember(user, TestChatMemberUpdatedBase.old_status) + return ChatMember(user, ChatMemberUpdatedTestBase.old_status) @pytest.fixture(scope="module") def new_chat_member(user): return ChatMemberAdministrator( user, - TestChatMemberUpdatedBase.new_status, True, True, True, @@ -64,12 +63,16 @@ def new_chat_member(user): True, True, True, + True, + True, + True, + custom_title=ChatMemberUpdatedTestBase.new_status, ) @pytest.fixture(scope="module") def time(): - return datetime.datetime.now(tz=UTC) + return dtm.datetime.now(tz=UTC) @pytest.fixture(scope="module") @@ -79,22 +82,26 @@ def invite_link(user): @pytest.fixture(scope="module") def chat_member_updated(user, chat, old_chat_member, new_chat_member, invite_link, time): - return ChatMemberUpdated(chat, user, time, old_chat_member, new_chat_member, invite_link, True) + return ChatMemberUpdated( + chat, user, time, old_chat_member, new_chat_member, invite_link, True, True + ) -class TestChatMemberUpdatedBase: +class ChatMemberUpdatedTestBase: old_status = ChatMember.MEMBER new_status = ChatMember.ADMINISTRATOR -class TestChatMemberUpdatedWithoutRequest(TestChatMemberUpdatedBase): +class TestChatMemberUpdatedWithoutRequest(ChatMemberUpdatedTestBase): def test_slot_behaviour(self, chat_member_updated): action = chat_member_updated for attr in action.__slots__: assert getattr(action, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" - def test_de_json_required_args(self, bot, user, chat, old_chat_member, new_chat_member, time): + def test_de_json_required_args( + self, offline_bot, user, chat, old_chat_member, new_chat_member, time + ): json_dict = { "chat": chat.to_dict(), "from": user.to_dict(), @@ -103,12 +110,12 @@ def test_de_json_required_args(self, bot, user, chat, old_chat_member, new_chat_ "new_chat_member": new_chat_member.to_dict(), } - chat_member_updated = ChatMemberUpdated.de_json(json_dict, bot) + chat_member_updated = ChatMemberUpdated.de_json(json_dict, offline_bot) assert chat_member_updated.api_kwargs == {} assert chat_member_updated.chat == chat assert chat_member_updated.from_user == user - assert abs(chat_member_updated.date - time) < datetime.timedelta(seconds=1) + assert abs(chat_member_updated.date - time) < dtm.timedelta(seconds=1) assert to_timestamp(chat_member_updated.date) == to_timestamp(time) assert chat_member_updated.old_chat_member == old_chat_member assert chat_member_updated.new_chat_member == new_chat_member @@ -116,7 +123,7 @@ def test_de_json_required_args(self, bot, user, chat, old_chat_member, new_chat_ assert chat_member_updated.via_chat_folder_invite_link is None def test_de_json_all_args( - self, bot, user, time, invite_link, chat, old_chat_member, new_chat_member + self, offline_bot, user, time, invite_link, chat, old_chat_member, new_chat_member ): json_dict = { "chat": chat.to_dict(), @@ -126,22 +133,33 @@ def test_de_json_all_args( "new_chat_member": new_chat_member.to_dict(), "invite_link": invite_link.to_dict(), "via_chat_folder_invite_link": True, + "via_join_request": True, } - chat_member_updated = ChatMemberUpdated.de_json(json_dict, bot) + chat_member_updated = ChatMemberUpdated.de_json(json_dict, offline_bot) assert chat_member_updated.api_kwargs == {} assert chat_member_updated.chat == chat assert chat_member_updated.from_user == user - assert abs(chat_member_updated.date - time) < datetime.timedelta(seconds=1) + assert abs(chat_member_updated.date - time) < dtm.timedelta(seconds=1) assert to_timestamp(chat_member_updated.date) == to_timestamp(time) assert chat_member_updated.old_chat_member == old_chat_member assert chat_member_updated.new_chat_member == new_chat_member assert chat_member_updated.invite_link == invite_link assert chat_member_updated.via_chat_folder_invite_link is True + assert chat_member_updated.via_join_request is True def test_de_json_localization( - self, bot, raw_bot, tz_bot, user, chat, old_chat_member, new_chat_member, time, invite_link + self, + offline_bot, + raw_bot, + tz_bot, + user, + chat, + old_chat_member, + new_chat_member, + time, + invite_link, ): json_dict = { "chat": chat.to_dict(), @@ -152,7 +170,7 @@ def test_de_json_localization( "invite_link": invite_link.to_dict(), } - chat_member_updated_bot = ChatMemberUpdated.de_json(json_dict, bot) + chat_member_updated_bot = ChatMemberUpdated.de_json(json_dict, offline_bot) chat_member_updated_raw = ChatMemberUpdated.de_json(json_dict, raw_bot) chat_member_updated_tz = ChatMemberUpdated.de_json(json_dict, tz_bot) @@ -185,6 +203,7 @@ def test_to_dict(self, chat_member_updated): chat_member_updated_dict["via_chat_folder_invite_link"] == chat_member_updated.via_chat_folder_invite_link ) + assert chat_member_updated_dict["via_join_request"] == chat_member_updated.via_join_request def test_equality(self, time, old_chat_member, new_chat_member, invite_link): a = ChatMemberUpdated( @@ -202,7 +221,7 @@ def test_equality(self, time, old_chat_member, new_chat_member, invite_link): c = ChatMemberUpdated( Chat(1, "chat"), User(1, "", False), - time + datetime.timedelta(hours=1), + time + dtm.timedelta(hours=1), old_chat_member, new_chat_member, ) @@ -245,7 +264,7 @@ def test_difference_required(self, user, chat): old_chat_member = ChatMember(user, "old_status") new_chat_member = ChatMember(user, "new_status") chat_member_updated = ChatMemberUpdated( - chat, user, datetime.datetime.utcnow(), old_chat_member, new_chat_member + chat, user, dtm.datetime.utcnow(), old_chat_member, new_chat_member ) assert chat_member_updated.difference() == {"status": ("old_status", "new_status")} @@ -254,7 +273,7 @@ def test_difference_required(self, user, chat): new_user = User(1, "First name", False, last_name="last name") new_chat_member = ChatMember(new_user, "new_status") chat_member_updated = ChatMemberUpdated( - chat, user, datetime.datetime.utcnow(), old_chat_member, new_chat_member + chat, user, dtm.datetime.utcnow(), old_chat_member, new_chat_member ) assert chat_member_updated.difference() == { "status": ("old_status", "new_status"), @@ -264,10 +283,19 @@ def test_difference_required(self, user, chat): @pytest.mark.parametrize( "optional_attribute", # This gives the names of all optional arguments of ChatMember + # skipping stories names because they aren't optional even though we pretend they are [ name for name, param in inspect.signature(ChatMemberAdministrator).parameters.items() - if name not in ["self", "api_kwargs"] and param.default != inspect.Parameter.empty + if name + not in [ + "self", + "api_kwargs", + "can_delete_stories", + "can_post_stories", + "can_edit_stories", + ] + and param.default != inspect.Parameter.empty ], ) def test_difference_optionals(self, optional_attribute, user, chat): @@ -276,25 +304,39 @@ def test_difference_optionals(self, optional_attribute, user, chat): old_value = "old_value" new_value = "new_value" trues = tuple(True for _ in range(9)) - old_chat_member = ChatMemberAdministrator(user, *trues, **{optional_attribute: old_value}) - new_chat_member = ChatMemberAdministrator(user, *trues, **{optional_attribute: new_value}) + old_chat_member = ChatMemberAdministrator( + user, + *trues, + **{optional_attribute: old_value}, + can_delete_stories=True, + can_edit_stories=True, + can_post_stories=True, + ) + new_chat_member = ChatMemberAdministrator( + user, + *trues, + **{optional_attribute: new_value}, + can_delete_stories=True, + can_edit_stories=True, + can_post_stories=True, + ) chat_member_updated = ChatMemberUpdated( - chat, user, datetime.datetime.utcnow(), old_chat_member, new_chat_member + chat, user, dtm.datetime.utcnow(), old_chat_member, new_chat_member ) assert chat_member_updated.difference() == {optional_attribute: (old_value, new_value)} def test_difference_different_classes(self, user, chat): old_chat_member = ChatMemberOwner(user=user, is_anonymous=False) - new_chat_member = ChatMemberBanned(user=user, until_date=datetime.datetime(2021, 1, 1)) + new_chat_member = ChatMemberBanned(user=user, until_date=dtm.datetime(2021, 1, 1)) chat_member_updated = ChatMemberUpdated( chat=chat, from_user=user, - date=datetime.datetime.utcnow(), + date=dtm.datetime.utcnow(), old_chat_member=old_chat_member, new_chat_member=new_chat_member, ) diff = chat_member_updated.difference() assert diff.pop("is_anonymous") == (False, None) - assert diff.pop("until_date") == (None, datetime.datetime(2021, 1, 1)) + assert diff.pop("until_date") == (None, dtm.datetime(2021, 1, 1)) assert diff.pop("status") == (ChatMember.OWNER, ChatMember.BANNED) assert diff == {} diff --git a/tests/test_chatpermissions.py b/tests/test_chatpermissions.py index 9585710c108..54de0bc00f1 100644 --- a/tests/test_chatpermissions.py +++ b/tests/test_chatpermissions.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,7 +20,6 @@ import pytest from telegram import ChatPermissions, User -from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -28,7 +27,6 @@ def chat_permissions(): return ChatPermissions( can_send_messages=True, - can_send_media_messages=True, can_send_polls=True, can_send_other_messages=True, can_add_web_page_previews=True, @@ -45,9 +43,8 @@ def chat_permissions(): ) -class TestChatPermissionsBase: +class ChatPermissionsTestBase: can_send_messages = True - can_send_media_messages = True can_send_polls = True can_send_other_messages = False can_add_web_page_previews = False @@ -63,17 +60,17 @@ class TestChatPermissionsBase: can_send_voice_notes = None -class TestChatPermissionsWithoutRequest(TestChatPermissionsBase): +class TestChatPermissionsWithoutRequest(ChatPermissionsTestBase): def test_slot_behaviour(self, chat_permissions): inst = chat_permissions 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): + def test_de_json(self, offline_bot): json_dict = { "can_send_messages": self.can_send_messages, - "can_send_media_messages": self.can_send_media_messages, + "can_send_media_messages": "can_send_media_messages", "can_send_polls": self.can_send_polls, "can_send_other_messages": self.can_send_other_messages, "can_add_web_page_previews": self.can_add_web_page_previews, @@ -87,11 +84,10 @@ def test_de_json(self, bot): "can_send_video_notes": self.can_send_video_notes, "can_send_voice_notes": self.can_send_voice_notes, } - permissions = ChatPermissions.de_json(json_dict, bot) - assert permissions.api_kwargs == {} + permissions = ChatPermissions.de_json(json_dict, offline_bot) + assert permissions.api_kwargs == {"can_send_media_messages": "can_send_media_messages"} assert permissions.can_send_messages == self.can_send_messages - assert permissions.can_send_media_messages == self.can_send_media_messages assert permissions.can_send_polls == self.can_send_polls assert permissions.can_send_other_messages == self.can_send_other_messages assert permissions.can_add_web_page_previews == self.can_add_web_page_previews @@ -111,9 +107,6 @@ def test_to_dict(self, chat_permissions): assert isinstance(permissions_dict, dict) assert permissions_dict["can_send_messages"] == chat_permissions.can_send_messages - assert ( - permissions_dict["can_send_media_messages"] == chat_permissions.can_send_media_messages - ) assert permissions_dict["can_send_polls"] == chat_permissions.can_send_polls assert ( permissions_dict["can_send_other_messages"] == chat_permissions.can_send_other_messages @@ -136,7 +129,6 @@ def test_to_dict(self, chat_permissions): def test_equality(self): a = ChatPermissions( can_send_messages=True, - can_send_media_messages=True, can_send_polls=True, can_send_other_messages=False, ) @@ -144,18 +136,26 @@ def test_equality(self): can_send_polls=True, can_send_other_messages=False, can_send_messages=True, - can_send_media_messages=True, ) c = ChatPermissions( can_send_messages=False, - can_send_media_messages=True, can_send_polls=True, can_send_other_messages=False, ) d = User(123, "", False) e = ChatPermissions( can_send_messages=True, - can_send_media_messages=True, + can_send_polls=True, + can_send_other_messages=False, + can_send_audios=True, + can_send_documents=True, + can_send_photos=True, + can_send_videos=True, + can_send_video_notes=True, + can_send_voice_notes=True, + ) + f = ChatPermissions( + can_send_messages=True, can_send_polls=True, can_send_other_messages=False, can_send_audios=True, @@ -176,9 +176,11 @@ def test_equality(self): assert a != d assert hash(a) != hash(d) - # we expect this to be true since we don't compare these in V20 - assert a == e - assert hash(a) == hash(e) + assert a != e + assert hash(a) != hash(e) + + assert e == f + assert hash(e) == hash(f) def test_all_permissions(self): f = ChatPermissions() @@ -203,14 +205,3 @@ def test_no_permissions(self): assert t[key] is False # and as a finisher, make sure the default is different. assert f != t - - def test_equality_warning(self, recwarn, chat_permissions): - recwarn.clear() - assert chat_permissions == chat_permissions - - assert str(recwarn[0].message) == ( - "In v21, granular media settings will be considered as well when comparing" - " ChatPermissions instances." - ) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__, "wrong stacklevel" diff --git a/tests/test_checklists.py b/tests/test_checklists.py new file mode 100644 index 00000000000..5395a900e35 --- /dev/null +++ b/tests/test_checklists.py @@ -0,0 +1,445 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm + +import pytest + +from telegram import ( + Chat, + Checklist, + ChecklistTask, + ChecklistTasksAdded, + ChecklistTasksDone, + Dice, + MessageEntity, + User, +) +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import ZERO_DATE +from tests.auxil.build_messages import make_message +from tests.auxil.slots import mro_slots + + +class ChecklistTaskTestBase: + id = 42 + text = "here is a text" + text_entities = [ + MessageEntity(type="bold", offset=0, length=4), + MessageEntity(type="italic", offset=5, length=2), + ] + completed_by_user = User(id=1, first_name="Test", last_name="User", is_bot=False) + completed_by_chat = Chat(id=-100, type=Chat.SUPERGROUP, title="Test Chat") + completion_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + + +@pytest.fixture(scope="module") +def checklist_task(): + return ChecklistTask( + id=ChecklistTaskTestBase.id, + text=ChecklistTaskTestBase.text, + text_entities=ChecklistTaskTestBase.text_entities, + completed_by_user=ChecklistTaskTestBase.completed_by_user, + completed_by_chat=ChecklistTaskTestBase.completed_by_chat, + completion_date=ChecklistTaskTestBase.completion_date, + ) + + +class TestChecklistTaskWithoutRequest(ChecklistTaskTestBase): + def test_slot_behaviour(self, checklist_task): + for attr in checklist_task.__slots__: + assert getattr(checklist_task, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(checklist_task)) == len(set(mro_slots(checklist_task))), ( + "duplicate slot" + ) + + def test_to_dict(self, checklist_task): + clt_dict = checklist_task.to_dict() + assert isinstance(clt_dict, dict) + assert clt_dict["id"] == self.id + assert clt_dict["text"] == self.text + assert clt_dict["text_entities"] == [entity.to_dict() for entity in self.text_entities] + assert clt_dict["completed_by_user"] == self.completed_by_user.to_dict() + assert clt_dict["completed_by_chat"] == self.completed_by_chat.to_dict() + assert clt_dict["completion_date"] == to_timestamp(self.completion_date) + + def test_de_json(self, offline_bot): + json_dict = { + "id": self.id, + "text": self.text, + "text_entities": [entity.to_dict() for entity in self.text_entities], + "completed_by_user": self.completed_by_user.to_dict(), + "completed_by_chat": self.completed_by_chat.to_dict(), + "completion_date": to_timestamp(self.completion_date), + } + clt = ChecklistTask.de_json(json_dict, offline_bot) + assert isinstance(clt, ChecklistTask) + assert clt.id == self.id + assert clt.text == self.text + assert clt.text_entities == tuple(self.text_entities) + assert clt.completed_by_user == self.completed_by_user + assert clt.completed_by_chat == self.completed_by_chat + assert clt.completion_date == self.completion_date + assert clt.api_kwargs == {} + + def test_de_json_required_fields(self, offline_bot): + json_dict = { + "id": self.id, + "text": self.text, + } + clt = ChecklistTask.de_json(json_dict, offline_bot) + assert isinstance(clt, ChecklistTask) + assert clt.id == self.id + assert clt.text == self.text + assert clt.text_entities == () + assert clt.completed_by_user is None + assert clt.completion_date is None + assert clt.api_kwargs == {} + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): + json_dict = { + "id": self.id, + "text": self.text, + "completion_date": to_timestamp(self.completion_date), + } + clt_bot = ChecklistTask.de_json(json_dict, offline_bot) + clt_bot_raw = ChecklistTask.de_json(json_dict, raw_bot) + clt_bot_tz = ChecklistTask.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing tzinfo objects is not reliable + completion_date_offset = clt_bot_tz.completion_date.utcoffset() + completion_date_offset_tz = tz_bot.defaults.tzinfo.utcoffset( + clt_bot_tz.completion_date.replace(tzinfo=None) + ) + + assert clt_bot.completion_date.tzinfo == UTC + assert clt_bot_raw.completion_date.tzinfo == UTC + assert completion_date_offset_tz == completion_date_offset + + @pytest.mark.parametrize( + ("completion_date", "expected"), + [ + (None, None), + (0, ZERO_DATE), + (1735689600, dtm.datetime(2025, 1, 1, tzinfo=UTC)), + ], + ) + def test_de_json_completion_date(self, offline_bot, completion_date, expected): + json_dict = { + "id": self.id, + "text": self.text, + "completion_date": completion_date, + } + clt = ChecklistTask.de_json(json_dict, offline_bot) + assert isinstance(clt, ChecklistTask) + assert clt.completion_date == expected + + def test_parse_entity(self, checklist_task): + assert checklist_task.parse_entity(checklist_task.text_entities[0]) == "here" + + def test_parse_entities(self, checklist_task): + assert checklist_task.parse_entities(MessageEntity.BOLD) == { + checklist_task.text_entities[0]: "here" + } + assert checklist_task.parse_entities() == { + checklist_task.text_entities[0]: "here", + checklist_task.text_entities[1]: "is", + } + + def test_equality(self, checklist_task): + clt1 = checklist_task + clt2 = ChecklistTask( + id=self.id, + text="other text", + ) + clt3 = ChecklistTask( + id=self.id + 1, + text=self.text, + ) + clt4 = Dice(value=1, emoji="🎲") + + assert clt1 == clt2 + assert hash(clt1) == hash(clt2) + + assert clt1 != clt3 + assert hash(clt1) != hash(clt3) + + assert clt1 != clt4 + assert hash(clt1) != hash(clt4) + + +class ChecklistTestBase: + title = "Checklist Title" + title_entities = [ + MessageEntity(type="bold", offset=0, length=9), + MessageEntity(type="italic", offset=10, length=5), + ] + tasks = [ + ChecklistTask( + id=1, + text="Task 1", + ), + ChecklistTask( + id=2, + text="Task 2", + ), + ] + others_can_add_tasks = True + others_can_mark_tasks_as_done = False + + +@pytest.fixture(scope="module") +def checklist(): + return Checklist( + title=ChecklistTestBase.title, + title_entities=ChecklistTestBase.title_entities, + tasks=ChecklistTestBase.tasks, + others_can_add_tasks=ChecklistTestBase.others_can_add_tasks, + others_can_mark_tasks_as_done=ChecklistTestBase.others_can_mark_tasks_as_done, + ) + + +class TestChecklistWithoutRequest(ChecklistTestBase): + def test_slot_behaviour(self, checklist): + for attr in checklist.__slots__: + assert getattr(checklist, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(checklist)) == len(set(mro_slots(checklist))), "duplicate slot" + + def test_to_dict(self, checklist): + cl_dict = checklist.to_dict() + assert isinstance(cl_dict, dict) + assert cl_dict["title"] == self.title + assert cl_dict["title_entities"] == [entity.to_dict() for entity in self.title_entities] + assert cl_dict["tasks"] == [task.to_dict() for task in self.tasks] + assert cl_dict["others_can_add_tasks"] is self.others_can_add_tasks + assert cl_dict["others_can_mark_tasks_as_done"] is self.others_can_mark_tasks_as_done + + def test_de_json(self, offline_bot): + json_dict = { + "title": self.title, + "title_entities": [entity.to_dict() for entity in self.title_entities], + "tasks": [task.to_dict() for task in self.tasks], + "others_can_add_tasks": self.others_can_add_tasks, + "others_can_mark_tasks_as_done": self.others_can_mark_tasks_as_done, + } + cl = Checklist.de_json(json_dict, offline_bot) + assert isinstance(cl, Checklist) + assert cl.title == self.title + assert cl.title_entities == tuple(self.title_entities) + assert cl.tasks == tuple(self.tasks) + assert cl.others_can_add_tasks is self.others_can_add_tasks + assert cl.others_can_mark_tasks_as_done is self.others_can_mark_tasks_as_done + assert cl.api_kwargs == {} + + def test_de_json_required_fields(self, offline_bot): + json_dict = { + "title": self.title, + "tasks": [task.to_dict() for task in self.tasks], + } + cl = Checklist.de_json(json_dict, offline_bot) + assert isinstance(cl, Checklist) + assert cl.title == self.title + assert cl.title_entities == () + assert cl.tasks == tuple(self.tasks) + assert not cl.others_can_add_tasks + assert not cl.others_can_mark_tasks_as_done + + def test_parse_entity(self, checklist): + assert checklist.parse_entity(checklist.title_entities[0]) == "Checklist" + assert checklist.parse_entity(checklist.title_entities[1]) == "Title" + + def test_parse_entities(self, checklist): + assert checklist.parse_entities(MessageEntity.BOLD) == { + checklist.title_entities[0]: "Checklist" + } + assert checklist.parse_entities() == { + checklist.title_entities[0]: "Checklist", + checklist.title_entities[1]: "Title", + } + + def test_equality(self, checklist, checklist_task): + cl1 = checklist + cl2 = Checklist( + title=self.title + " other", + tasks=[ChecklistTask(id=1, text="something"), ChecklistTask(id=2, text="something")], + ) + cl3 = Checklist( + title=self.title + " other", + tasks=[ChecklistTask(id=42, text="Task 2")], + ) + cl4 = checklist_task + + assert cl1 == cl2 + assert hash(cl1) == hash(cl2) + + assert cl1 != cl3 + assert hash(cl1) != hash(cl3) + + assert cl1 != cl4 + assert hash(cl1) != hash(cl4) + + +class ChecklistTasksDoneTestBase: + checklist_message = make_message("Checklist message") + marked_as_done_task_ids = [1, 2, 3] + marked_as_not_done_task_ids = [4, 5] + + +@pytest.fixture(scope="module") +def checklist_tasks_done(): + return ChecklistTasksDone( + checklist_message=ChecklistTasksDoneTestBase.checklist_message, + marked_as_done_task_ids=ChecklistTasksDoneTestBase.marked_as_done_task_ids, + marked_as_not_done_task_ids=ChecklistTasksDoneTestBase.marked_as_not_done_task_ids, + ) + + +class TestChecklistTasksDoneWithoutRequest(ChecklistTasksDoneTestBase): + def test_slot_behaviour(self, checklist_tasks_done): + for attr in checklist_tasks_done.__slots__: + assert getattr(checklist_tasks_done, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(checklist_tasks_done)) == len(set(mro_slots(checklist_tasks_done))), ( + "duplicate slot" + ) + + def test_to_dict(self, checklist_tasks_done): + cltd_dict = checklist_tasks_done.to_dict() + assert isinstance(cltd_dict, dict) + assert cltd_dict["checklist_message"] == self.checklist_message.to_dict() + assert cltd_dict["marked_as_done_task_ids"] == self.marked_as_done_task_ids + assert cltd_dict["marked_as_not_done_task_ids"] == self.marked_as_not_done_task_ids + + def test_de_json(self, offline_bot): + json_dict = { + "checklist_message": self.checklist_message.to_dict(), + "marked_as_done_task_ids": self.marked_as_done_task_ids, + "marked_as_not_done_task_ids": self.marked_as_not_done_task_ids, + } + cltd = ChecklistTasksDone.de_json(json_dict, offline_bot) + assert isinstance(cltd, ChecklistTasksDone) + assert cltd.checklist_message == self.checklist_message + assert cltd.marked_as_done_task_ids == tuple(self.marked_as_done_task_ids) + assert cltd.marked_as_not_done_task_ids == tuple(self.marked_as_not_done_task_ids) + assert cltd.api_kwargs == {} + + def test_de_json_required_fields(self, offline_bot): + cltd = ChecklistTasksDone.de_json({}, offline_bot) + assert isinstance(cltd, ChecklistTasksDone) + assert cltd.checklist_message is None + assert cltd.marked_as_done_task_ids == () + assert cltd.marked_as_not_done_task_ids == () + assert cltd.api_kwargs == {} + + def test_equality(self, checklist_tasks_done): + cltd1 = checklist_tasks_done + cltd2 = ChecklistTasksDone( + checklist_message=None, + marked_as_done_task_ids=[1, 2, 3], + marked_as_not_done_task_ids=[4, 5], + ) + cltd3 = ChecklistTasksDone( + checklist_message=make_message("Checklist message"), + marked_as_done_task_ids=[1, 2, 3], + ) + cltd4 = make_message("Not a checklist tasks done") + + assert cltd1 == cltd2 + assert hash(cltd1) == hash(cltd2) + + assert cltd1 != cltd3 + assert hash(cltd1) != hash(cltd3) + + assert cltd1 != cltd4 + assert hash(cltd1) != hash(cltd4) + + +class ChecklistTasksAddedTestBase: + checklist_message = make_message("Checklist message") + tasks = [ + ChecklistTask(id=1, text="Task 1"), + ChecklistTask(id=2, text="Task 2"), + ChecklistTask(id=3, text="Task 3"), + ] + + +@pytest.fixture(scope="module") +def checklist_tasks_added(): + return ChecklistTasksAdded( + checklist_message=ChecklistTasksAddedTestBase.checklist_message, + tasks=ChecklistTasksAddedTestBase.tasks, + ) + + +class TestChecklistTasksAddedWithoutRequest(ChecklistTasksAddedTestBase): + def test_slot_behaviour(self, checklist_tasks_added): + for attr in checklist_tasks_added.__slots__: + assert getattr(checklist_tasks_added, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(checklist_tasks_added)) == len( + set(mro_slots(checklist_tasks_added)) + ), "duplicate slot" + + def test_to_dict(self, checklist_tasks_added): + clta_dict = checklist_tasks_added.to_dict() + assert isinstance(clta_dict, dict) + assert clta_dict["checklist_message"] == self.checklist_message.to_dict() + assert clta_dict["tasks"] == [task.to_dict() for task in self.tasks] + + def test_de_json(self, offline_bot): + json_dict = { + "checklist_message": self.checklist_message.to_dict(), + "tasks": [task.to_dict() for task in self.tasks], + } + clta = ChecklistTasksAdded.de_json(json_dict, offline_bot) + assert isinstance(clta, ChecklistTasksAdded) + assert clta.checklist_message == self.checklist_message + assert clta.tasks == tuple(self.tasks) + assert clta.api_kwargs == {} + + def test_de_json_required_fields(self, offline_bot): + clta = ChecklistTasksAdded.de_json( + {"tasks": [task.to_dict() for task in self.tasks]}, offline_bot + ) + assert isinstance(clta, ChecklistTasksAdded) + assert clta.checklist_message is None + assert clta.tasks == tuple(self.tasks) + assert clta.api_kwargs == {} + + def test_equality(self, checklist_tasks_added): + clta1 = checklist_tasks_added + clta2 = ChecklistTasksAdded( + checklist_message=None, + tasks=[ + ChecklistTask(id=1, text="Other Task 1"), + ChecklistTask(id=2, text="Other Task 2"), + ChecklistTask(id=3, text="Other Task 3"), + ], + ) + clta3 = ChecklistTasksAdded( + checklist_message=make_message("Checklist message"), + tasks=[ChecklistTask(id=1, text="Task 1")], + ) + clta4 = make_message("Not a checklist tasks added") + + assert clta1 == clta2 + assert hash(clta1) == hash(clta2) + + assert clta1 != clta3 + assert hash(clta1) != hash(clta3) + + assert clta1 != clta4 + assert hash(clta1) != hash(clta4) diff --git a/tests/test_choseninlineresult.py b/tests/test_choseninlineresult.py index 0ed4605c99b..f5b1ce99274 100644 --- a/tests/test_choseninlineresult.py +++ b/tests/test_choseninlineresult.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -33,32 +33,32 @@ def user(): @pytest.fixture(scope="module") def chosen_inline_result(user): return ChosenInlineResult( - TestChosenInlineResultBase.result_id, user, TestChosenInlineResultBase.query + ChosenInlineResultTestBase.result_id, user, ChosenInlineResultTestBase.query ) -class TestChosenInlineResultBase: +class ChosenInlineResultTestBase: result_id = "result id" query = "query text" -class TestChosenInlineResultWithoutRequest(TestChosenInlineResultBase): +class TestChosenInlineResultWithoutRequest(ChosenInlineResultTestBase): def test_slot_behaviour(self, chosen_inline_result): inst = chosen_inline_result 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_required(self, bot, user): + def test_de_json_required(self, offline_bot, user): json_dict = {"result_id": self.result_id, "from": user.to_dict(), "query": self.query} - result = ChosenInlineResult.de_json(json_dict, bot) + result = ChosenInlineResult.de_json(json_dict, offline_bot) assert result.api_kwargs == {} assert result.result_id == self.result_id assert result.from_user == user assert result.query == self.query - def test_de_json_all(self, bot, user): + def test_de_json_all(self, offline_bot, user): loc = Location(-42.003, 34.004) json_dict = { "result_id": self.result_id, @@ -67,7 +67,7 @@ def test_de_json_all(self, bot, user): "location": loc.to_dict(), "inline_message_id": "a random id", } - result = ChosenInlineResult.de_json(json_dict, bot) + result = ChosenInlineResult.de_json(json_dict, offline_bot) assert result.api_kwargs == {} assert result.result_id == self.result_id diff --git a/tests/test_constants.py b/tests/test_constants.py index 681de2b97f0..5611b5f80c7 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -17,12 +17,18 @@ # 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 asyncio +import inspect import json +import re -from telegram import constants -from telegram._utils.enum import IntEnum, StringEnum +import pytest + +from telegram import Message, constants +from telegram._utils.enum import FloatEnum, IntEnum, StringEnum from telegram.error import BadRequest +from tests.auxil.build_messages import make_message from tests.auxil.files import data_file +from tests.auxil.string_manipulation import to_snake_case class StrEnumTest(StringEnum): @@ -35,6 +41,11 @@ class IntEnumTest(IntEnum): BAR = 2 +class FloatEnumTest(FloatEnum): + FOO = 1.1 + BAR = 2.1 + + class TestConstantsWithoutRequest: """Also test _utils.enum.StringEnum on the fly because tg.constants is currently the only place where that class is used.""" @@ -47,13 +58,13 @@ def test__all__(self): not key.startswith("_") # exclude imported stuff and getattr(member, "__module__", "telegram.constants") == "telegram.constants" - and key != "sys" + and key not in ("sys", "dtm", "UTC") ) } actual = set(constants.__all__) - assert ( - actual == expected - ), f"Members {expected - actual} were not listed in constants.__all__" + assert actual == expected, ( + f"Members {expected - actual} were not listed in constants.__all__" + ) def test_message_attachment_type(self): assert all( @@ -63,6 +74,7 @@ def test_message_attachment_type(self): def test_to_json(self): assert json.dumps(StrEnumTest.FOO) == json.dumps("foo") assert json.dumps(IntEnumTest.FOO) == json.dumps(1) + assert json.dumps(FloatEnumTest.FOO) == json.dumps(1.1) def test_string_representation(self): # test __repr__ @@ -84,6 +96,15 @@ def test_int_representation(self): # test __str__ assert str(IntEnumTest.FOO) == "1" + def test_float_representation(self): + # test __repr__ + assert repr(FloatEnumTest.FOO) == "" + # test __format__ + assert f"{FloatEnumTest.FOO}/0 is undefined!" == "1.1/0 is undefined!" + assert f"{FloatEnumTest.FOO:*^10}" == "***1.1****" + # test __str__ + assert str(FloatEnumTest.FOO) == "1.1" + def test_string_inheritance(self): assert isinstance(StrEnumTest.FOO, str) assert StrEnumTest.FOO + StrEnumTest.BAR == "foobar" @@ -109,6 +130,18 @@ def test_int_inheritance(self): assert hash(IntEnumTest.FOO) == hash(1) + def test_float_inheritance(self): + assert isinstance(FloatEnumTest.FOO, float) + assert FloatEnumTest.FOO + FloatEnumTest.BAR == 3.2 + + assert FloatEnumTest.FOO == FloatEnumTest.FOO + assert FloatEnumTest.FOO == 1.1 + assert FloatEnumTest.FOO != FloatEnumTest.BAR + assert FloatEnumTest.FOO != 2.1 + assert object() != FloatEnumTest.FOO + + assert hash(FloatEnumTest.FOO) == hash(1.1) + def test_bot_api_version_and_info(self): assert str(constants.BOT_API_VERSION_INFO) == constants.BOT_API_VERSION assert ( @@ -128,6 +161,99 @@ def test_bot_api_version_info(self): assert vi[0] == vi.major assert vi[1] == vi.minor + @staticmethod + def is_type_attribute(name: str) -> bool: + # Return False if the attribute doesn't generate a message type, i.e. only message + # metadata. Manually excluding a lot of attributes here is a bit of work, but it makes + # sure that we don't miss any new message types in the future. + patters = { + "(text|caption)_(markdown|html)", + "caption_(entities|html|markdown)", + "(edit_)?date", + "forward_", + "has_", + } + + if any(re.match(pattern, name) for pattern in patters): + return False + return name not in { + "author_signature", + "api_kwargs", + "caption", + "chat", + "chat_id", + "direct_messages_topic", + "effective_attachment", + "entities", + "from_user", + "id", + "is_automatic_forward", + "is_topic_message", + "link", + "link_preview_options", + "media_group_id", + "message_id", + "message_thread_id", + "migrate_from_chat_id", + "reply_markup", + "reply_to_message", + "sender_chat", + "is_accessible", + "quote", + "external_reply", + "via_bot", + "is_from_offline", + "show_caption_above_media", + "paid_star_count", + "is_paid_post", + "reply_to_checklist_task_id", + } + + @pytest.mark.parametrize( + "attribute", + [ + name + for name, _ in inspect.getmembers( + make_message("test"), lambda x: not inspect.isroutine(x) + ) + ], + ) + def test_message_type_completeness(self, attribute): + if attribute.startswith("_") or not self.is_type_attribute(attribute): + return + + assert hasattr(constants.MessageType, attribute.upper()), ( + f"Missing MessageType.{attribute}. Please also check if this should be present in " + f"MessageAttachmentType." + ) + + @pytest.mark.parametrize("member", constants.MessageType) + def test_message_type_completeness_reverse(self, member): + assert self.is_type_attribute(member.value), ( + f"Additional member {member} in MessageType that should not be a message type" + ) + + @pytest.mark.parametrize("member", constants.MessageAttachmentType) + def test_message_attachment_type_completeness(self, member): + try: + constants.MessageType(member) + except ValueError: + pytest.fail(f"Missing MessageType for {member}") + + def test_message_attachment_type_completeness_reverse(self): + # Getting the type hints of a property is a bit tricky, so we instead parse the docstring + # for now + for match in re.finditer(r"`telegram.(\w+)`", Message.effective_attachment.__doc__): + 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: + pytest.fail(f"Missing MessageAttachmentType for {match.group(1)}") + class TestConstantsWithRequest: async def test_max_message_length(self, bot, chat_id): @@ -139,6 +265,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) @@ -152,6 +283,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_copytextbutton.py b/tests/test_copytextbutton.py new file mode 100644 index 00000000000..537a302e694 --- /dev/null +++ b/tests/test_copytextbutton.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 BotCommand, CopyTextButton +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def copy_text_button(): + return CopyTextButton(text=CopyTextButtonTestBase.text) + + +class CopyTextButtonTestBase: + text = "This is some text" + + +class TestCopyTextButtonWithoutRequest(CopyTextButtonTestBase): + def test_slot_behaviour(self, copy_text_button): + for attr in copy_text_button.__slots__: + assert getattr(copy_text_button, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(copy_text_button)) == len(set(mro_slots(copy_text_button))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = {"text": self.text} + copy_text_button = CopyTextButton.de_json(json_dict, offline_bot) + assert copy_text_button.api_kwargs == {} + + assert copy_text_button.text == self.text + + def test_to_dict(self, copy_text_button): + copy_text_button_dict = copy_text_button.to_dict() + + assert isinstance(copy_text_button_dict, dict) + assert copy_text_button_dict["text"] == copy_text_button.text + + def test_equality(self): + a = CopyTextButton(self.text) + b = CopyTextButton(self.text) + c = CopyTextButton("text") + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_dice.py b/tests/test_dice.py index 11277912861..bab02522f9f 100644 --- a/tests/test_dice.py +++ b/tests/test_dice.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -28,25 +28,24 @@ def dice(request): return Dice(value=5, emoji=request.param) -class TestDiceBase: +class DiceTestBase: value = 4 -class TestDiceWithoutRequest(TestDiceBase): +class TestDiceWithoutRequest(DiceTestBase): def test_slot_behaviour(self, dice): for attr in dice.__slots__: assert getattr(dice, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(dice)) == len(set(mro_slots(dice))), "duplicate slot" @pytest.mark.parametrize("emoji", Dice.ALL_EMOJI) - def test_de_json(self, bot, emoji): + def test_de_json(self, offline_bot, emoji): json_dict = {"value": self.value, "emoji": emoji} - dice = Dice.de_json(json_dict, bot) + dice = Dice.de_json(json_dict, offline_bot) assert dice.api_kwargs == {} assert dice.value == self.value assert dice.emoji == emoji - assert Dice.de_json(None, bot) is None def test_to_dict(self, dice): dice_dict = dice.to_dict() diff --git a/tests/test_directmessagepricechanged.py b/tests/test_directmessagepricechanged.py new file mode 100644 index 00000000000..58dd722b4b9 --- /dev/null +++ b/tests/test_directmessagepricechanged.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 for testing a Direct Message Price.""" + +from typing import TYPE_CHECKING + +import pytest + +from telegram import DirectMessagePriceChanged, User +from tests.auxil.slots import mro_slots + +if TYPE_CHECKING: + from telegram._utils.types import JSONDict + + +@pytest.fixture +def direct_message_price_changed(): + return DirectMessagePriceChanged( + are_direct_messages_enabled=DirectMessagePriceChangedTestBase.are_direct_messages_enabled, + direct_message_star_count=DirectMessagePriceChangedTestBase.direct_message_star_count, + ) + + +class DirectMessagePriceChangedTestBase: + are_direct_messages_enabled: bool = True + direct_message_star_count: int = 100 + + +class TestDirectMessagePriceChangedWithoutRequest(DirectMessagePriceChangedTestBase): + def test_slot_behaviour(self, direct_message_price_changed): + action = direct_message_price_changed + for attr in action.__slots__: + assert getattr(action, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict: JSONDict = { + "are_direct_messages_enabled": self.are_direct_messages_enabled, + "direct_message_star_count": self.direct_message_star_count, + } + dmpc = DirectMessagePriceChanged.de_json(json_dict, offline_bot) + assert dmpc.api_kwargs == {} + + assert dmpc.are_direct_messages_enabled == self.are_direct_messages_enabled + assert dmpc.direct_message_star_count == self.direct_message_star_count + + def test_to_dict(self, direct_message_price_changed): + dmpc_dict = direct_message_price_changed.to_dict() + assert dmpc_dict["are_direct_messages_enabled"] == self.are_direct_messages_enabled + assert dmpc_dict["direct_message_star_count"] == self.direct_message_star_count + + def test_equality(self, direct_message_price_changed): + dmpc1 = direct_message_price_changed + dmpc2 = DirectMessagePriceChanged( + are_direct_messages_enabled=self.are_direct_messages_enabled, + direct_message_star_count=self.direct_message_star_count, + ) + assert dmpc1 == dmpc2 + assert hash(dmpc1) == hash(dmpc2) + + dmpc3 = DirectMessagePriceChanged( + are_direct_messages_enabled=False, + direct_message_star_count=self.direct_message_star_count, + ) + assert dmpc1 != dmpc3 + assert hash(dmpc1) != hash(dmpc3) + + not_a_dmpc = User(id=1, first_name="wrong", is_bot=False) + assert dmpc1 != not_a_dmpc + assert hash(dmpc1) != hash(not_a_dmpc) diff --git a/tests/test_directmessagestopic.py b/tests/test_directmessagestopic.py new file mode 100644 index 00000000000..53161478b5c --- /dev/null +++ b/tests/test_directmessagestopic.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 the TestDirectMessagesTopic class.""" + +import pytest + +from telegram import DirectMessagesTopic, User +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def direct_messages_topic(offline_bot): + dmt = DirectMessagesTopic( + topic_id=DirectMessagesTopicTestBase.topic_id, + user=DirectMessagesTopicTestBase.user, + ) + dmt.set_bot(offline_bot) + dmt._unfreeze() + return dmt + + +class DirectMessagesTopicTestBase: + topic_id = 12345 + user = User(id=67890, is_bot=False, first_name="Test") + + +class TestDirectMessagesTopicWithoutRequest(DirectMessagesTopicTestBase): + def test_slot_behaviour(self, direct_messages_topic): + cfi = direct_messages_topic + for attr in cfi.__slots__: + assert getattr(cfi, attr, "err") != "err", f"got extra slot '{attr}'" + + assert len(mro_slots(cfi)) == len(set(mro_slots(cfi))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "topic_id": self.topic_id, + "user": self.user.to_dict(), + } + + dmt = DirectMessagesTopic.de_json(json_dict, offline_bot) + assert dmt.topic_id == self.topic_id + assert dmt.user == self.user + assert dmt.api_kwargs == {} + + def test_to_dict(self, direct_messages_topic): + dmt = direct_messages_topic + dmt_dict = dmt.to_dict() + + assert isinstance(dmt_dict, dict) + assert dmt_dict["topic_id"] == dmt.topic_id + assert dmt_dict["user"] == dmt.user.to_dict() + + def test_equality(self, direct_messages_topic): + dmt_1 = direct_messages_topic + dmt_2 = DirectMessagesTopic( + topic_id=dmt_1.topic_id, + user=dmt_1.user, + ) + assert dmt_1 == dmt_2 + assert hash(dmt_1) == hash(dmt_2) + + random = User(id=99999, is_bot=False, first_name="Random") + assert random != dmt_2 + assert hash(random) != hash(dmt_2) + + dmt_3 = DirectMessagesTopic( + topic_id=8371, + user=dmt_1.user, + ) + assert dmt_1 != dmt_3 + assert hash(dmt_1) != hash(dmt_3) diff --git a/tests/test_enum_types.py b/tests/test_enum_types.py new file mode 100644 index 00000000000..aa9f4c17696 --- /dev/null +++ b/tests/test_enum_types.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 re +from pathlib import Path + +from telegram._utils.strings import TextEncoding + +telegram_root = Path(__file__).parent.parent / "telegram" +telegram_ext_root = telegram_root / "ext" +exclude_dirs = { + # We touch passport stuff only if strictly necessary. + telegram_root / "_passport", +} + +exclude_patterns = { + re.compile(re.escape("self.type: ReactionType = type")), + re.compile(re.escape("self.type: BackgroundType = type")), + re.compile(re.escape("self.type: StoryAreaType = type")), +} + + +def test_types_are_converted_to_enum(): + """We want to convert all attributes of name "type" to an enum from telegram.constants. + Since we don't necessarily document this as type hint, we simply check this with a regex. + """ + pattern = re.compile(r"self\.type: [^=]+ = ([^\n]+)\n", re.MULTILINE) + + for path in telegram_root.rglob("*.py"): + if telegram_ext_root in path.parents or any( + exclude_dir in path.parents for exclude_dir in exclude_dirs + ): + # We don't check tg.ext. + continue + + text = path.read_text(encoding=TextEncoding.UTF_8) + for match in re.finditer(pattern, text): + if any(exclude_pattern.match(match.group(0)) for exclude_pattern in exclude_patterns): + continue + + assert match.group(1).startswith("enum.get_member") or match.group(1).startswith( + "get_member" + ), ( + f"`{match.group(1)}` in `{path}` does not seem to convert the type to an enum. " + f"Please fix this and also make sure to add a separate test to the classes test " + f"file." + ) diff --git a/tests/test_error.py b/tests/test_error.py index 757b1704153..a6eadc0e2f1 100644 --- a/tests/test_error.py +++ b/tests/test_error.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,6 +16,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/]. +import datetime as dtm import pickle from collections import defaultdict @@ -25,6 +26,7 @@ BadRequest, ChatMigrated, Conflict, + EndPointNotFound, Forbidden, InvalidToken, NetworkError, @@ -34,28 +36,29 @@ TimedOut, ) from telegram.ext import InvalidCallbackData +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots class TestErrors: def test_telegram_error(self): - with pytest.raises(TelegramError, match="^test message$"): + with pytest.raises(TelegramError, match=r"^test message$"): raise TelegramError("test message") - with pytest.raises(TelegramError, match="^Test message$"): + with pytest.raises(TelegramError, match=r"^Test message$"): raise TelegramError("Error: test message") - with pytest.raises(TelegramError, match="^Test message$"): + with pytest.raises(TelegramError, match=r"^Test message$"): raise TelegramError("[Error]: test message") - with pytest.raises(TelegramError, match="^Test message$"): + with pytest.raises(TelegramError, match=r"^Test message$"): raise TelegramError("Bad Request: test message") def test_unauthorized(self): with pytest.raises(Forbidden, match="test message"): raise Forbidden("test message") - with pytest.raises(Forbidden, match="^Test message$"): + with pytest.raises(Forbidden, match=r"^Test message$"): raise Forbidden("Error: test message") - with pytest.raises(Forbidden, match="^Test message$"): + with pytest.raises(Forbidden, match=r"^Test message$"): raise Forbidden("[Error]: test message") - with pytest.raises(Forbidden, match="^Test message$"): + with pytest.raises(Forbidden, match=r"^Test message$"): raise Forbidden("Bad Request: test message") def test_invalid_token(self): @@ -65,25 +68,25 @@ def test_invalid_token(self): def test_network_error(self): with pytest.raises(NetworkError, match="test message"): raise NetworkError("test message") - with pytest.raises(NetworkError, match="^Test message$"): + with pytest.raises(NetworkError, match=r"^Test message$"): raise NetworkError("Error: test message") - with pytest.raises(NetworkError, match="^Test message$"): + with pytest.raises(NetworkError, match=r"^Test message$"): raise NetworkError("[Error]: test message") - with pytest.raises(NetworkError, match="^Test message$"): + with pytest.raises(NetworkError, match=r"^Test message$"): raise NetworkError("Bad Request: test message") def test_bad_request(self): with pytest.raises(BadRequest, match="test message"): raise BadRequest("test message") - with pytest.raises(BadRequest, match="^Test message$"): + with pytest.raises(BadRequest, match=r"^Test message$"): raise BadRequest("Error: test message") - with pytest.raises(BadRequest, match="^Test message$"): + with pytest.raises(BadRequest, match=r"^Test message$"): raise BadRequest("[Error]: test message") - with pytest.raises(BadRequest, match="^Test message$"): + with pytest.raises(BadRequest, match=r"^Test message$"): raise BadRequest("Bad Request: test message") def test_timed_out(self): - with pytest.raises(TimedOut, match="^Timed out$"): + with pytest.raises(TimedOut, match=r"^Timed out$"): raise TimedOut def test_chat_migrated(self): @@ -91,12 +94,31 @@ def test_chat_migrated(self): raise ChatMigrated(1234) assert e.value.new_chat_id == 1234 - def test_retry_after(self): - with pytest.raises(RetryAfter, match="Flood control exceeded. Retry in 12 seconds"): - raise RetryAfter(12) + @pytest.mark.parametrize("retry_after", [12, dtm.timedelta(seconds=12)]) + def test_retry_after(self, PTB_TIMEDELTA, retry_after): + if PTB_TIMEDELTA: + with pytest.raises(RetryAfter, match="Flood control exceeded\\. Retry in 0:00:12"): + raise (exception := RetryAfter(retry_after)) + assert type(exception.retry_after) is dtm.timedelta + else: + with pytest.raises(RetryAfter, match="Flood control exceeded\\. Retry in 12 seconds"): + raise (exception := RetryAfter(retry_after)) + assert type(exception.retry_after) is int + + def test_retry_after_int_deprecated(self, PTB_TIMEDELTA, recwarn): + retry_after = RetryAfter(12).retry_after + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + assert type(retry_after) is dtm.timedelta + else: + assert len(recwarn) == 1 + assert "`retry_after` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + assert type(retry_after) is int def test_conflict(self): - with pytest.raises(Conflict, match="Something something."): + with pytest.raises(Conflict, match="Something something\\."): raise Conflict("Something something.") @pytest.mark.parametrize( @@ -110,9 +132,11 @@ def test_conflict(self): (TimedOut(), ["message"]), (ChatMigrated(1234), ["message", "new_chat_id"]), (RetryAfter(12), ["message", "retry_after"]), + (RetryAfter(dtm.timedelta(seconds=12)), ["message", "retry_after"]), (Conflict("test message"), ["message"]), (PassportDecryptionError("test message"), ["message"]), (InvalidCallbackData("test data"), ["callback_data"]), + (EndPointNotFound("endPoint"), ["message"]), ], ) def test_errors_pickling(self, exception, attributes): @@ -134,10 +158,11 @@ def test_errors_pickling(self, exception, attributes): (BadRequest("test message")), (TimedOut()), (ChatMigrated(1234)), - (RetryAfter(12)), + (RetryAfter(dtm.timedelta(seconds=12))), (Conflict("test message")), (PassportDecryptionError("test message")), (InvalidCallbackData("test data")), + (EndPointNotFound("test message")), ], ) def test_slot_behaviour(self, inst): @@ -170,6 +195,7 @@ def make_assertion(cls): Conflict, PassportDecryptionError, InvalidCallbackData, + EndPointNotFound, }, NetworkError: {BadRequest, TimedOut}, } @@ -177,15 +203,19 @@ def make_assertion(cls): make_assertion(TelegramError) - def test_string_representations(self): + def test_string_representations(self, PTB_TIMEDELTA): """We just randomly test a few of the subclasses - should suffice""" e = TelegramError("This is a message") assert repr(e) == "TelegramError('This is a message')" assert str(e) == "This is a message" - e = RetryAfter(42) - assert repr(e) == "RetryAfter('Flood control exceeded. Retry in 42 seconds')" - assert str(e) == "Flood control exceeded. Retry in 42 seconds" + e = RetryAfter(dtm.timedelta(seconds=42)) + if PTB_TIMEDELTA: + assert repr(e) == "RetryAfter('Flood control exceeded. Retry in 0:00:42')" + assert str(e) == "Flood control exceeded. Retry in 0:00:42" + else: + assert repr(e) == "RetryAfter('Flood control exceeded. Retry in 42 seconds')" + assert str(e) == "Flood control exceeded. Retry in 42 seconds" e = BadRequest("This is a message") assert repr(e) == "BadRequest('This is a message')" diff --git a/tests/test_forcereply.py b/tests/test_forcereply.py index eac3c70c7d8..cfbe51cce34 100644 --- a/tests/test_forcereply.py +++ b/tests/test_forcereply.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,16 +25,16 @@ @pytest.fixture(scope="module") def force_reply(): - return ForceReply(TestForceReplyBase.selective, TestForceReplyBase.input_field_placeholder) + return ForceReply(ForceReplyTestBase.selective, ForceReplyTestBase.input_field_placeholder) -class TestForceReplyBase: +class ForceReplyTestBase: force_reply = True selective = True input_field_placeholder = "force replies can be annoying if not used properly" -class TestForceReplyWithoutRequest(TestForceReplyBase): +class TestForceReplyWithoutRequest(ForceReplyTestBase): def test_slot_behaviour(self, force_reply): for attr in force_reply.__slots__: assert getattr(force_reply, attr, "err") != "err", f"got extra slot '{attr}'" @@ -69,7 +69,7 @@ def test_equality(self): assert hash(a) != hash(d) -class TestForceReplyWithRequest(TestForceReplyBase): +class TestForceReplyWithRequest(ForceReplyTestBase): async def test_send_message_with_force_reply(self, bot, chat_id, force_reply): message = await bot.send_message(chat_id, "text", reply_markup=force_reply) assert message.text == "text" diff --git a/tests/test_forum.py b/tests/test_forum.py index 961a7eb9075..73d20c6d786 100644 --- a/tests/test_forum.py +++ b/tests/test_forum.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -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/]. import asyncio -import datetime +import datetime as dtm import pytest @@ -32,48 +32,28 @@ Sticker, ) from telegram.error import BadRequest +from tests.auxil.constants import TEST_MSG_TEXT, TEST_TOPIC_ICON_COLOR, TEST_TOPIC_NAME from tests.auxil.slots import mro_slots -TEST_MSG_TEXT = "Topics are forever" -TEST_TOPIC_ICON_COLOR = 0x6FB9F0 -TEST_TOPIC_NAME = "Sad bot true: real stories" - - -@pytest.fixture(scope="module") -async def emoji_id(bot): - emoji_sticker_list = await bot.get_forum_topic_icon_stickers() - first_sticker = emoji_sticker_list[0] - return first_sticker.custom_emoji_id - @pytest.fixture(scope="module") async def forum_topic_object(forum_group_id, emoji_id): return ForumTopic( message_thread_id=forum_group_id, - name=TEST_TOPIC_NAME, - icon_color=TEST_TOPIC_ICON_COLOR, + name=ForumTopicTestBase.TEST_TOPIC_NAME, + icon_color=ForumTopicTestBase.TEST_TOPIC_ICON_COLOR, icon_custom_emoji_id=emoji_id, + is_name_implicit=ForumTopicTestBase.is_name_implicit, ) -@pytest.fixture() -async def real_topic(bot, emoji_id, forum_group_id): - result = await bot.create_forum_topic( - chat_id=forum_group_id, - name=TEST_TOPIC_NAME, - icon_color=TEST_TOPIC_ICON_COLOR, - icon_custom_emoji_id=emoji_id, - ) - - yield result - - result = await bot.delete_forum_topic( - chat_id=forum_group_id, message_thread_id=result.message_thread_id - ) - assert result is True, "Topic was not deleted" +class ForumTopicTestBase: + TEST_TOPIC_NAME = TEST_TOPIC_NAME + TEST_TOPIC_ICON_COLOR = TEST_TOPIC_ICON_COLOR + is_name_implicit = False -class TestForumTopicWithoutRequest: +class TestForumTopicWithoutRequest(ForumTopicTestBase): def test_slot_behaviour(self, forum_topic_object): inst = forum_topic_object for attr in inst.__slots__: @@ -82,35 +62,37 @@ def test_slot_behaviour(self, forum_topic_object): async def test_expected_values(self, emoji_id, forum_group_id, forum_topic_object): assert forum_topic_object.message_thread_id == forum_group_id - assert forum_topic_object.icon_color == TEST_TOPIC_ICON_COLOR - assert forum_topic_object.name == TEST_TOPIC_NAME + assert forum_topic_object.icon_color == self.TEST_TOPIC_ICON_COLOR + assert forum_topic_object.name == self.TEST_TOPIC_NAME assert forum_topic_object.icon_custom_emoji_id == emoji_id + assert forum_topic_object.is_name_implicit == self.is_name_implicit - def test_de_json(self, bot, emoji_id, forum_group_id): - assert ForumTopic.de_json(None, bot=bot) is None - + def test_de_json(self, offline_bot, emoji_id, forum_group_id): json_dict = { "message_thread_id": forum_group_id, - "name": TEST_TOPIC_NAME, - "icon_color": TEST_TOPIC_ICON_COLOR, + "name": self.TEST_TOPIC_NAME, + "icon_color": self.TEST_TOPIC_ICON_COLOR, "icon_custom_emoji_id": emoji_id, + "is_name_implicit": self.is_name_implicit, } - topic = ForumTopic.de_json(json_dict, bot) + topic = ForumTopic.de_json(json_dict, offline_bot) assert topic.api_kwargs == {} assert topic.message_thread_id == forum_group_id - assert topic.icon_color == TEST_TOPIC_ICON_COLOR - assert topic.name == TEST_TOPIC_NAME + assert topic.icon_color == self.TEST_TOPIC_ICON_COLOR + assert topic.name == self.TEST_TOPIC_NAME assert topic.icon_custom_emoji_id == emoji_id + assert topic.is_name_implicit == self.is_name_implicit def test_to_dict(self, emoji_id, forum_group_id, forum_topic_object): topic_dict = forum_topic_object.to_dict() assert isinstance(topic_dict, dict) assert topic_dict["message_thread_id"] == forum_group_id - assert topic_dict["name"] == TEST_TOPIC_NAME - assert topic_dict["icon_color"] == TEST_TOPIC_ICON_COLOR + assert topic_dict["name"] == self.TEST_TOPIC_NAME + assert topic_dict["icon_color"] == self.TEST_TOPIC_ICON_COLOR assert topic_dict["icon_custom_emoji_id"] == emoji_id + assert topic_dict["is_name_implicit"] == self.is_name_implicit def test_equality(self, emoji_id, forum_group_id): a = ForumTopic( @@ -186,15 +168,15 @@ async def test_get_forum_topic_icon_stickers(self, bot): assert not first_sticker.is_video assert first_sticker.set_name == "Topics" assert first_sticker.type == Sticker.CUSTOM_EMOJI - assert first_sticker.thumb.width == 128 - assert first_sticker.thumb.height == 128 + assert first_sticker.thumbnail.width == 128 + assert first_sticker.thumbnail.height == 128 # The following data of first item returned has changed in the past already, # so check sizes loosely and ID's only by length of string - assert first_sticker.thumb.file_size in range(2000, 7000) + assert first_sticker.thumbnail.file_size in range(2000, 7000) assert first_sticker.file_size in range(20000, 70000) assert len(first_sticker.custom_emoji_id) == 19 - assert len(first_sticker.thumb.file_unique_id) == 16 + assert len(first_sticker.thumbnail.file_unique_id) == 16 assert len(first_sticker.file_unique_id) == 15 async def test_edit_forum_topic(self, emoji_id, forum_group_id, bot, real_topic): @@ -236,6 +218,7 @@ async def test_close_and_reopen_forum_topic(self, bot, forum_group_id, real_topi assert result is True, "Failed to reopen forum topic" async def test_unpin_all_forum_topic_messages(self, bot, forum_group_id, real_topic): + # We need 2 or more pinned msgs for this to work, else we get Chat_not_modified error message_thread_id = real_topic.message_thread_id pin_msg_tasks = set() @@ -249,14 +232,27 @@ async def test_unpin_all_forum_topic_messages(self, bot, forum_group_id, real_to assert all([await task for task in pin_msg_tasks]) is True, "Message(s) were not pinned" - # We need 2 or more pinned msgs for this to work, else we get Chat_not_modified error result = await bot.unpin_all_forum_topic_messages(forum_group_id, message_thread_id) assert result is True, "Failed to unpin all the messages in forum topic" + async def test_unpin_all_general_forum_topic_messages(self, bot, forum_group_id): + # We need 2 or more pinned msgs for this to work, else we get Chat_not_modified error + pin_msg_tasks = set() + + awaitables = {bot.send_message(forum_group_id, TEST_MSG_TEXT) for _ in range(2)} + for coro in asyncio.as_completed(awaitables): + msg = await coro + pin_msg_tasks.add(asyncio.create_task(msg.pin())) + + assert all([await task for task in pin_msg_tasks]) is True, "Message(s) were not pinned" + + result = await bot.unpin_all_general_forum_topic_messages(forum_group_id) + assert result is True, "Failed to unpin all the messages in forum topic" + async def test_edit_general_forum_topic(self, bot, forum_group_id): result = await bot.edit_general_forum_topic( chat_id=forum_group_id, - name=f"GENERAL_{datetime.datetime.now().timestamp()}", + name=f"GENERAL_{dtm.datetime.now().timestamp()}", ) assert result is True, "Failed to edit general forum topic" # no way of checking the edited name, just the boolean result @@ -304,37 +300,52 @@ async def test_close_reopen_hide_unhide_general_forum_topic(self, bot, forum_gro @pytest.fixture(scope="module") def topic_created(): - return ForumTopicCreated(name=TEST_TOPIC_NAME, icon_color=TEST_TOPIC_ICON_COLOR) + return ForumTopicCreated( + name=ForumTopicCreatedTestBase.TEST_TOPIC_NAME, + icon_color=ForumTopicCreatedTestBase.TEST_TOPIC_ICON_COLOR, + is_name_implicit=ForumTopicCreatedTestBase.is_name_implicit, + ) + +class ForumTopicCreatedTestBase: + TEST_TOPIC_NAME = TEST_TOPIC_NAME + TEST_TOPIC_ICON_COLOR = TEST_TOPIC_ICON_COLOR + is_name_implicit = False -class TestForumTopicCreatedWithoutRequest: + +class TestForumTopicCreatedWithoutRequest(ForumTopicCreatedTestBase): def test_slot_behaviour(self, topic_created): for attr in topic_created.__slots__: assert getattr(topic_created, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(topic_created)) == len( - set(mro_slots(topic_created)) - ), "duplicate slot" + assert len(mro_slots(topic_created)) == len(set(mro_slots(topic_created))), ( + "duplicate slot" + ) def test_expected_values(self, topic_created): - assert topic_created.icon_color == TEST_TOPIC_ICON_COLOR - assert topic_created.name == TEST_TOPIC_NAME + assert topic_created.icon_color == self.TEST_TOPIC_ICON_COLOR + assert topic_created.name == self.TEST_TOPIC_NAME + assert topic_created.is_name_implicit == self.is_name_implicit - def test_de_json(self, bot): - assert ForumTopicCreated.de_json(None, bot=bot) is None - - json_dict = {"icon_color": TEST_TOPIC_ICON_COLOR, "name": TEST_TOPIC_NAME} - action = ForumTopicCreated.de_json(json_dict, bot) + def test_de_json(self, offline_bot): + json_dict = { + "icon_color": self.TEST_TOPIC_ICON_COLOR, + "name": self.TEST_TOPIC_NAME, + "is_name_implicit": self.is_name_implicit, + } + action = ForumTopicCreated.de_json(json_dict, offline_bot) assert action.api_kwargs == {} - assert action.icon_color == TEST_TOPIC_ICON_COLOR - assert action.name == TEST_TOPIC_NAME + assert action.icon_color == self.TEST_TOPIC_ICON_COLOR + assert action.name == self.TEST_TOPIC_NAME + assert action.is_name_implicit == self.is_name_implicit def test_to_dict(self, topic_created): action_dict = topic_created.to_dict() assert isinstance(action_dict, dict) - assert action_dict["name"] == TEST_TOPIC_NAME - assert action_dict["icon_color"] == TEST_TOPIC_ICON_COLOR + assert action_dict["name"] == self.TEST_TOPIC_NAME + assert action_dict["icon_color"] == self.TEST_TOPIC_ICON_COLOR + assert action_dict["is_name_implicit"] == self.is_name_implicit def test_equality(self, emoji_id): a = ForumTopicCreated(name=TEST_TOPIC_NAME, icon_color=TEST_TOPIC_ICON_COLOR) @@ -408,8 +419,6 @@ def test_expected_values(self, topic_edited, emoji_id): assert topic_edited.icon_custom_emoji_id == emoji_id def test_de_json(self, bot, emoji_id): - assert ForumTopicEdited.de_json(None, bot=bot) is None - json_dict = {"name": TEST_TOPIC_NAME, "icon_custom_emoji_id": emoji_id} action = ForumTopicEdited.de_json(json_dict, bot) assert action.api_kwargs == {} diff --git a/tests/test_gifts.py b/tests/test_gifts.py new file mode 100644 index 00000000000..c09d1b30a6e --- /dev/null +++ b/tests/test_gifts.py @@ -0,0 +1,607 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 collections.abc import Sequence + +import pytest + +from telegram import BotCommand, Chat, Gift, GiftInfo, Gifts, MessageEntity, Sticker +from telegram._gifts import AcceptedGiftTypes, GiftBackground +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram.request import RequestData +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def gift_background(): + return GiftBackground( + center_color=GiftBackgroundTestBase.center_color, + edge_color=GiftBackgroundTestBase.edge_color, + text_color=GiftBackgroundTestBase.text_color, + ) + + +class GiftBackgroundTestBase: + center_color = 0xFFFFFF + edge_color = 0x000000 + text_color = 0xFF0000 + + +class TestGiftBackgroundWithoutRequest(GiftBackgroundTestBase): + def test_slot_behaviour(self, gift_background): + for attr in gift_background.__slots__: + assert getattr(gift_background, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(gift_background)) == len(set(mro_slots(gift_background))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "center_color": self.center_color, + "edge_color": self.edge_color, + "text_color": self.text_color, + } + gift_background = GiftBackground.de_json(json_dict, offline_bot) + assert gift_background.api_kwargs == {} + assert gift_background.center_color == self.center_color + assert gift_background.edge_color == self.edge_color + assert gift_background.text_color == self.text_color + + def test_to_dict(self, gift_background): + json_dict = gift_background.to_dict() + assert json_dict["center_color"] == self.center_color + assert json_dict["edge_color"] == self.edge_color + assert json_dict["text_color"] == self.text_color + + def test_equality(self, gift_background): + a = gift_background + b = GiftBackground( + self.center_color, + self.edge_color, + self.text_color, + ) + c = GiftBackground( + 0x000000, + self.edge_color, + self.text_color, + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def gift(request): + return Gift( + id=GiftTestBase.id, + sticker=GiftTestBase.sticker, + star_count=GiftTestBase.star_count, + total_count=GiftTestBase.total_count, + remaining_count=GiftTestBase.remaining_count, + upgrade_star_count=GiftTestBase.upgrade_star_count, + publisher_chat=GiftTestBase.publisher_chat, + personal_total_count=GiftTestBase.personal_total_count, + personal_remaining_count=GiftTestBase.personal_remaining_count, + background=GiftTestBase.background, + is_premium=GiftTestBase.is_premium, + has_colors=GiftTestBase.has_colors, + unique_gift_variant_count=GiftTestBase.unique_gift_variant_count, + ) + + +class GiftTestBase: + id = "some_id" + sticker = Sticker( + file_id="file_id", + file_unique_id="file_unique_id", + width=512, + height=512, + is_animated=False, + is_video=False, + type="regular", + ) + star_count = 5 + total_count = 10 + remaining_count = 5 + upgrade_star_count = 10 + publisher_chat = Chat(1, Chat.PRIVATE) + personal_total_count = 37 + personal_remaining_count = 23 + background = GiftBackground(0xFFFFFF, 0x000000, 0xFF0000) + is_premium = True + has_colors = True + unique_gift_variant_count = 42 + + +class TestGiftWithoutRequest(GiftTestBase): + def test_slot_behaviour(self, gift): + for attr in gift.__slots__: + assert getattr(gift, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(gift)) == len(set(mro_slots(gift))), "duplicate slot" + + def test_de_json(self, offline_bot, gift): + json_dict = { + "id": self.id, + "sticker": self.sticker.to_dict(), + "star_count": self.star_count, + "total_count": self.total_count, + "remaining_count": self.remaining_count, + "upgrade_star_count": self.upgrade_star_count, + "publisher_chat": self.publisher_chat.to_dict(), + "personal_total_count": self.personal_total_count, + "personal_remaining_count": self.personal_remaining_count, + "background": self.background.to_dict(), + "is_premium": self.is_premium, + "has_colors": self.has_colors, + "unique_gift_variant_count": self.unique_gift_variant_count, + } + gift = Gift.de_json(json_dict, offline_bot) + assert gift.api_kwargs == {} + + assert gift.id == self.id + assert gift.sticker == self.sticker + assert gift.star_count == self.star_count + assert gift.total_count == self.total_count + assert gift.remaining_count == self.remaining_count + assert gift.upgrade_star_count == self.upgrade_star_count + assert gift.publisher_chat == self.publisher_chat + assert gift.personal_total_count == self.personal_total_count + assert gift.personal_remaining_count == self.personal_remaining_count + assert gift.background == self.background + assert gift.is_premium == self.is_premium + assert gift.has_colors == self.has_colors + assert gift.unique_gift_variant_count == self.unique_gift_variant_count + + def test_to_dict(self, gift): + gift_dict = gift.to_dict() + + assert isinstance(gift_dict, dict) + assert gift_dict["id"] == self.id + assert gift_dict["sticker"] == self.sticker.to_dict() + assert gift_dict["star_count"] == self.star_count + assert gift_dict["total_count"] == self.total_count + assert gift_dict["remaining_count"] == self.remaining_count + assert gift_dict["upgrade_star_count"] == self.upgrade_star_count + assert gift_dict["publisher_chat"] == self.publisher_chat.to_dict() + assert gift_dict["personal_total_count"] == self.personal_total_count + assert gift_dict["personal_remaining_count"] == self.personal_remaining_count + assert gift_dict["background"] == self.background.to_dict() + assert gift_dict["is_premium"] == self.is_premium + assert gift_dict["has_colors"] == self.has_colors + assert gift_dict["unique_gift_variant_count"] == self.unique_gift_variant_count + + def test_equality(self, gift): + a = gift + b = Gift( + self.id, + self.sticker, + self.star_count, + self.total_count, + self.remaining_count, + self.upgrade_star_count, + self.publisher_chat, + ) + c = Gift( + "other_uid", + self.sticker, + self.star_count, + self.total_count, + self.remaining_count, + self.upgrade_star_count, + self.publisher_chat, + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + @pytest.mark.parametrize( + "gift", + [ + "gift_id", + Gift( + "gift_id", + Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular"), + 5, + 10, + 5, + 10, + ), + ], + ids=["string", "Gift"], + ) + @pytest.mark.parametrize("id_name", ["user_id", "chat_id"]) + async def test_send_gift(self, offline_bot, gift, monkeypatch, id_name): + # We can't send actual gifts, so we just check that the correct parameters are passed + text_entities = [ + MessageEntity(MessageEntity.TEXT_LINK, 0, 4, "url"), + MessageEntity(MessageEntity.BOLD, 5, 9), + ] + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + received_id = request_data.parameters[id_name] == id_name + gift_id = request_data.parameters["gift_id"] == "gift_id" + text = request_data.parameters["text"] == "text" + text_parse_mode = request_data.parameters["text_parse_mode"] == "text_parse_mode" + tes = request_data.parameters["text_entities"] == [ + me.to_dict() for me in text_entities + ] + pay_for_upgrade = request_data.parameters["pay_for_upgrade"] is True + + return received_id and gift_id and text and text_parse_mode and tes and pay_for_upgrade + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + assert await offline_bot.send_gift( + gift, + "text", + text_parse_mode="text_parse_mode", + text_entities=text_entities, + pay_for_upgrade=True, + **{id_name: id_name}, + ) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + @pytest.mark.parametrize( + ("passed_value", "expected_value"), + [(DEFAULT_NONE, "Markdown"), ("HTML", "HTML"), (None, None)], + ) + async def test_send_gift_default_parse_mode( + self, default_bot, monkeypatch, passed_value, expected_value + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + return request_data.parameters.get("text_parse_mode") == expected_value + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + kwargs = { + "user_id": "user_id", + "gift_id": "gift_id", + } + if passed_value is not DEFAULT_NONE: + kwargs["text_parse_mode"] = passed_value + + assert await default_bot.send_gift(**kwargs) + + +@pytest.fixture +def gifts(request): + return Gifts(gifts=GiftsTestBase.gifts) + + +class GiftsTestBase: + gifts: Sequence[Gift] = [ + Gift( + id="id1", + sticker=Sticker( + file_id="file_id", + file_unique_id="file_unique_id", + width=512, + height=512, + is_animated=False, + is_video=False, + type="regular", + ), + star_count=5, + total_count=5, + remaining_count=5, + upgrade_star_count=5, + publisher_chat=Chat(5, Chat.PRIVATE), + ), + Gift( + id="id2", + sticker=Sticker( + file_id="file_id", + file_unique_id="file_unique_id", + width=512, + height=512, + is_animated=False, + is_video=False, + type="regular", + ), + star_count=6, + total_count=6, + remaining_count=6, + upgrade_star_count=6, + publisher_chat=Chat(6, Chat.PRIVATE), + ), + Gift( + id="id3", + sticker=Sticker( + file_id="file_id", + file_unique_id="file_unique_id", + width=512, + height=512, + is_animated=False, + is_video=False, + type="regular", + ), + star_count=7, + total_count=7, + remaining_count=7, + upgrade_star_count=7, + publisher_chat=Chat(7, Chat.PRIVATE), + ), + ] + + +class TestGiftsWithoutRequest(GiftsTestBase): + def test_slot_behaviour(self, gifts): + for attr in gifts.__slots__: + assert getattr(gifts, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(gifts)) == len(set(mro_slots(gifts))), "duplicate slot" + + def test_de_json(self, offline_bot, gifts): + json_dict = {"gifts": [gift.to_dict() for gift in self.gifts]} + gifts = Gifts.de_json(json_dict, offline_bot) + assert gifts.api_kwargs == {} + + assert gifts.gifts == tuple(self.gifts) + for de_json_gift, original_gift in zip(gifts.gifts, self.gifts, strict=False): + assert de_json_gift.id == original_gift.id + assert de_json_gift.sticker == original_gift.sticker + assert de_json_gift.star_count == original_gift.star_count + assert de_json_gift.total_count == original_gift.total_count + assert de_json_gift.remaining_count == original_gift.remaining_count + assert de_json_gift.upgrade_star_count == original_gift.upgrade_star_count + assert de_json_gift.publisher_chat == original_gift.publisher_chat + + def test_to_dict(self, gifts): + gifts_dict = gifts.to_dict() + + assert isinstance(gifts_dict, dict) + assert gifts_dict["gifts"] == [gift.to_dict() for gift in self.gifts] + + def test_equality(self, gifts): + a = gifts + b = Gifts(self.gifts) + c = Gifts(self.gifts[:2]) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +class TestGiftsWithRequest(GiftTestBase): + async def test_get_available_gifts(self, bot, chat_id): + # We don't control the available gifts, so we can not make any better assertions + assert isinstance(await bot.get_available_gifts(), Gifts) + + +@pytest.fixture +def gift_info(): + return GiftInfo( + gift=GiftInfoTestBase.gift, + owned_gift_id=GiftInfoTestBase.owned_gift_id, + convert_star_count=GiftInfoTestBase.convert_star_count, + prepaid_upgrade_star_count=GiftInfoTestBase.prepaid_upgrade_star_count, + can_be_upgraded=GiftInfoTestBase.can_be_upgraded, + text=GiftInfoTestBase.text, + entities=GiftInfoTestBase.entities, + is_private=GiftInfoTestBase.is_private, + is_upgrade_separate=GiftInfoTestBase.is_upgrade_separate, + unique_gift_number=GiftInfoTestBase.unique_gift_number, + ) + + +class GiftInfoTestBase: + gift = Gift( + id="some_id", + sticker=Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular"), + star_count=5, + total_count=10, + remaining_count=15, + upgrade_star_count=20, + ) + owned_gift_id = "some_owned_gift_id" + convert_star_count = 100 + prepaid_upgrade_star_count = 200 + can_be_upgraded = True + text = "test text" + entities = ( + MessageEntity(MessageEntity.BOLD, 0, 4), + MessageEntity(MessageEntity.ITALIC, 5, 8), + ) + is_private = True + is_upgrade_separate = False + unique_gift_number = 42 + + +class TestGiftInfoWithoutRequest(GiftInfoTestBase): + def test_slot_behaviour(self, gift_info): + for attr in gift_info.__slots__: + assert getattr(gift_info, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(gift_info)) == len(set(mro_slots(gift_info))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "gift": self.gift.to_dict(), + "owned_gift_id": self.owned_gift_id, + "convert_star_count": self.convert_star_count, + "prepaid_upgrade_star_count": self.prepaid_upgrade_star_count, + "can_be_upgraded": self.can_be_upgraded, + "text": self.text, + "entities": [e.to_dict() for e in self.entities], + "is_private": self.is_private, + "is_upgrade_separate": self.is_upgrade_separate, + "unique_gift_number": self.unique_gift_number, + } + gift_info = GiftInfo.de_json(json_dict, offline_bot) + assert gift_info.api_kwargs == {} + assert gift_info.gift == self.gift + assert gift_info.owned_gift_id == self.owned_gift_id + assert gift_info.convert_star_count == self.convert_star_count + assert gift_info.prepaid_upgrade_star_count == self.prepaid_upgrade_star_count + assert gift_info.can_be_upgraded == self.can_be_upgraded + assert gift_info.text == self.text + assert gift_info.entities == self.entities + assert gift_info.is_private == self.is_private + assert gift_info.is_upgrade_separate == self.is_upgrade_separate + assert gift_info.unique_gift_number == self.unique_gift_number + + def test_to_dict(self, gift_info): + json_dict = gift_info.to_dict() + assert json_dict["gift"] == self.gift.to_dict() + assert json_dict["owned_gift_id"] == self.owned_gift_id + assert json_dict["convert_star_count"] == self.convert_star_count + assert json_dict["prepaid_upgrade_star_count"] == self.prepaid_upgrade_star_count + assert json_dict["can_be_upgraded"] == self.can_be_upgraded + assert json_dict["text"] == self.text + assert json_dict["entities"] == [e.to_dict() for e in self.entities] + assert json_dict["is_private"] == self.is_private + assert json_dict["is_upgrade_separate"] == self.is_upgrade_separate + assert json_dict["unique_gift_number"] == self.unique_gift_number + + def test_parse_entity(self, gift_info): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + + assert gift_info.parse_entity(entity) == "test" + + with pytest.raises(RuntimeError, match="GiftInfo has no"): + GiftInfo( + gift=self.gift, + ).parse_entity(entity) + + def test_parse_entities(self, gift_info): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + entity_2 = MessageEntity(MessageEntity.ITALIC, 5, 8) + + assert gift_info.parse_entities(MessageEntity.BOLD) == {entity: "test"} + assert gift_info.parse_entities() == {entity: "test", entity_2: "text"} + + with pytest.raises(RuntimeError, match="GiftInfo has no"): + GiftInfo( + gift=self.gift, + ).parse_entities() + + def test_equality(self, gift_info): + a = gift_info + b = GiftInfo(gift=self.gift) + c = GiftInfo( + gift=Gift( + id="some_other_gift_id", + sticker=Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular"), + star_count=5, + ), + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def accepted_gift_types(): + return AcceptedGiftTypes( + unlimited_gifts=AcceptedGiftTypesTestBase.unlimited_gifts, + limited_gifts=AcceptedGiftTypesTestBase.limited_gifts, + unique_gifts=AcceptedGiftTypesTestBase.unique_gifts, + premium_subscription=AcceptedGiftTypesTestBase.premium_subscription, + gifts_from_channels=AcceptedGiftTypesTestBase.gifts_from_channels, + ) + + +class AcceptedGiftTypesTestBase: + unlimited_gifts = False + limited_gifts = True + unique_gifts = True + premium_subscription = True + gifts_from_channels = False + + +class TestAcceptedGiftTypesWithoutRequest(AcceptedGiftTypesTestBase): + def test_slot_behaviour(self, accepted_gift_types): + for attr in accepted_gift_types.__slots__: + assert getattr(accepted_gift_types, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(accepted_gift_types)) == len(set(mro_slots(accepted_gift_types))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "unlimited_gifts": self.unlimited_gifts, + "limited_gifts": self.limited_gifts, + "unique_gifts": self.unique_gifts, + "premium_subscription": self.premium_subscription, + "gifts_from_channels": self.gifts_from_channels, + } + accepted_gift_types = AcceptedGiftTypes.de_json(json_dict, offline_bot) + assert accepted_gift_types.api_kwargs == {} + assert accepted_gift_types.unlimited_gifts == self.unlimited_gifts + assert accepted_gift_types.limited_gifts == self.limited_gifts + assert accepted_gift_types.unique_gifts == self.unique_gifts + assert accepted_gift_types.premium_subscription == self.premium_subscription + assert accepted_gift_types.gifts_from_channels == self.gifts_from_channels + + def test_to_dict(self, accepted_gift_types): + json_dict = accepted_gift_types.to_dict() + assert json_dict["unlimited_gifts"] == self.unlimited_gifts + assert json_dict["limited_gifts"] == self.limited_gifts + assert json_dict["unique_gifts"] == self.unique_gifts + assert json_dict["premium_subscription"] == self.premium_subscription + assert json_dict["gifts_from_channels"] == self.gifts_from_channels + + def test_equality(self, accepted_gift_types): + a = accepted_gift_types + b = AcceptedGiftTypes( + self.unlimited_gifts, + self.limited_gifts, + self.unique_gifts, + self.premium_subscription, + self.gifts_from_channels, + ) + c = AcceptedGiftTypes( + not self.unlimited_gifts, + self.limited_gifts, + self.unique_gifts, + self.premium_subscription, + self.gifts_from_channels, + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_giveaway.py b/tests/test_giveaway.py new file mode 100644 index 00000000000..1c54dd6d8f5 --- /dev/null +++ b/tests/test_giveaway.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm + +import pytest + +from telegram import ( + BotCommand, + Chat, + Giveaway, + GiveawayCompleted, + GiveawayCreated, + GiveawayWinners, + Message, + User, +) +from telegram._utils.datetime import UTC, to_timestamp +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def giveaway(): + return Giveaway( + chats=[Chat(1, Chat.CHANNEL), Chat(2, Chat.SUPERGROUP)], + winners_selection_date=TestGiveawayWithoutRequest.winners_selection_date, + winner_count=TestGiveawayWithoutRequest.winner_count, + only_new_members=TestGiveawayWithoutRequest.only_new_members, + has_public_winners=TestGiveawayWithoutRequest.has_public_winners, + prize_description=TestGiveawayWithoutRequest.prize_description, + country_codes=TestGiveawayWithoutRequest.country_codes, + premium_subscription_month_count=( + TestGiveawayWithoutRequest.premium_subscription_month_count + ), + prize_star_count=TestGiveawayWithoutRequest.prize_star_count, + ) + + +class TestGiveawayWithoutRequest: + chats = [Chat(1, Chat.CHANNEL), Chat(2, Chat.SUPERGROUP)] + winners_selection_date = dtm.datetime.now(dtm.timezone.utc).replace(microsecond=0) + winner_count = 42 + only_new_members = True + has_public_winners = True + prize_description = "prize_description" + country_codes = ["DE", "US"] + premium_subscription_month_count = 3 + prize_star_count = 99 + + def test_slot_behaviour(self, giveaway): + for attr in giveaway.__slots__: + assert getattr(giveaway, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(giveaway)) == len(set(mro_slots(giveaway))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "chats": [chat.to_dict() for chat in self.chats], + "winners_selection_date": to_timestamp(self.winners_selection_date), + "winner_count": self.winner_count, + "only_new_members": self.only_new_members, + "has_public_winners": self.has_public_winners, + "prize_description": self.prize_description, + "country_codes": self.country_codes, + "premium_subscription_month_count": self.premium_subscription_month_count, + "prize_star_count": self.prize_star_count, + } + + giveaway = Giveaway.de_json(json_dict, offline_bot) + assert giveaway.api_kwargs == {} + + assert giveaway.chats == tuple(self.chats) + assert giveaway.winners_selection_date == self.winners_selection_date + assert giveaway.winner_count == self.winner_count + assert giveaway.only_new_members == self.only_new_members + assert giveaway.has_public_winners == self.has_public_winners + assert giveaway.prize_description == self.prize_description + assert giveaway.country_codes == tuple(self.country_codes) + assert giveaway.premium_subscription_month_count == self.premium_subscription_month_count + assert giveaway.prize_star_count == self.prize_star_count + + def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): + json_dict = { + "chats": [chat.to_dict() for chat in self.chats], + "winners_selection_date": to_timestamp(self.winners_selection_date), + "winner_count": self.winner_count, + "only_new_members": self.only_new_members, + "has_public_winners": self.has_public_winners, + "prize_description": self.prize_description, + "country_codes": self.country_codes, + "premium_subscription_month_count": self.premium_subscription_month_count, + "prize_star_count": self.prize_star_count, + } + + giveaway_raw = Giveaway.de_json(json_dict, raw_bot) + giveaway_bot = Giveaway.de_json(json_dict, offline_bot) + giveaway_bot_tz = Giveaway.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + giveaway_bot_tz_offset = giveaway_bot_tz.winners_selection_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + giveaway_bot_tz.winners_selection_date.replace(tzinfo=None) + ) + + assert giveaway_raw.winners_selection_date.tzinfo == UTC + assert giveaway_bot.winners_selection_date.tzinfo == UTC + assert giveaway_bot_tz_offset == tz_bot_offset + + def test_to_dict(self, giveaway): + giveaway_dict = giveaway.to_dict() + + assert isinstance(giveaway_dict, dict) + assert giveaway_dict["chats"] == [chat.to_dict() for chat in self.chats] + assert giveaway_dict["winners_selection_date"] == to_timestamp(self.winners_selection_date) + assert giveaway_dict["winner_count"] == self.winner_count + assert giveaway_dict["only_new_members"] == self.only_new_members + assert giveaway_dict["has_public_winners"] == self.has_public_winners + assert giveaway_dict["prize_description"] == self.prize_description + assert giveaway_dict["country_codes"] == self.country_codes + assert ( + giveaway_dict["premium_subscription_month_count"] + == self.premium_subscription_month_count + ) + assert giveaway_dict["prize_star_count"] == self.prize_star_count + + def test_equality(self, giveaway): + a = giveaway + b = Giveaway( + chats=self.chats, + winners_selection_date=self.winners_selection_date, + winner_count=self.winner_count, + ) + c = Giveaway( + chats=self.chats, + winners_selection_date=self.winners_selection_date + dtm.timedelta(seconds=100), + winner_count=self.winner_count, + ) + d = Giveaway( + chats=self.chats, winners_selection_date=self.winners_selection_date, winner_count=17 + ) + e = BotCommand("start", "description") + + 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) + + +@pytest.fixture(scope="module") +def giveaway_created(): + return GiveawayCreated( + prize_star_count=TestGiveawayCreatedWithoutRequest.prize_star_count, + ) + + +class TestGiveawayCreatedWithoutRequest: + prize_star_count = 99 + + def test_slot_behaviour(self, giveaway_created): + for attr in giveaway_created.__slots__: + assert getattr(giveaway_created, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(giveaway_created)) == len(set(mro_slots(giveaway_created))), ( + "duplicate slot" + ) + + def test_de_json(self, bot): + json_dict = { + "prize_star_count": self.prize_star_count, + } + + gac = GiveawayCreated.de_json(json_dict, bot) + assert gac.api_kwargs == {} + assert gac.prize_star_count == self.prize_star_count + + def test_to_dict(self, giveaway_created): + gac_dict = giveaway_created.to_dict() + + assert isinstance(gac_dict, dict) + assert gac_dict["prize_star_count"] == self.prize_star_count + + +@pytest.fixture(scope="module") +def giveaway_winners(): + return GiveawayWinners( + chat=TestGiveawayWinnersWithoutRequest.chat, + giveaway_message_id=TestGiveawayWinnersWithoutRequest.giveaway_message_id, + winners_selection_date=TestGiveawayWinnersWithoutRequest.winners_selection_date, + winner_count=TestGiveawayWinnersWithoutRequest.winner_count, + winners=TestGiveawayWinnersWithoutRequest.winners, + only_new_members=TestGiveawayWinnersWithoutRequest.only_new_members, + prize_description=TestGiveawayWinnersWithoutRequest.prize_description, + premium_subscription_month_count=( + TestGiveawayWinnersWithoutRequest.premium_subscription_month_count + ), + additional_chat_count=TestGiveawayWinnersWithoutRequest.additional_chat_count, + unclaimed_prize_count=TestGiveawayWinnersWithoutRequest.unclaimed_prize_count, + was_refunded=TestGiveawayWinnersWithoutRequest.was_refunded, + prize_star_count=TestGiveawayWinnersWithoutRequest.prize_star_count, + ) + + +class TestGiveawayWinnersWithoutRequest: + chat = Chat(1, Chat.CHANNEL) + giveaway_message_id = 123456789 + winners_selection_date = dtm.datetime.now(dtm.timezone.utc).replace(microsecond=0) + winner_count = 42 + winners = [User(1, "user1", False), User(2, "user2", False)] + additional_chat_count = 2 + premium_subscription_month_count = 3 + unclaimed_prize_count = 4 + only_new_members = True + was_refunded = True + prize_description = "prize_description" + prize_star_count = 99 + + def test_slot_behaviour(self, giveaway_winners): + for attr in giveaway_winners.__slots__: + assert getattr(giveaway_winners, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(giveaway_winners)) == len(set(mro_slots(giveaway_winners))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "chat": self.chat.to_dict(), + "giveaway_message_id": self.giveaway_message_id, + "winners_selection_date": to_timestamp(self.winners_selection_date), + "winner_count": self.winner_count, + "winners": [winner.to_dict() for winner in self.winners], + "additional_chat_count": self.additional_chat_count, + "premium_subscription_month_count": self.premium_subscription_month_count, + "unclaimed_prize_count": self.unclaimed_prize_count, + "only_new_members": self.only_new_members, + "was_refunded": self.was_refunded, + "prize_description": self.prize_description, + "prize_star_count": self.prize_star_count, + } + + giveaway_winners = GiveawayWinners.de_json(json_dict, offline_bot) + assert giveaway_winners.api_kwargs == {} + + assert giveaway_winners.chat == self.chat + assert giveaway_winners.giveaway_message_id == self.giveaway_message_id + assert giveaway_winners.winners_selection_date == self.winners_selection_date + assert giveaway_winners.winner_count == self.winner_count + assert giveaway_winners.winners == tuple(self.winners) + assert giveaway_winners.additional_chat_count == self.additional_chat_count + assert ( + giveaway_winners.premium_subscription_month_count + == self.premium_subscription_month_count + ) + assert giveaway_winners.unclaimed_prize_count == self.unclaimed_prize_count + assert giveaway_winners.only_new_members == self.only_new_members + assert giveaway_winners.was_refunded == self.was_refunded + assert giveaway_winners.prize_description == self.prize_description + assert giveaway_winners.prize_star_count == self.prize_star_count + + def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): + json_dict = { + "chat": self.chat.to_dict(), + "giveaway_message_id": self.giveaway_message_id, + "winners_selection_date": to_timestamp(self.winners_selection_date), + "winner_count": self.winner_count, + "winners": [winner.to_dict() for winner in self.winners], + } + + giveaway_winners_raw = GiveawayWinners.de_json(json_dict, raw_bot) + giveaway_winners_bot = GiveawayWinners.de_json(json_dict, offline_bot) + giveaway_winners_bot_tz = GiveawayWinners.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + giveaway_winners_bot_tz_offset = giveaway_winners_bot_tz.winners_selection_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + giveaway_winners_bot_tz.winners_selection_date.replace(tzinfo=None) + ) + + assert giveaway_winners_raw.winners_selection_date.tzinfo == UTC + assert giveaway_winners_bot.winners_selection_date.tzinfo == UTC + assert giveaway_winners_bot_tz_offset == tz_bot_offset + + def test_to_dict(self, giveaway_winners): + giveaway_winners_dict = giveaway_winners.to_dict() + + assert isinstance(giveaway_winners_dict, dict) + assert giveaway_winners_dict["chat"] == self.chat.to_dict() + assert giveaway_winners_dict["giveaway_message_id"] == self.giveaway_message_id + assert giveaway_winners_dict["winners_selection_date"] == to_timestamp( + self.winners_selection_date + ) + assert giveaway_winners_dict["winner_count"] == self.winner_count + assert giveaway_winners_dict["winners"] == [winner.to_dict() for winner in self.winners] + assert giveaway_winners_dict["additional_chat_count"] == self.additional_chat_count + assert ( + giveaway_winners_dict["premium_subscription_month_count"] + == self.premium_subscription_month_count + ) + assert giveaway_winners_dict["unclaimed_prize_count"] == self.unclaimed_prize_count + assert giveaway_winners_dict["only_new_members"] == self.only_new_members + assert giveaway_winners_dict["was_refunded"] == self.was_refunded + assert giveaway_winners_dict["prize_description"] == self.prize_description + assert giveaway_winners_dict["prize_star_count"] == self.prize_star_count + + def test_equality(self, giveaway_winners): + a = giveaway_winners + b = GiveawayWinners( + chat=self.chat, + giveaway_message_id=self.giveaway_message_id, + winners_selection_date=self.winners_selection_date, + winner_count=self.winner_count, + winners=self.winners, + ) + c = GiveawayWinners( + chat=self.chat, + giveaway_message_id=self.giveaway_message_id, + winners_selection_date=self.winners_selection_date + dtm.timedelta(seconds=100), + winner_count=self.winner_count, + winners=self.winners, + ) + d = GiveawayWinners( + chat=self.chat, + giveaway_message_id=self.giveaway_message_id, + winners_selection_date=self.winners_selection_date, + winner_count=17, + winners=self.winners, + ) + e = BotCommand("start", "description") + + 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) + + +@pytest.fixture(scope="module") +def giveaway_completed(): + return GiveawayCompleted( + winner_count=TestGiveawayCompletedWithoutRequest.winner_count, + unclaimed_prize_count=TestGiveawayCompletedWithoutRequest.unclaimed_prize_count, + giveaway_message=TestGiveawayCompletedWithoutRequest.giveaway_message, + is_star_giveaway=TestGiveawayCompletedWithoutRequest.is_star_giveaway, + ) + + +class TestGiveawayCompletedWithoutRequest: + winner_count = 42 + unclaimed_prize_count = 4 + is_star_giveaway = True + giveaway_message = Message( + message_id=1, + date=dtm.datetime.now(dtm.timezone.utc), + text="giveaway_message", + chat=Chat(1, Chat.CHANNEL), + from_user=User(1, "user1", False), + ) + + def test_slot_behaviour(self, giveaway_completed): + for attr in giveaway_completed.__slots__: + assert getattr(giveaway_completed, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(giveaway_completed)) == len(set(mro_slots(giveaway_completed))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "winner_count": self.winner_count, + "unclaimed_prize_count": self.unclaimed_prize_count, + "giveaway_message": self.giveaway_message.to_dict(), + "is_star_giveaway": self.is_star_giveaway, + } + + giveaway_completed = GiveawayCompleted.de_json(json_dict, offline_bot) + assert giveaway_completed.api_kwargs == {} + + assert giveaway_completed.winner_count == self.winner_count + assert giveaway_completed.unclaimed_prize_count == self.unclaimed_prize_count + assert giveaway_completed.giveaway_message == self.giveaway_message + assert giveaway_completed.is_star_giveaway == self.is_star_giveaway + + def test_to_dict(self, giveaway_completed): + giveaway_completed_dict = giveaway_completed.to_dict() + + assert isinstance(giveaway_completed_dict, dict) + assert giveaway_completed_dict["winner_count"] == self.winner_count + assert giveaway_completed_dict["unclaimed_prize_count"] == self.unclaimed_prize_count + assert giveaway_completed_dict["giveaway_message"] == self.giveaway_message.to_dict() + assert giveaway_completed_dict["is_star_giveaway"] == self.is_star_giveaway + + def test_equality(self, giveaway_completed): + a = giveaway_completed + b = GiveawayCompleted( + winner_count=self.winner_count, + unclaimed_prize_count=self.unclaimed_prize_count, + giveaway_message=self.giveaway_message, + is_star_giveaway=self.is_star_giveaway, + ) + c = GiveawayCompleted( + winner_count=self.winner_count + 30, + unclaimed_prize_count=self.unclaimed_prize_count, + ) + d = GiveawayCompleted( + winner_count=self.winner_count, + unclaimed_prize_count=17, + giveaway_message=self.giveaway_message, + ) + e = GiveawayCompleted( + winner_count=self.winner_count + 1, + unclaimed_prize_count=self.unclaimed_prize_count, + ) + f = BotCommand("start", "description") + + 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 a != f + assert hash(a) != hash(f) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 9ac642a2276..86a9b52f468 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -137,9 +137,8 @@ def build_test_message(kwargs): assert helpers.effective_message_type(empty_update) is None def test_effective_message_type_wrong_type(self): - entity = {} with pytest.raises( - TypeError, match=re.escape(f"neither Message nor Update (got: {type(entity)})") + TypeError, match=re.escape(f"neither Message nor Update (got: {type(entity := {})})") ): helpers.effective_message_type(entity) diff --git a/tests/test_inlinequeryresultsbutton.py b/tests/test_inlinequeryresultsbutton.py index db87326c556..fbc92c3649e 100644 --- a/tests/test_inlinequeryresultsbutton.py +++ b/tests/test_inlinequeryresultsbutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,19 +25,19 @@ @pytest.fixture(scope="module") def inline_query_results_button(): return InlineQueryResultsButton( - text=TestInlineQueryResultsButtonBase.text, - start_parameter=TestInlineQueryResultsButtonBase.start_parameter, - web_app=TestInlineQueryResultsButtonBase.web_app, + text=InlineQueryResultsButtonTestBase.text, + start_parameter=InlineQueryResultsButtonTestBase.start_parameter, + web_app=InlineQueryResultsButtonTestBase.web_app, ) -class TestInlineQueryResultsButtonBase: +class InlineQueryResultsButtonTestBase: text = "text" start_parameter = "start_parameter" web_app = WebAppInfo(url="https://python-telegram-bot.org") -class TestInlineQueryResultsButtonWithoutRequest(TestInlineQueryResultsButtonBase): +class TestInlineQueryResultsButtonWithoutRequest(InlineQueryResultsButtonTestBase): def test_slot_behaviour(self, inline_query_results_button): inst = inline_query_results_button for attr in inst.__slots__: @@ -51,16 +51,13 @@ def test_to_dict(self, inline_query_results_button): assert inline_query_results_button_dict["start_parameter"] == self.start_parameter assert inline_query_results_button_dict["web_app"] == self.web_app.to_dict() - def test_de_json(self, bot): - assert InlineQueryResultsButton.de_json(None, bot) is None - assert InlineQueryResultsButton.de_json({}, bot) is None - + def test_de_json(self, offline_bot): json_dict = { "text": self.text, "start_parameter": self.start_parameter, "web_app": self.web_app.to_dict(), } - inline_query_results_button = InlineQueryResultsButton.de_json(json_dict, bot) + inline_query_results_button = InlineQueryResultsButton.de_json(json_dict, offline_bot) assert inline_query_results_button.text == self.text assert inline_query_results_button.start_parameter == self.start_parameter diff --git a/tests/test_inputchecklist.py b/tests/test_inputchecklist.py new file mode 100644 index 00000000000..e2543562d54 --- /dev/null +++ b/tests/test_inputchecklist.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 Dice, InputChecklist, InputChecklistTask, MessageEntity +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def input_checklist_task(): + return InputChecklistTask( + id=InputChecklistTaskTestBase.id, + text=InputChecklistTaskTestBase.text, + parse_mode=InputChecklistTaskTestBase.parse_mode, + text_entities=InputChecklistTaskTestBase.text_entities, + ) + + +class InputChecklistTaskTestBase: + id = 1 + text = "buy food" + parse_mode = "MarkdownV2" + text_entities = [ + MessageEntity(type="bold", offset=0, length=3), + MessageEntity(type="italic", offset=4, length=4), + ] + + +class TestInputChecklistTaskWithoutRequest(InputChecklistTaskTestBase): + def test_slot_behaviour(self, input_checklist_task): + for attr in input_checklist_task.__slots__: + assert getattr(input_checklist_task, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(input_checklist_task)) == len(set(mro_slots(input_checklist_task))), ( + "duplicate slot" + ) + + def test_expected_values(self, input_checklist_task): + assert input_checklist_task.id == self.id + assert input_checklist_task.text == self.text + assert input_checklist_task.parse_mode == self.parse_mode + assert input_checklist_task.text_entities == tuple(self.text_entities) + + def test_to_dict(self, input_checklist_task): + iclt_dict = input_checklist_task.to_dict() + + assert isinstance(iclt_dict, dict) + assert iclt_dict["id"] == self.id + assert iclt_dict["text"] == self.text + assert iclt_dict["parse_mode"] == self.parse_mode + assert iclt_dict["text_entities"] == [entity.to_dict() for entity in self.text_entities] + + # Test that default-value parameter `parse_mode` is handled correctly + input_checklist_task = InputChecklistTask(id=1, text="text") + iclt_dict = input_checklist_task.to_dict() + assert "parse_mode" not in iclt_dict + + def test_equality(self, input_checklist_task): + a = input_checklist_task + b = InputChecklistTask(id=self.id, text=f"other {self.text}") + c = InputChecklistTask(id=self.id + 1, text=self.text) + d = Dice(value=1, 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) + + +@pytest.fixture(scope="module") +def input_checklist(): + return InputChecklist( + title=InputChecklistTestBase.title, + tasks=InputChecklistTestBase.tasks, + parse_mode=InputChecklistTestBase.parse_mode, + title_entities=InputChecklistTestBase.title_entities, + others_can_add_tasks=InputChecklistTestBase.others_can_add_tasks, + others_can_mark_tasks_as_done=InputChecklistTestBase.others_can_mark_tasks_as_done, + ) + + +class InputChecklistTestBase: + title = "test list" + tasks = [ + InputChecklistTask(id=1, text="eat"), + InputChecklistTask(id=2, text="sleep"), + ] + parse_mode = "MarkdownV2" + title_entities = [ + MessageEntity(type="bold", offset=0, length=4), + MessageEntity(type="italic", offset=5, length=4), + ] + others_can_add_tasks = True + others_can_mark_tasks_as_done = False + + +class TestInputChecklistWithoutRequest(InputChecklistTestBase): + def test_slot_behaviour(self, input_checklist): + for attr in input_checklist.__slots__: + assert getattr(input_checklist, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(input_checklist)) == len(set(mro_slots(input_checklist))), ( + "duplicate slot" + ) + + def test_expected_values(self, input_checklist): + assert input_checklist.title == self.title + assert input_checklist.tasks == tuple(self.tasks) + assert input_checklist.parse_mode == self.parse_mode + assert input_checklist.title_entities == tuple(self.title_entities) + assert input_checklist.others_can_add_tasks == self.others_can_add_tasks + assert input_checklist.others_can_mark_tasks_as_done == self.others_can_mark_tasks_as_done + + def test_to_dict(self, input_checklist): + icl_dict = input_checklist.to_dict() + + assert isinstance(icl_dict, dict) + assert icl_dict["title"] == self.title + assert icl_dict["tasks"] == [task.to_dict() for task in self.tasks] + assert icl_dict["parse_mode"] == self.parse_mode + assert icl_dict["title_entities"] == [entity.to_dict() for entity in self.title_entities] + assert icl_dict["others_can_add_tasks"] == self.others_can_add_tasks + assert icl_dict["others_can_mark_tasks_as_done"] == self.others_can_mark_tasks_as_done + + # Test that default-value parameter `parse_mode` is handled correctly + input_checklist = InputChecklist(title=self.title, tasks=self.tasks) + icl_dict = input_checklist.to_dict() + assert "parse_mode" not in icl_dict + + def test_equality(self, input_checklist): + a = input_checklist + b = InputChecklist( + title=f"other {self.title}", + tasks=[InputChecklistTask(id=1, text="eat"), InputChecklistTask(id=2, text="sleep")], + ) + c = InputChecklist( + title=self.title, + tasks=[InputChecklistTask(id=9, text="Other Task")], + ) + d = Dice(value=1, 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) diff --git a/tests/test_keyboardbutton.py b/tests/test_keyboardbutton.py index f58af574c7f..f7d14902784 100644 --- a/tests/test_keyboardbutton.py +++ b/tests/test_keyboardbutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,37 +23,36 @@ KeyboardButton, KeyboardButtonPollType, KeyboardButtonRequestChat, - KeyboardButtonRequestUser, + KeyboardButtonRequestUsers, WebAppInfo, ) -from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def keyboard_button(): return KeyboardButton( - TestKeyboardButtonBase.text, - request_location=TestKeyboardButtonBase.request_location, - request_contact=TestKeyboardButtonBase.request_contact, - request_poll=TestKeyboardButtonBase.request_poll, - web_app=TestKeyboardButtonBase.web_app, - request_chat=TestKeyboardButtonBase.request_chat, - request_user=TestKeyboardButtonBase.request_user, + KeyboardButtonTestBase.text, + request_location=KeyboardButtonTestBase.request_location, + request_contact=KeyboardButtonTestBase.request_contact, + request_poll=KeyboardButtonTestBase.request_poll, + web_app=KeyboardButtonTestBase.web_app, + request_chat=KeyboardButtonTestBase.request_chat, + request_users=KeyboardButtonTestBase.request_users, ) -class TestKeyboardButtonBase: +class KeyboardButtonTestBase: text = "text" request_location = True request_contact = True request_poll = KeyboardButtonPollType("quiz") web_app = WebAppInfo(url="https://example.com") request_chat = KeyboardButtonRequestChat(1, True) - request_user = KeyboardButtonRequestUser(2) + request_users = KeyboardButtonRequestUsers(2) -class TestKeyboardButtonWithoutRequest(TestKeyboardButtonBase): +class TestKeyboardButtonWithoutRequest(KeyboardButtonTestBase): def test_slot_behaviour(self, keyboard_button): inst = keyboard_button for attr in inst.__slots__: @@ -67,7 +66,7 @@ def test_expected_values(self, keyboard_button): assert keyboard_button.request_poll == self.request_poll assert keyboard_button.web_app == self.web_app assert keyboard_button.request_chat == self.request_chat - assert keyboard_button.request_user == self.request_user + assert keyboard_button.request_users == self.request_users def test_to_dict(self, keyboard_button): keyboard_button_dict = keyboard_button.to_dict() @@ -79,9 +78,10 @@ def test_to_dict(self, keyboard_button): assert keyboard_button_dict["request_poll"] == keyboard_button.request_poll.to_dict() assert keyboard_button_dict["web_app"] == keyboard_button.web_app.to_dict() assert keyboard_button_dict["request_chat"] == keyboard_button.request_chat.to_dict() - assert keyboard_button_dict["request_user"] == keyboard_button.request_user.to_dict() + assert keyboard_button_dict["request_users"] == keyboard_button.request_users.to_dict() - def test_de_json(self, bot): + @pytest.mark.parametrize("request_user", [True, False]) + def test_de_json(self, request_user): json_dict = { "text": self.text, "request_location": self.request_location, @@ -89,21 +89,24 @@ def test_de_json(self, bot): "request_poll": self.request_poll.to_dict(), "web_app": self.web_app.to_dict(), "request_chat": self.request_chat.to_dict(), - "request_user": self.request_user.to_dict(), + "request_users": self.request_users.to_dict(), } + if request_user: + json_dict["request_user"] = {"request_id": 2} - inline_keyboard_button = KeyboardButton.de_json(json_dict, None) - assert inline_keyboard_button.api_kwargs == {} - assert inline_keyboard_button.text == self.text - assert inline_keyboard_button.request_location == self.request_location - assert inline_keyboard_button.request_contact == self.request_contact - assert inline_keyboard_button.request_poll == self.request_poll - assert inline_keyboard_button.web_app == self.web_app - assert inline_keyboard_button.request_chat == self.request_chat - assert inline_keyboard_button.request_user == self.request_user + keyboard_button = KeyboardButton.de_json(json_dict, None) + if request_user: + assert keyboard_button.api_kwargs == {"request_user": {"request_id": 2}} + else: + assert keyboard_button.api_kwargs == {} - none = KeyboardButton.de_json({}, None) - assert none is None + assert keyboard_button.text == self.text + assert keyboard_button.request_location == self.request_location + assert keyboard_button.request_contact == self.request_contact + assert keyboard_button.request_poll == self.request_poll + assert keyboard_button.web_app == self.web_app + assert keyboard_button.request_chat == self.request_chat + assert keyboard_button.request_users == self.request_users def test_equality(self): a = KeyboardButton("test", request_contact=True) @@ -115,7 +118,13 @@ def test_equality(self): "test", request_contact=True, request_chat=KeyboardButtonRequestChat(1, False), - request_user=KeyboardButtonRequestUser(2), + request_users=KeyboardButtonRequestUsers(2), + ) + g = KeyboardButton( + "test", + request_contact=True, + request_chat=KeyboardButtonRequestChat(1, False), + request_users=KeyboardButtonRequestUsers(2), ) assert a == b @@ -130,17 +139,8 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) - # we expect this to be true since we don't compare these in V20 - assert a == f - assert hash(a) == hash(f) - - def test_equality_warning(self, recwarn, keyboard_button): - recwarn.clear() - assert keyboard_button == keyboard_button + assert a != f + assert hash(a) != hash(f) - assert str(recwarn[0].message) == ( - "In v21, granular media settings will be considered as well when comparing" - " ChatPermissions instances." - ) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__, "wrong stacklevel" + assert f == g + assert hash(f) == hash(g) diff --git a/tests/test_keyboardbuttonpolltype.py b/tests/test_keyboardbuttonpolltype.py index e324ab7ffa2..5abcfed0aa7 100644 --- a/tests/test_keyboardbuttonpolltype.py +++ b/tests/test_keyboardbuttonpolltype.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,19 +19,20 @@ import pytest from telegram import KeyboardButtonPollType, Poll +from telegram.constants import PollType from tests.auxil.slots import mro_slots @pytest.fixture(scope="module") def keyboard_button_poll_type(): - return KeyboardButtonPollType(TestKeyboardButtonPollTypeBase.type) + return KeyboardButtonPollType(KeyboardButtonPollTypeTestBase.type) -class TestKeyboardButtonPollTypeBase: +class KeyboardButtonPollTypeTestBase: type = Poll.QUIZ -class TestKeyboardButtonPollTypeWithoutRequest(TestKeyboardButtonPollTypeBase): +class TestKeyboardButtonPollTypeWithoutRequest(KeyboardButtonPollTypeTestBase): def test_slot_behaviour(self, keyboard_button_poll_type): inst = keyboard_button_poll_type for attr in inst.__slots__: @@ -43,6 +44,22 @@ def test_to_dict(self, keyboard_button_poll_type): assert isinstance(keyboard_button_poll_type_dict, dict) assert keyboard_button_poll_type_dict["type"] == self.type + def test_type_enum_conversion(self): + assert ( + type( + KeyboardButtonPollType( + type="quiz", + ).type + ) + is PollType + ) + assert ( + KeyboardButtonPollType( + type="unknown", + ).type + == "unknown" + ) + def test_equality(self): a = KeyboardButtonPollType(Poll.QUIZ) b = KeyboardButtonPollType(Poll.QUIZ) diff --git a/tests/test_keyboardbuttonrequest.py b/tests/test_keyboardbuttonrequest.py index 764fd376dd0..8a63bb2dbad 100644 --- a/tests/test_keyboardbuttonrequest.py +++ b/tests/test_keyboardbuttonrequest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,56 +19,63 @@ import pytest -from telegram import ChatAdministratorRights, KeyboardButtonRequestChat, KeyboardButtonRequestUser +from telegram import ChatAdministratorRights, KeyboardButtonRequestChat, KeyboardButtonRequestUsers from tests.auxil.slots import mro_slots @pytest.fixture(scope="class") -def request_user(): - return KeyboardButtonRequestUser( - TestKeyboardButtonRequestUserBase.request_id, - TestKeyboardButtonRequestUserBase.user_is_bot, - TestKeyboardButtonRequestUserBase.user_is_premium, +def request_users(): + return KeyboardButtonRequestUsers( + KeyboardButtonRequestUsersTestBase.request_id, + KeyboardButtonRequestUsersTestBase.user_is_bot, + KeyboardButtonRequestUsersTestBase.user_is_premium, + KeyboardButtonRequestUsersTestBase.max_quantity, ) -class TestKeyboardButtonRequestUserBase: +class KeyboardButtonRequestUsersTestBase: request_id = 123 user_is_bot = True user_is_premium = False + max_quantity = 10 -class TestKeyboardButtonRequestUserWithoutRequest(TestKeyboardButtonRequestUserBase): - def test_slot_behaviour(self, request_user): - for attr in request_user.__slots__: - assert getattr(request_user, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(request_user)) == len(set(mro_slots(request_user))), "duplicate slot" +class TestKeyboardButtonRequestUsersWithoutRequest(KeyboardButtonRequestUsersTestBase): + def test_slot_behaviour(self, request_users): + for attr in request_users.__slots__: + assert getattr(request_users, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(request_users)) == len(set(mro_slots(request_users))), ( + "duplicate slot" + ) - def test_to_dict(self, request_user): - request_user_dict = request_user.to_dict() + def test_to_dict(self, request_users): + request_users_dict = request_users.to_dict() - assert isinstance(request_user_dict, dict) - assert request_user_dict["request_id"] == self.request_id - assert request_user_dict["user_is_bot"] == self.user_is_bot - assert request_user_dict["user_is_premium"] == self.user_is_premium + assert isinstance(request_users_dict, dict) + assert request_users_dict["request_id"] == self.request_id + assert request_users_dict["user_is_bot"] == self.user_is_bot + assert request_users_dict["user_is_premium"] == self.user_is_premium + assert request_users_dict["max_quantity"] == self.max_quantity - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "request_id": self.request_id, "user_is_bot": self.user_is_bot, "user_is_premium": self.user_is_premium, + "max_quantity": self.max_quantity, } - request_user = KeyboardButtonRequestUser.de_json(json_dict, bot) - assert request_user.api_kwargs == {} + request_users = KeyboardButtonRequestUsers.de_json(json_dict, offline_bot) + assert request_users.api_kwargs == {} - assert request_user.request_id == self.request_id - assert request_user.user_is_bot == self.user_is_bot - assert request_user.user_is_premium == self.user_is_premium + assert request_users.request_id == self.request_id + assert request_users.user_is_bot == self.user_is_bot + assert request_users.user_is_premium == self.user_is_premium + assert request_users.max_quantity == self.max_quantity def test_equality(self): - a = KeyboardButtonRequestUser(self.request_id) - b = KeyboardButtonRequestUser(self.request_id) - c = KeyboardButtonRequestUser(1) + a = KeyboardButtonRequestUsers(self.request_id) + b = KeyboardButtonRequestUsers(self.request_id) + c = KeyboardButtonRequestUsers(1) assert a == b assert hash(a) == hash(b) @@ -81,33 +88,53 @@ def test_equality(self): @pytest.fixture(scope="class") def request_chat(): return KeyboardButtonRequestChat( - TestKeyboardButtonRequestChatBase.request_id, - TestKeyboardButtonRequestChatBase.chat_is_channel, - TestKeyboardButtonRequestChatBase.chat_is_forum, - TestKeyboardButtonRequestChatBase.chat_has_username, - TestKeyboardButtonRequestChatBase.chat_is_created, - TestKeyboardButtonRequestChatBase.user_administrator_rights, - TestKeyboardButtonRequestChatBase.bot_administrator_rights, - TestKeyboardButtonRequestChatBase.bot_is_member, + KeyboardButtonRequestChatTestBase.request_id, + KeyboardButtonRequestChatTestBase.chat_is_channel, + KeyboardButtonRequestChatTestBase.chat_is_forum, + KeyboardButtonRequestChatTestBase.chat_has_username, + KeyboardButtonRequestChatTestBase.chat_is_created, + KeyboardButtonRequestChatTestBase.user_administrator_rights, + KeyboardButtonRequestChatTestBase.bot_administrator_rights, + KeyboardButtonRequestChatTestBase.bot_is_member, ) -class TestKeyboardButtonRequestChatBase: +class KeyboardButtonRequestChatTestBase: request_id = 456 chat_is_channel = True chat_is_forum = False chat_has_username = True chat_is_created = False user_administrator_rights = ChatAdministratorRights( - True, False, True, False, True, False, True, False + True, + False, + True, + False, + True, + False, + True, + False, + can_post_stories=False, + can_edit_stories=False, + can_delete_stories=False, ) bot_administrator_rights = ChatAdministratorRights( - True, False, True, False, True, False, True, False + True, + False, + True, + False, + True, + False, + True, + False, + can_post_stories=False, + can_edit_stories=False, + can_delete_stories=False, ) bot_is_member = True -class TestKeyboardButtonRequestChatWithoutRequest(TestKeyboardButtonRequestChatBase): +class TestKeyboardButtonRequestChatWithoutRequest(KeyboardButtonRequestChatTestBase): def test_slot_behaviour(self, request_chat): for attr in request_chat.__slots__: assert getattr(request_chat, attr, "err") != "err", f"got extra slot '{attr}'" @@ -131,7 +158,7 @@ def test_to_dict(self, request_chat): ) assert request_chat_dict["bot_is_member"] == self.bot_is_member - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "request_id": self.request_id, "chat_is_channel": self.chat_is_channel, @@ -141,7 +168,7 @@ def test_de_json(self, bot): "bot_administrator_rights": self.bot_administrator_rights.to_dict(), "bot_is_member": self.bot_is_member, } - request_chat = KeyboardButtonRequestChat.de_json(json_dict, bot) + request_chat = KeyboardButtonRequestChat.de_json(json_dict, offline_bot) assert request_chat.api_kwargs == {} assert request_chat.request_id == self.request_id @@ -152,9 +179,6 @@ def test_de_json(self, bot): assert request_chat.bot_administrator_rights == self.bot_administrator_rights assert request_chat.bot_is_member == self.bot_is_member - empty_chat = KeyboardButtonRequestChat.de_json({}, bot) - assert empty_chat is None - def test_equality(self): a = KeyboardButtonRequestChat(self.request_id, True) b = KeyboardButtonRequestChat(self.request_id, True) diff --git a/tests/test_linkpreviewoptions.py b/tests/test_linkpreviewoptions.py new file mode 100644 index 00000000000..946a1f7bf6f --- /dev/null +++ b/tests/test_linkpreviewoptions.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 LinkPreviewOptions +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def link_preview_options(): + return LinkPreviewOptions( + is_disabled=LinkPreviewOptionsTestBase.is_disabled, + url=LinkPreviewOptionsTestBase.url, + prefer_small_media=LinkPreviewOptionsTestBase.prefer_small_media, + prefer_large_media=LinkPreviewOptionsTestBase.prefer_large_media, + show_above_text=LinkPreviewOptionsTestBase.show_above_text, + ) + + +class LinkPreviewOptionsTestBase: + is_disabled = True + url = "https://www.example.com" + prefer_small_media = True + prefer_large_media = False + show_above_text = True + + +class TestLinkPreviewOptionsWithoutRequest(LinkPreviewOptionsTestBase): + def test_slot_behaviour(self, link_preview_options): + a = link_preview_options + for attr in a.__slots__: + assert getattr(a, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(a)) == len(set(mro_slots(a))), "duplicate slot" + + def test_to_dict(self, link_preview_options): + link_preview_options_dict = link_preview_options.to_dict() + + assert isinstance(link_preview_options_dict, dict) + assert link_preview_options_dict["is_disabled"] == self.is_disabled + assert link_preview_options_dict["url"] == self.url + assert link_preview_options_dict["prefer_small_media"] == self.prefer_small_media + assert link_preview_options_dict["prefer_large_media"] == self.prefer_large_media + assert link_preview_options_dict["show_above_text"] == self.show_above_text + + def test_de_json(self, link_preview_options): + link_preview_options_dict = { + "is_disabled": self.is_disabled, + "url": self.url, + "prefer_small_media": self.prefer_small_media, + "prefer_large_media": self.prefer_large_media, + "show_above_text": self.show_above_text, + } + + link_preview_options = LinkPreviewOptions.de_json(link_preview_options_dict, bot=None) + assert link_preview_options.api_kwargs == {} + + assert link_preview_options.is_disabled == self.is_disabled + assert link_preview_options.url == self.url + assert link_preview_options.prefer_small_media == self.prefer_small_media + assert link_preview_options.prefer_large_media == self.prefer_large_media + assert link_preview_options.show_above_text == self.show_above_text + + def test_equality(self): + a = LinkPreviewOptions( + self.is_disabled, + self.url, + self.prefer_small_media, + self.prefer_large_media, + self.show_above_text, + ) + b = LinkPreviewOptions( + self.is_disabled, + self.url, + self.prefer_small_media, + self.prefer_large_media, + self.show_above_text, + ) + c = LinkPreviewOptions(self.is_disabled) + d = LinkPreviewOptions( + False, self.url, self.prefer_small_media, self.prefer_large_media, self.show_above_text + ) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_loginurl.py b/tests/test_loginurl.py index 6f22d33ded8..c98bd974404 100644 --- a/tests/test_loginurl.py +++ b/tests/test_loginurl.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,21 +25,21 @@ @pytest.fixture(scope="module") def login_url(): return LoginUrl( - url=TestLoginUrlBase.url, - forward_text=TestLoginUrlBase.forward_text, - bot_username=TestLoginUrlBase.bot_username, - request_write_access=TestLoginUrlBase.request_write_access, + url=LoginUrlTestBase.url, + forward_text=LoginUrlTestBase.forward_text, + bot_username=LoginUrlTestBase.bot_username, + request_write_access=LoginUrlTestBase.request_write_access, ) -class TestLoginUrlBase: +class LoginUrlTestBase: url = "http://www.google.com" forward_text = "Send me forward!" bot_username = "botname" request_write_access = True -class TestLoginUrlWithoutRequest(TestLoginUrlBase): +class TestLoginUrlWithoutRequest(LoginUrlTestBase): def test_slot_behaviour(self, login_url): for attr in login_url.__slots__: assert getattr(login_url, attr, "err") != "err", f"got extra slot '{attr}'" diff --git a/tests/test_maybeinaccessiblemessage.py b/tests/test_maybeinaccessiblemessage.py new file mode 100644 index 00000000000..d92e8c99591 --- /dev/null +++ b/tests/test_maybeinaccessiblemessage.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm + +import pytest + +from telegram import Chat, MaybeInaccessibleMessage +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import ZERO_DATE +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="class") +def maybe_inaccessible_message(): + return MaybeInaccessibleMessage( + MaybeInaccessibleMessageTestBase.chat, + MaybeInaccessibleMessageTestBase.message_id, + MaybeInaccessibleMessageTestBase.date, + ) + + +class MaybeInaccessibleMessageTestBase: + chat = Chat(1, "title") + message_id = 123 + date = dtm.datetime.now(dtm.timezone.utc).replace(microsecond=0) + + +class TestMaybeInaccessibleMessageWithoutRequest(MaybeInaccessibleMessageTestBase): + def test_slot_behaviour(self, maybe_inaccessible_message): + for attr in maybe_inaccessible_message.__slots__: + assert getattr(maybe_inaccessible_message, attr, "err") != "err", ( + f"got extra slot '{attr}'" + ) + assert len(mro_slots(maybe_inaccessible_message)) == len( + set(mro_slots(maybe_inaccessible_message)) + ), "duplicate slot" + + def test_to_dict(self, maybe_inaccessible_message): + maybe_inaccessible_message_dict = maybe_inaccessible_message.to_dict() + + assert isinstance(maybe_inaccessible_message_dict, dict) + assert maybe_inaccessible_message_dict["chat"] == self.chat.to_dict() + assert maybe_inaccessible_message_dict["message_id"] == self.message_id + assert maybe_inaccessible_message_dict["date"] == to_timestamp(self.date) + + def test_de_json(self, offline_bot): + json_dict = { + "chat": self.chat.to_dict(), + "message_id": self.message_id, + "date": to_timestamp(self.date), + } + maybe_inaccessible_message = MaybeInaccessibleMessage.de_json(json_dict, offline_bot) + assert maybe_inaccessible_message.api_kwargs == {} + + assert maybe_inaccessible_message.chat == self.chat + assert maybe_inaccessible_message.message_id == self.message_id + assert maybe_inaccessible_message.date == self.date + + def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): + json_dict = { + "chat": self.chat.to_dict(), + "message_id": self.message_id, + "date": to_timestamp(self.date), + } + + maybe_inaccessible_message_raw = MaybeInaccessibleMessage.de_json(json_dict, raw_bot) + maybe_inaccessible_message_bot = MaybeInaccessibleMessage.de_json(json_dict, offline_bot) + maybe_inaccessible_message_bot_tz = MaybeInaccessibleMessage.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + maybe_inaccessible_message_bot_tz_offset = ( + maybe_inaccessible_message_bot_tz.date.utcoffset() + ) + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + maybe_inaccessible_message_bot_tz.date.replace(tzinfo=None) + ) + + assert maybe_inaccessible_message_raw.date.tzinfo == UTC + assert maybe_inaccessible_message_bot.date.tzinfo == UTC + assert maybe_inaccessible_message_bot_tz_offset == tz_bot_offset + + def test_de_json_zero_date(self, offline_bot): + json_dict = { + "chat": self.chat.to_dict(), + "message_id": self.message_id, + "date": 0, + } + + maybe_inaccessible_message = MaybeInaccessibleMessage.de_json(json_dict, offline_bot) + assert maybe_inaccessible_message.date == ZERO_DATE + assert maybe_inaccessible_message.date is ZERO_DATE + + def test_is_accessible(self): + assert MaybeInaccessibleMessage(self.chat, self.message_id, self.date).is_accessible + assert not MaybeInaccessibleMessage(self.chat, self.message_id, ZERO_DATE).is_accessible + + def test_equality(self, maybe_inaccessible_message): + a = maybe_inaccessible_message + b = MaybeInaccessibleMessage( + self.chat, self.message_id, self.date + dtm.timedelta(seconds=1) + ) + c = MaybeInaccessibleMessage(self.chat, self.message_id + 1, self.date) + d = MaybeInaccessibleMessage(Chat(2, "title"), self.message_id, self.date) + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a != c + assert hash(a) != hash(c) + assert a is not c + + assert a != d + assert hash(a) != hash(d) + assert a is not d diff --git a/tests/test_menubutton.py b/tests/test_menubutton.py index 418de635f15..8253ec33d86 100644 --- a/tests/test_menubutton.py +++ b/tests/test_menubutton.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,8 +16,6 @@ # # 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 ( @@ -28,129 +26,68 @@ MenuButtonWebApp, WebAppInfo, ) +from telegram.constants import MenuButtonType from tests.auxil.slots import mro_slots -@pytest.fixture( - scope="module", - params=[ - MenuButton.DEFAULT, - MenuButton.WEB_APP, - MenuButton.COMMANDS, - ], -) -def scope_type(request): - return request.param - - -@pytest.fixture( - scope="module", - params=[ - MenuButtonDefault, - MenuButtonCommands, - MenuButtonWebApp, - ], - ids=[ - MenuButton.DEFAULT, - MenuButton.COMMANDS, - MenuButton.WEB_APP, - ], -) -def scope_class(request): - return request.param - - -@pytest.fixture( - scope="module", - params=[ - (MenuButtonDefault, MenuButton.DEFAULT), - (MenuButtonCommands, MenuButton.COMMANDS), - (MenuButtonWebApp, MenuButton.WEB_APP), - ], - ids=[ - MenuButton.DEFAULT, - MenuButton.COMMANDS, - MenuButton.WEB_APP, - ], -) -def scope_class_and_type(request): - return request.param - - -@pytest.fixture(scope="module") -def menu_button(scope_class_and_type): - # We use de_json here so that we don't have to worry about which class gets which arguments - return scope_class_and_type[0].de_json( - { - "type": scope_class_and_type[1], - "text": TestMenuButtonselfBase.text, - "web_app": TestMenuButtonselfBase.web_app.to_dict(), - }, - bot=None, - ) +@pytest.fixture +def menu_button(): + return MenuButton(MenuButtonTestBase.type) -class TestMenuButtonselfBase: - text = "button_text" - web_app = WebAppInfo(url="https://python-telegram-bot.org/web_app") +class MenuButtonTestBase: + type = MenuButtonType.DEFAULT + text = "this is a test string" + web_app = WebAppInfo(url="https://python-telegram-bot.org") -# All the scope types are very similar, so we test everything via parametrization -class TestMenuButtonWithoutRequest(TestMenuButtonselfBase): +class TestMenuButtonWithoutRequest(MenuButtonTestBase): def test_slot_behaviour(self, menu_button): - for attr in menu_button.__slots__: - assert getattr(menu_button, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(menu_button)) == len(set(mro_slots(menu_button))), "duplicate slot" - - def test_de_json(self, bot, scope_class_and_type): - cls = scope_class_and_type[0] - type_ = scope_class_and_type[1] - - json_dict = {"type": type_, "text": self.text, "web_app": self.web_app.to_dict()} - menu_button = MenuButton.de_json(json_dict, bot) - assert set(menu_button.api_kwargs.keys()) == {"text", "web_app"} - set(cls.__slots__) - - assert isinstance(menu_button, MenuButton) - assert type(menu_button) is cls - assert menu_button.type == type_ - if "web_app" in cls.__slots__: - assert menu_button.web_app == self.web_app - if "text" in cls.__slots__: - assert menu_button.text == self.text - - assert cls.de_json(None, bot) is None - assert MenuButton.de_json({}, bot) is None - - def test_de_json_invalid_type(self, bot): - json_dict = {"type": "invalid", "text": self.text, "web_app": self.web_app.to_dict()} - menu_button = MenuButton.de_json(json_dict, bot) - assert menu_button.api_kwargs == {"text": self.text, "web_app": self.web_app.to_dict()} - - assert type(menu_button) is MenuButton - assert menu_button.type == "invalid" - - def test_de_json_subclass(self, scope_class, bot): - """This makes sure that e.g. MenuButtonDefault(data) never returns a - MenuButtonChat instance.""" - 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 + inst = menu_button + 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_type_enum_conversion(self, menu_button): + assert type(MenuButton("default").type) is MenuButtonType + assert MenuButton("unknown").type == "unknown" + + def test_de_json(self, offline_bot): + data = {"type": "unknown"} + transaction_partner = MenuButton.de_json(data, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "unknown" + + @pytest.mark.parametrize( + ("mb_type", "subclass"), + [ + ("commands", MenuButtonCommands), + ("web_app", MenuButtonWebApp), + ("default", MenuButtonDefault), + ], + ) + def test_de_json_subclass(self, offline_bot, mb_type, subclass): + json_dict = { + "type": mb_type, + "web_app": self.web_app.to_dict(), + "text": self.text, + } + mb = MenuButton.de_json(json_dict, offline_bot) + + assert type(mb) is subclass + assert set(mb.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { + "type" + } + assert mb.type == mb_type def test_to_dict(self, menu_button): - menu_button_dict = menu_button.to_dict() - - assert isinstance(menu_button_dict, dict) - assert menu_button_dict["type"] == menu_button.type - if hasattr(menu_button, "web_app"): - assert menu_button_dict["web_app"] == menu_button.web_app.to_dict() - if hasattr(menu_button, "text"): - assert menu_button_dict["text"] == menu_button.text - - def test_equality(self, menu_button, bot): - a = MenuButton("base_type") - b = MenuButton("base_type") - c = menu_button - d = deepcopy(menu_button) - e = Dice(4, "emoji") + assert menu_button.to_dict() == {"type": menu_button.type} + + def test_equality(self, menu_button): + a = menu_button + b = MenuButton(self.type) + c = MenuButton("unknown") + d = Dice(5, "test") assert a == b assert hash(a) == hash(b) @@ -161,27 +98,130 @@ def test_equality(self, menu_button, bot): assert a != d assert hash(a) != hash(d) - assert a != e - assert hash(a) != hash(e) - assert c == d - assert hash(c) == hash(d) +@pytest.fixture +def menu_button_commands(): + return MenuButtonCommands() + + +class TestMenuButtonCommandsWithoutRequest(MenuButtonTestBase): + type = MenuButtonType.COMMANDS + + def test_slot_behaviour(self, menu_button_commands): + inst = menu_button_commands + 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, offline_bot): + transaction_partner = MenuButtonCommands.de_json({}, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "commands" + + def test_to_dict(self, menu_button_commands): + assert menu_button_commands.to_dict() == {"type": menu_button_commands.type} + + def test_equality(self, menu_button_commands): + a = menu_button_commands + b = MenuButtonCommands() + c = Dice(5, "test") + d = MenuButtonDefault() + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def menu_button_default(): + return MenuButtonDefault() + - assert c != e - assert hash(c) != hash(e) +class TestMenuButtonDefaultWithoutRequest(MenuButtonTestBase): + type = MenuButtonType.DEFAULT - if hasattr(c, "web_app"): - json_dict = c.to_dict() - json_dict["web_app"] = WebAppInfo("https://foo.bar/web_app").to_dict() - f = c.__class__.de_json(json_dict, bot) + def test_slot_behaviour(self, menu_button_default): + inst = menu_button_default + 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" - assert c != f - assert hash(c) != hash(f) + def test_de_json(self, offline_bot): + transaction_partner = MenuButtonDefault.de_json({}, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "default" - if hasattr(c, "text"): - json_dict = c.to_dict() - json_dict["text"] = "other text" - g = c.__class__.de_json(json_dict, bot) + def test_to_dict(self, menu_button_default): + assert menu_button_default.to_dict() == {"type": menu_button_default.type} - assert c != g - assert hash(c) != hash(g) + def test_equality(self, menu_button_default): + a = menu_button_default + b = MenuButtonDefault() + c = Dice(5, "test") + d = MenuButtonCommands() + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def menu_button_web_app(): + return MenuButtonWebApp( + web_app=TestMenuButtonWebAppWithoutRequest.web_app, + text=TestMenuButtonWebAppWithoutRequest.text, + ) + + +class TestMenuButtonWebAppWithoutRequest(MenuButtonTestBase): + type = MenuButtonType.WEB_APP + + def test_slot_behaviour(self, menu_button_web_app): + inst = menu_button_web_app + 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, offline_bot): + json_dict = {"web_app": self.web_app.to_dict(), "text": self.text} + transaction_partner = MenuButtonWebApp.de_json(json_dict, offline_bot) + assert transaction_partner.api_kwargs == {} + assert transaction_partner.type == "web_app" + assert transaction_partner.web_app == self.web_app + assert transaction_partner.text == self.text + + def test_to_dict(self, menu_button_web_app): + assert menu_button_web_app.to_dict() == { + "type": menu_button_web_app.type, + "web_app": menu_button_web_app.web_app.to_dict(), + "text": menu_button_web_app.text, + } + + def test_equality(self, menu_button_web_app): + a = menu_button_web_app + b = MenuButtonWebApp(web_app=self.web_app, text=self.text) + c = MenuButtonWebApp(web_app=self.web_app, text="other text") + d = MenuButtonWebApp(web_app=WebAppInfo(url="https://example.org"), text=self.text) + e = Dice(5, "test") + + 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) diff --git a/tests/test_message.py b/tests/test_message.py index 86b3c0fa470..218b210f991 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,36 +16,78 @@ # # 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 copy -from datetime import datetime + +import datetime as dtm +from copy import copy, deepcopy import pytest from telegram import ( Animation, Audio, + BackgroundTypeChatTheme, Bot, Chat, + ChatBackground, + ChatBoostAdded, ChatShared, + Checklist, + ChecklistTask, + ChecklistTasksAdded, + ChecklistTasksDone, Contact, Dice, + DirectMessagePriceChanged, Document, + ExternalReplyInfo, Game, + Gift, + GiftInfo, + Giveaway, + GiveawayCompleted, + GiveawayCreated, + GiveawayWinners, + InputChecklist, + InputChecklistTask, + InputPaidMediaPhoto, Invoice, + LinkPreviewOptions, Location, Message, MessageAutoDeleteTimerChanged, MessageEntity, + MessageOriginChat, + PaidMediaInfo, + PaidMediaPreview, + PaidMessagePriceChanged, PassportData, PhotoSize, Poll, PollOption, ProximityAlertTriggered, + RefundedPayment, + ReplyParameters, + SharedUser, Sticker, + Story, SuccessfulPayment, + SuggestedPostApprovalFailed, + SuggestedPostApproved, + SuggestedPostDeclined, + SuggestedPostInfo, + SuggestedPostPaid, + SuggestedPostPrice, + SuggestedPostRefunded, + TextQuote, + UniqueGift, + UniqueGiftBackdrop, + UniqueGiftBackdropColors, + UniqueGiftInfo, + UniqueGiftModel, + UniqueGiftSymbol, Update, User, - UserShared, + UsersShared, Venue, Video, VideoChatEnded, @@ -56,7 +98,10 @@ Voice, WebAppData, ) +from telegram._directmessagestopic import DirectMessagesTopic from telegram._utils.datetime import UTC +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram._utils.types import ODVInput from telegram.constants import ChatAction, ParseMode from telegram.ext import Defaults from telegram.warnings import PTBDeprecationWarning @@ -66,16 +111,20 @@ check_shortcut_call, check_shortcut_signature, ) +from tests.auxil.build_messages import make_message +from tests.auxil.dummy_objects import get_dummy_object_json_dict +from tests.auxil.pytest_classes import PytestExtBot, PytestMessage from tests.auxil.slots import mro_slots -@pytest.fixture(scope="module") +@pytest.fixture def message(bot): - message = Message( - message_id=TestMessageBase.id_, - date=TestMessageBase.date, - chat=copy(TestMessageBase.chat), - from_user=copy(TestMessageBase.from_user), + message = PytestMessage( + message_id=MessageTestBase.id_, + date=MessageTestBase.date, + chat=copy(MessageTestBase.chat), + from_user=copy(MessageTestBase.from_user), + business_connection_id="123456789", ) message.set_bot(bot) message._unfreeze() @@ -86,18 +135,12 @@ def message(bot): @pytest.fixture( params=[ - {"forward_from": User(99, "forward_user", False), "forward_date": datetime.utcnow()}, - { - "forward_from_chat": Chat(-23, "channel"), - "forward_from_message_id": 101, - "forward_date": datetime.utcnow(), - }, { "reply_to_message": Message( - 50, datetime.utcnow(), Chat(13, "channel"), User(9, "i", False) + 50, dtm.datetime.utcnow(), Chat(13, "channel"), User(9, "i", False) ) }, - {"edit_date": datetime.utcnow()}, + {"edit_date": dtm.datetime.utcnow()}, { "text": "a text message", "entities": [MessageEntity("bold", 10, 4), MessageEntity("italic", 16, 7)], @@ -123,6 +166,7 @@ def message(bot): }, {"photo": [PhotoSize("photo_id", "unique_id", 50, 50)], "caption": "photo_file"}, {"sticker": Sticker("sticker_id", "unique_id", 50, 50, True, False, Sticker.REGULAR)}, + {"story": Story(Chat(1, Chat.PRIVATE), 0)}, {"video": Video("video_id", "unique_id", 12, 12, 12), "caption": "video_file"}, {"voice": Voice("voice_id", "unique_id", 5)}, {"video_note": VideoNote("video_note_id", "unique_id", 20, 12)}, @@ -142,7 +186,7 @@ def message(bot): {"migrate_from_chat_id": -54321}, { "pinned_message": Message( - 7, datetime.utcnow(), Chat(13, "channel"), User(9, "i", False) + 7, dtm.datetime.utcnow(), Chat(13, "channel"), User(9, "i", False) ) }, {"invoice": Invoice("my invoice", "invoice", "start", "EUR", 243)}, @@ -152,7 +196,6 @@ def message(bot): ) }, {"connected_website": "http://example.com/"}, - {"forward_signature": "some_forward_sign"}, {"author_signature": "some_author_sign"}, { "photo": [PhotoSize("photo_id", "unique_id", 50, 50)], @@ -192,7 +235,7 @@ def message(bot): User(1, "John", False), User(2, "Doe", False), 42 ) }, - {"video_chat_scheduled": VideoChatScheduled(datetime.utcnow())}, + {"video_chat_scheduled": VideoChatScheduled(dtm.datetime.utcnow())}, {"video_chat_started": VideoChatStarted()}, {"video_chat_ended": VideoChatEnded(100)}, { @@ -211,12 +254,184 @@ def message(bot): }, {"web_app_data": WebAppData("some_data", "some_button_text")}, {"message_thread_id": 123}, - {"user_shared": UserShared(1, 2)}, + {"users_shared": UsersShared(1, users=[SharedUser(2, "user2"), SharedUser(3, "user3")])}, {"chat_shared": ChatShared(3, 4)}, + { + "gift": GiftInfo( + gift=Gift( + "gift_id", + Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular"), + 5, + ) + ) + }, + { + "unique_gift": UniqueGiftInfo( + gift=UniqueGift( + gift_id="gift_id", + base_name="human_readable_name", + name="unique_name", + number=2, + model=UniqueGiftModel( + "model_name", + Sticker("file_id1", "file_unique_id1", 512, 512, False, False, "regular"), + 10, + ), + symbol=UniqueGiftSymbol( + "symbol_name", + Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + 20, + ), + backdrop=UniqueGiftBackdrop( + "backdrop_name", + UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F), + 30, + ), + ), + origin=UniqueGiftInfo.UPGRADE, + owned_gift_id="id", + transfer_star_count=10, + ) + }, + { + "giveaway": Giveaway( + chats=[Chat(1, Chat.SUPERGROUP)], + winners_selection_date=dtm.datetime.utcnow().replace(microsecond=0), + winner_count=5, + ) + }, + {"giveaway_created": GiveawayCreated(prize_star_count=99)}, + { + "giveaway_winners": GiveawayWinners( + chat=Chat(1, Chat.CHANNEL), + giveaway_message_id=123456789, + winners_selection_date=dtm.datetime.utcnow().replace(microsecond=0), + winner_count=42, + winners=[User(1, "user1", False), User(2, "user2", False)], + ) + }, + { + "giveaway_completed": GiveawayCompleted( + winner_count=42, + unclaimed_prize_count=4, + giveaway_message=make_message(text="giveaway_message"), + ) + }, + { + "link_preview_options": LinkPreviewOptions( + is_disabled=True, + url="https://python-telegram-bot.org", + prefer_small_media=True, + prefer_large_media=True, + show_above_text=True, + ) + }, + { + "external_reply": ExternalReplyInfo( + MessageOriginChat(dtm.datetime.utcnow(), Chat(1, Chat.PRIVATE)) + ) + }, + {"quote": TextQuote("a text quote", 1)}, + {"forward_origin": MessageOriginChat(dtm.datetime.utcnow(), Chat(1, Chat.PRIVATE))}, + {"reply_to_story": Story(Chat(1, Chat.PRIVATE), 0)}, + {"boost_added": ChatBoostAdded(100)}, + {"sender_boost_count": 1}, + {"is_from_offline": True}, + {"sender_business_bot": User(1, "BusinessBot", True)}, + {"business_connection_id": "123456789"}, + {"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")}, + {"paid_star_count": 291}, + {"paid_message_price_changed": PaidMessagePriceChanged(291)}, + {"direct_message_price_changed": DirectMessagePriceChanged(True, 100)}, + { + "checklist": Checklist( + "checklist_id", + tasks=[ChecklistTask(id=42, text="task 1"), ChecklistTask(id=43, text="task 2")], + ) + }, + { + "checklist_tasks_done": ChecklistTasksDone( + marked_as_done_task_ids=[1, 2, 3], + marked_as_not_done_task_ids=[4, 5], + ) + }, + { + "checklist_tasks_added": ChecklistTasksAdded( + tasks=[ChecklistTask(id=42, text="task 1"), ChecklistTask(id=43, text="task 2")], + ) + }, + {"is_paid_post": True}, + { + "direct_messages_topic": DirectMessagesTopic( + topic_id=1234, + user=User(id=5678, first_name="TestUser", is_bot=False), + ) + }, + {"reply_to_checklist_task_id": 11}, + { + "suggested_post_declined": SuggestedPostDeclined( + suggested_post_message=Message( + 7, dtm.datetime.utcnow(), Chat(13, "channel"), User(9, "i", False) + ), + comment="comment", + ) + }, + { + "suggested_post_paid": SuggestedPostPaid( + currency="XTR", + suggested_post_message=Message( + 7, dtm.datetime.utcnow(), Chat(13, "channel"), User(9, "i", False) + ), + amount=100, + ) + }, + { + "suggested_post_refunded": SuggestedPostRefunded( + reason="post_deleted", + suggested_post_message=Message( + 7, dtm.datetime.utcnow(), Chat(13, "channel"), User(9, "i", False) + ), + ) + }, + { + "suggested_post_approved": SuggestedPostApproved( + send_date=dtm.datetime.utcnow(), + price=SuggestedPostPrice(currency="XTR", amount=100), + suggested_post_message=Message( + 7, dtm.datetime.utcnow(), Chat(13, "channel"), User(9, "i", False) + ), + ) + }, + { + "suggested_post_approval_failed": SuggestedPostApprovalFailed( + price=SuggestedPostPrice(currency="XTR", amount=100), + suggested_post_message=Message( + 7, dtm.datetime.utcnow(), Chat(13, "channel"), User(9, "i", False) + ), + ) + }, + { + "suggested_post_info": SuggestedPostInfo( + state="pending", + price=SuggestedPostPrice(currency="XTR", amount=100), + send_date=dtm.datetime.utcnow(), + ) + }, + { + "gift_upgrade_sent": GiftInfo( + gift=Gift( + "gift_id", + Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular"), + 5, + ) + ) + }, ], ids=[ - "forwarded_user", - "forwarded_channel", "reply", "edited", "text", @@ -227,6 +442,7 @@ def message(bot): "game", "photo", "sticker", + "story", "video", "voice", "video_note", @@ -248,7 +464,6 @@ def message(bot): "invoice", "successful_payment", "connected_website", - "forward_signature", "author_signature", "photo_from_media_group", "passport_data", @@ -267,26 +482,63 @@ def message(bot): "entities", "web_app_data", "message_thread_id", - "user_shared", + "users_shared", "chat_shared", + "gift", + "unique_gift", + "giveaway", + "giveaway_created", + "giveaway_winners", + "giveaway_completed", + "link_preview_options", + "external_reply", + "quote", + "forward_origin", + "reply_to_story", + "boost_added", + "sender_boost_count", + "sender_business_bot", + "business_connection_id", + "is_from_offline", + "chat_background_set", + "effect_id", + "show_caption_above_media", + "paid_media", + "refunded_payment", + "paid_star_count", + "paid_message_price_changed", + "direct_message_price_changed", + "checklist", + "checklist_tasks_done", + "checklist_tasks_added", + "is_paid_post", + "direct_messages_topic", + "reply_to_checklist_task_id", + "suggested_post_declined", + "suggested_post_paid", + "suggested_post_refunded", + "suggested_post_approved", + "suggested_post_approval_failed", + "suggested_post_info", + "gift_upgrade_sent", ], ) def message_params(bot, request): message = Message( - message_id=TestMessageBase.id_, - from_user=TestMessageBase.from_user, - date=TestMessageBase.date, - chat=TestMessageBase.chat, + message_id=MessageTestBase.id_, + from_user=MessageTestBase.from_user, + date=MessageTestBase.date, + chat=MessageTestBase.chat, **request.param, ) message.set_bot(bot) return message -class TestMessageBase: +class MessageTestBase: id_ = 1 from_user = User(2, "testuser", False) - date = datetime.utcnow() + date = dtm.datetime.utcnow() chat = Chat(3, "private") test_entities = [ {"length": 4, "offset": 10, "type": "bold"}, @@ -324,10 +576,14 @@ class TestMessageBase: {"length": 10, "offset": 129, "type": "pre", "language": "python"}, {"length": 7, "offset": 141, "type": "spoiler"}, {"length": 2, "offset": 150, "type": "custom_emoji", "custom_emoji_id": "1"}, + {"length": 34, "offset": 154, "type": "blockquote"}, + {"length": 6, "offset": 181, "type": "bold"}, + {"length": 33, "offset": 190, "type": "expandable_blockquote"}, ] test_text_v2 = ( r"Test for trgh nested in italic. Python pre. Spoiled. 👍." + "http://google.com and bold nested in strk>trgh nested in italic. Python pre. Spoiled. " + "👍.\nMultiline\nblock quote\nwith nested.\n\nMultiline\nexpandable\nblock quote." ) test_message = Message( message_id=1, @@ -351,14 +607,179 @@ class TestMessageBase: ) -class TestMessageWithoutRequest(TestMessageBase): - def test_slot_behaviour(self, message): +class TestMessageWithoutRequest(MessageTestBase): + async def check_quote_parsing( + self, message: Message, method, bot_method_name: str, args, monkeypatch + ): + """Used in testing reply_* below. Makes sure that do_quote is handled correctly""" + with pytest.raises( + ValueError, + match="`reply_to_message_id` and `reply_parameters` are mutually exclusive\\.", + ): + await method(*args, reply_to_message_id=42, reply_parameters=42) + + with pytest.raises( + ValueError, + match="`allow_sending_without_reply` and `reply_parameters` are mutually exclusive\\.", + ): + await method(*args, allow_sending_without_reply=True, reply_parameters=42) + + async def make_assertion(*args, **kwargs): + return kwargs.get("chat_id"), kwargs.get("reply_parameters") + + monkeypatch.setattr(message.get_bot(), bot_method_name, make_assertion) + + for aswr in (DEFAULT_NONE, True): + await self._check_quote_parsing( + message=message, + method=method, + bot_method_name=bot_method_name, + args=args, + monkeypatch=monkeypatch, + aswr=aswr, + ) + + @staticmethod + async def _check_quote_parsing( + message: Message, method, bot_method_name: str, args, monkeypatch, aswr + ): + # test that boolean input for do_quote is parse correctly + for value in (True, False): + chat_id, reply_parameters = await method( + *args, do_quote=value, allow_sending_without_reply=aswr + ) + if chat_id != message.chat.id: + pytest.fail(f"chat_id is {chat_id} but should be {message.chat.id}") + expected = ( + ReplyParameters(message.message_id, allow_sending_without_reply=aswr) + if value + else None + ) + if reply_parameters != expected: + pytest.fail(f"reply_parameters is {reply_parameters} but should be {expected}") + + # test that dict input for do_quote is parsed correctly + input_chat_id = object() + input_reply_parameters = ReplyParameters(message_id=1, chat_id=42) + coro = method( + *args, + do_quote={"chat_id": input_chat_id, "reply_parameters": input_reply_parameters}, + allow_sending_without_reply=aswr, + ) + if aswr is True: + with pytest.raises( + ValueError, + match="`allow_sending_without_reply` and `dict`-value input", + ): + await coro + else: + chat_id, reply_parameters = await coro + if chat_id is not input_chat_id: + pytest.fail(f"chat_id is {chat_id} but should be {input_chat_id}") + if reply_parameters is not input_reply_parameters: + pytest.fail( + f"reply_parameters is {reply_parameters} " + f"but should be {input_reply_parameters}" + ) + + # test that do_quote input is overridden by reply_parameters + input_parameters_2 = ReplyParameters( + message_id=message.message_id + 1, chat_id=message.chat_id + 1 + ) + chat_id, reply_parameters = await method( + *args, + reply_parameters=input_parameters_2, + # passing these here to make sure that `reply_parameters` has higher priority + do_quote={"chat_id": input_chat_id, "reply_parameters": input_reply_parameters}, + ) + if chat_id is not message.chat.id: + pytest.fail(f"chat_id is {chat_id} but should be {message.chat.id}") + if reply_parameters is not input_parameters_2: + pytest.fail( + f"reply_parameters is {reply_parameters} but should be {input_parameters_2}" + ) + + # test that do_quote input is overridden by reply_to_message_id + chat_id, reply_parameters = await method( + *args, + reply_to_message_id=42, + # passing these here to make sure that `reply_to_message_id` has higher priority + do_quote={"chat_id": input_chat_id, "reply_parameters": input_reply_parameters}, + allow_sending_without_reply=aswr, + ) + if chat_id != message.chat.id: + pytest.fail(f"chat_id is {chat_id} but should be {message.chat.id}") + if reply_parameters is None or reply_parameters.message_id != 42: + pytest.fail(f"reply_parameters is {reply_parameters} but should be 42") + if reply_parameters is None or reply_parameters.allow_sending_without_reply != aswr: + pytest.fail( + f"reply_parameters.allow_sending_without_reply is " + f"{reply_parameters.allow_sending_without_reply} it should be {aswr}" + ) + + @staticmethod + async def check_thread_id_parsing( + message: Message, method, bot_method_name: str, args, monkeypatch + ): + """Used in testing reply_* below. Makes sure that meassage_thread_id is parsed + correctly.""" + + async def extract_message_thread_id(*args, **kwargs): + return kwargs.get("message_thread_id") + + monkeypatch.setattr(message.get_bot(), bot_method_name, extract_message_thread_id) + + for is_topic_message in (True, False): + message.is_topic_message = is_topic_message + + message.message_thread_id = None + message_thread_id = await method(*args) + assert message_thread_id is None + + message.message_thread_id = 99 + message_thread_id = await method(*args) + assert message_thread_id == (99 if is_topic_message else None) + + message_thread_id = await method(*args, message_thread_id=50) + assert message_thread_id == 50 + + message_thread_id = await method(*args, message_thread_id=None) + assert message_thread_id is None + + # These methods do not accept `do_quote` as passed below + if bot_method_name in ["send_chat_action", "send_message_draft"]: + return + + message_thread_id = await method( + *args, + do_quote=message.build_reply_arguments( + target_chat_id=123, + ), + ) + assert message_thread_id is None + + for target_chat_id in (message.chat_id, message.chat.username): + message_thread_id = await method( + *args, + do_quote=message.build_reply_arguments( + target_chat_id=target_chat_id, + ), + ) + assert message_thread_id == (message.message_thread_id if is_topic_message else None) + + def test_slot_behaviour(self): + message = Message( + message_id=MessageTestBase.id_, + date=MessageTestBase.date, + chat=copy(MessageTestBase.chat), + from_user=copy(MessageTestBase.from_user), + ) for attr in message.__slots__: assert getattr(message, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(message)) == len(set(mro_slots(message))), "duplicate slot" - def test_all_possibilities_de_json_and_to_dict(self, bot, message_params): - new = Message.de_json(message_params.to_dict(), bot) + def test_all_possibilities_de_json_and_to_dict(self, offline_bot, message_params): + new = Message.de_json(message_params.to_dict(), offline_bot) assert new.api_kwargs == {} assert new.to_dict() == message_params.to_dict() @@ -368,18 +789,17 @@ def test_all_possibilities_de_json_and_to_dict(self, bot, message_params): for slot in new.__slots__: assert not isinstance(new[slot], dict) - def test_de_json_localization(self, bot, raw_bot, tz_bot): + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): json_dict = { "message_id": 12, - "from_user": None, - "date": int(datetime.now().timestamp()), - "chat": None, - "edit_date": int(datetime.now().timestamp()), - "forward_date": int(datetime.now().timestamp()), + "from_user": get_dummy_object_json_dict("User"), + "date": int(dtm.datetime.now().timestamp()), + "chat": get_dummy_object_json_dict("Chat"), + "edit_date": int(dtm.datetime.now().timestamp()), } message_raw = Message.de_json(json_dict, raw_bot) - message_bot = Message.de_json(json_dict, bot) + message_bot = Message.de_json(json_dict, offline_bot) message_tz = Message.de_json(json_dict, tz_bot) # comparing utcoffsets because comparing timezones is unpredicatable @@ -391,11 +811,6 @@ def test_de_json_localization(self, bot, raw_bot, tz_bot): message_tz.edit_date.replace(tzinfo=None) ) - forward_date_offset = message_tz.forward_date.utcoffset() - forward_date_tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( - message_tz.forward_date.replace(tzinfo=None) - ) - assert message_raw.date.tzinfo == UTC assert message_bot.date.tzinfo == UTC assert date_offset == date_tz_bot_offset @@ -404,9 +819,21 @@ def test_de_json_localization(self, bot, raw_bot, tz_bot): assert message_bot.edit_date.tzinfo == UTC assert edit_date_offset == edit_date_tz_bot_offset - assert message_raw.forward_date.tzinfo == UTC - assert message_bot.forward_date.tzinfo == UTC - assert forward_date_offset == forward_date_tz_bot_offset + def test_de_json_api_kwargs_backward_compatibility(self, offline_bot, message_params): + message_dict = message_params.to_dict() + keys = ( + "user_shared", + "forward_from", + "forward_from_chat", + "forward_from_message_id", + "forward_signature", + "forward_sender_name", + "forward_date", + ) + for key in keys: + message_dict[key] = key + message = Message.de_json(message_dict, offline_bot) + assert message.api_kwargs == {key: key for key in keys} def test_equality(self): id_ = 1 @@ -429,6 +856,12 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) + def test_bool(self, message, recwarn): + # Relevant as long as we override MaybeInaccessibleMessage.__bool__ + # Can be removed once that's removed + assert bool(message) is True + assert len(recwarn) == 0 + async def test_parse_entity(self): text = ( b"\\U0001f469\\u200d\\U0001f469\\u200d\\U0001f467" @@ -516,7 +949,9 @@ def test_text_html_simple(self): "and bold nested in strk>trgh nested in italic. " '
Python pre
. ' 'Spoiled. ' - '👍.' + '👍.\n' + "
Multiline\nblock quote\nwith nested.
\n\n" + "
Multiline\nexpandable\nblock quote.
" ) text_html = self.test_message_v2.text_html assert text_html == test_html_string @@ -536,7 +971,9 @@ def test_text_html_urled(self): "and bold nested in strk>trgh nested in italic. " '
Python pre
. ' 'Spoiled. ' - '👍.' + '👍.\n' + "
Multiline\nblock quote\nwith nested.
\n\n" + "
Multiline\nexpandable\nblock quote.
" ) text_html = self.test_message_v2.text_html_urled assert text_html == test_html_string @@ -557,12 +994,28 @@ def test_text_markdown_v2_simple(self): "[links](http://github.com/abc\\\\\\)def), " "[text\\-mention](tg://user?id=123456789) and ```\\`\\\\pre```\\. " r"http://google\.com and _bold *nested in ~strk\>trgh~ nested in* italic_\. " - "```python\nPython pre```\\. ||Spoiled||\\. ![👍](tg://emoji?id=1)\\." + "```python\nPython pre```\\. ||Spoiled||\\. ![👍](tg://emoji?id=1)\\.\n" + ">Multiline\n" + ">block quote\n" + r">with *nested*\." + "\n\n>Multiline\n" + ">expandable\n" + r">block quote\.||" ) text_markdown = self.test_message_v2.text_markdown_v2 assert text_markdown == test_md_string - def test_text_markdown_new_in_v2(self, message): + @pytest.mark.parametrize( + "entity_type", + [ + MessageEntity.UNDERLINE, + MessageEntity.STRIKETHROUGH, + MessageEntity.SPOILER, + MessageEntity.BLOCKQUOTE, + MessageEntity.CUSTOM_EMOJI, + ], + ) + def test_text_markdown_new_in_v2(self, message, entity_type): message.text = "test" message.entities = [ MessageEntity(MessageEntity.BOLD, offset=0, length=4), @@ -571,16 +1024,8 @@ def test_text_markdown_new_in_v2(self, message): with pytest.raises(ValueError, match="Nested entities are not supported for"): assert message.text_markdown - message.entities = [MessageEntity(MessageEntity.UNDERLINE, offset=0, length=4)] - with pytest.raises(ValueError, match="Underline entities are not supported for"): - message.text_markdown - - message.entities = [MessageEntity(MessageEntity.STRIKETHROUGH, offset=0, length=4)] - with pytest.raises(ValueError, match="Strikethrough entities are not supported for"): - message.text_markdown - - message.entities = [MessageEntity(MessageEntity.SPOILER, offset=0, length=4)] - with pytest.raises(ValueError, match="Spoiler entities are not supported for"): + message.entities = [MessageEntity(entity_type, offset=0, length=4)] + with pytest.raises(ValueError, match="entities are not supported for"): message.text_markdown message.entities = [] @@ -608,7 +1053,13 @@ def test_text_markdown_v2_urled(self): "[text\\-mention](tg://user?id=123456789) and ```\\`\\\\pre```\\. " r"[http://google\.com](http://google.com) and _bold *nested in ~strk\>trgh~ " "nested in* italic_\\. ```python\nPython pre```\\. ||Spoiled||\\. " - "![👍](tg://emoji?id=1)\\." + "![👍](tg://emoji?id=1)\\.\n" + ">Multiline\n" + ">block quote\n" + r">with *nested*\." + "\n\n>Multiline\n" + ">expandable\n" + r">block quote\.||" ) text_markdown = self.test_message_v2.text_markdown_v2_urled assert text_markdown == test_md_string @@ -645,7 +1096,6 @@ def test_text_markdown_emoji(self): ) def test_text_custom_emoji_md_v1(self, type_, recwarn): text = "Look a custom emoji: 😎" - expected = "Look a custom emoji: 😎" emoji_entity = MessageEntity( type=MessageEntity.CUSTOM_EMOJI, offset=21, @@ -660,14 +1110,8 @@ def test_text_custom_emoji_md_v1(self, type_, recwarn): text=text, entities=[emoji_entity], ) - assert expected == getattr(message, type_) - - assert len(recwarn) == 1 - assert recwarn[0].category is PTBDeprecationWarning - assert str(recwarn[0].message).startswith( - "Custom emoji entities are not supported for Markdown version 1" - ) - assert recwarn[0].filename == __file__ + with pytest.raises(ValueError, match="Custom Emoji entities are not supported for"): + getattr(message, type_) @pytest.mark.parametrize( "type_", @@ -731,7 +1175,9 @@ def test_caption_html_simple(self): "and bold nested in strk>trgh nested in italic. " '
Python pre
. ' 'Spoiled. ' - '👍.' + '👍.\n' + "
Multiline\nblock quote\nwith nested.
\n\n" + "
Multiline\nexpandable\nblock quote.
" ) caption_html = self.test_message_v2.caption_html assert caption_html == test_html_string @@ -751,7 +1197,9 @@ def test_caption_html_urled(self): "and bold nested in strk>trgh nested in italic. " '
Python pre
. ' 'Spoiled. ' - '👍.' + '👍.\n' + "
Multiline\nblock quote\nwith nested.
\n\n" + "
Multiline\nexpandable\nblock quote.
" ) caption_html = self.test_message_v2.caption_html_urled assert caption_html == test_html_string @@ -772,7 +1220,13 @@ def test_caption_markdown_v2_simple(self): "[links](http://github.com/abc\\\\\\)def), " "[text\\-mention](tg://user?id=123456789) and ```\\`\\\\pre```\\. " r"http://google\.com and _bold *nested in ~strk\>trgh~ nested in* italic_\. " - "```python\nPython pre```\\. ||Spoiled||\\. ![👍](tg://emoji?id=1)\\." + "```python\nPython pre```\\. ||Spoiled||\\. ![👍](tg://emoji?id=1)\\.\n" + ">Multiline\n" + ">block quote\n" + r">with *nested*\." + "\n\n>Multiline\n" + ">expandable\n" + r">block quote\.||" ) caption_markdown = self.test_message_v2.caption_markdown_v2 assert caption_markdown == test_md_string @@ -800,7 +1254,13 @@ def test_caption_markdown_v2_urled(self): "[text\\-mention](tg://user?id=123456789) and ```\\`\\\\pre```\\. " r"[http://google\.com](http://google.com) and _bold *nested in ~strk\>trgh~ " "nested in* italic_\\. ```python\nPython pre```\\. ||Spoiled||\\. " - "![👍](tg://emoji?id=1)\\." + "![👍](tg://emoji?id=1)\\.\n" + ">Multiline\n" + ">block quote\n" + r">with *nested*\." + "\n\n>Multiline\n" + ">expandable\n" + r">block quote\.||" ) caption_markdown = self.test_message_v2.caption_markdown_v2_urled assert caption_markdown == test_md_string @@ -842,7 +1302,6 @@ def test_caption_markdown_emoji(self): ) def test_caption_custom_emoji_md_v1(self, type_, recwarn): caption = "Look a custom emoji: 😎" - expected = "Look a custom emoji: 😎" emoji_entity = MessageEntity( type=MessageEntity.CUSTOM_EMOJI, offset=21, @@ -857,14 +1316,8 @@ def test_caption_custom_emoji_md_v1(self, type_, recwarn): caption=caption, caption_entities=[emoji_entity], ) - assert expected == getattr(message, type_) - - assert len(recwarn) == 1 - assert recwarn[0].category is PTBDeprecationWarning - assert str(recwarn[0].message).startswith( - "Custom emoji entities are not supported for Markdown version 1" - ) - assert recwarn[0].filename == __file__ + with pytest.raises(ValueError, match="Custom Emoji entities are not supported for"): + getattr(message, type_) @pytest.mark.parametrize( "type_", @@ -950,16 +1403,20 @@ def test_link_with_id(self, message, type_, id_): # The leading - for group ids/ -100 for supergroup ids isn't supposed to be in the link assert message.link == f"https://t.me/c/{3}/{message.message_id}" - def test_link_with_topics(self, message): + @pytest.mark.parametrize("type_", argvalues=[Chat.SUPERGROUP, Chat.CHANNEL]) + def test_link_with_topics(self, message, type_): message.chat.username = None message.chat.id = -1003 + message.chat.type = type_ message.is_topic_message = True message.message_thread_id = 123 assert message.link == f"https://t.me/c/3/{message.message_id}?thread=123" - def test_link_with_reply(self, message): + @pytest.mark.parametrize("type_", argvalues=[Chat.SUPERGROUP, Chat.CHANNEL]) + def test_link_with_reply(self, message, type_): message.chat.username = None message.chat.id = -1003 + message.chat.type = type_ message.reply_to_message = Message(7, self.from_user, self.date, self.chat, text="Reply") message.message_thread_id = 123 assert message.link == f"https://t.me/c/3/{message.message_id}?thread=123" @@ -985,10 +1442,12 @@ def test_effective_attachment(self, message_params): "game", "invoice", "location", + "paid_media", "passport_data", "photo", "poll", "sticker", + "story", "successful_payment", "video", "video_note", @@ -1012,26 +1471,213 @@ def test_effective_attachment(self, message_params): ) assert not condition, "effective_attachment was None even though it should not be" + def test_compute_quote_position_and_entities_false_index(self, message): + message.text = "AA" + with pytest.raises( + ValueError, + match="You requested the 5-th occurrence of 'A', but this text appears only 2 times", + ): + message.compute_quote_position_and_entities("A", 5) + + def test_compute_quote_position_and_entities_no_text_or_caption(self, message): + message.text = None + message.caption = None + with pytest.raises( + RuntimeError, + match="This message has neither text nor caption\\.", + ): + message.compute_quote_position_and_entities("A", 5) + + @pytest.mark.parametrize( + ("text", "quote", "index", "expected"), + argvalues=[ + ("AA", "A", None, 0), + ("AA", "A", 0, 0), + ("AA", "A", 1, 1), + ("ABC ABC ABC ABC", "ABC", None, 0), + ("ABC ABC ABC ABC", "ABC", 0, 0), + ("ABC ABC ABC ABC", "ABC", 3, 12), + ("👨‍👨‍👧👨‍👨‍👧👨‍👨‍👧👨‍👨‍👧", "👨‍👨‍👧", 0, 0), + ("👨‍👨‍👧👨‍👨‍👧👨‍👨‍👧👨‍👨‍👧", "👨‍👨‍👧", 3, 24), + ("👨‍👨‍👧👨‍👨‍👧👨‍👨‍👧👨‍👨‍👧", "👨", 1, 3), + ("👨‍👨‍👧👨‍👨‍👧👨‍👨‍👧👨‍👨‍👧", "👧", 2, 22), + ], + ) + @pytest.mark.parametrize("caption", [True, False]) + def test_compute_quote_position_and_entities_position( + self, message, text, quote, index, expected, caption + ): + if caption: + message.caption = text + message.text = None + else: + message.text = text + message.caption = None + + assert message.compute_quote_position_and_entities(quote, index)[0] == expected + + def test_compute_quote_position_and_entities_entities(self, message): + message.text = "A A A" + message.entities = () + assert message.compute_quote_position_and_entities("A", 0)[1] is None + + message.entities = ( + # covers complete string + MessageEntity(type=MessageEntity.BOLD, offset=0, length=6), + # covers first 2 As only + MessageEntity(type=MessageEntity.ITALIC, offset=0, length=3), + # covers second 2 As only + MessageEntity(type=MessageEntity.UNDERLINE, offset=2, length=3), + # covers middle A only + MessageEntity(type=MessageEntity.STRIKETHROUGH, offset=2, length=1), + # covers only whitespace, should be ignored + MessageEntity(type=MessageEntity.CODE, offset=1, length=1), + ) + + assert message.compute_quote_position_and_entities("A", 0)[1] == ( + MessageEntity(type=MessageEntity.BOLD, offset=0, length=1), + MessageEntity(type=MessageEntity.ITALIC, offset=0, length=1), + ) + + assert message.compute_quote_position_and_entities("A", 1)[1] == ( + MessageEntity(type=MessageEntity.BOLD, offset=0, length=1), + MessageEntity(type=MessageEntity.ITALIC, offset=0, length=1), + MessageEntity(type=MessageEntity.UNDERLINE, offset=0, length=1), + MessageEntity(type=MessageEntity.STRIKETHROUGH, offset=0, length=1), + ) + + assert message.compute_quote_position_and_entities("A", 2)[1] == ( + MessageEntity(type=MessageEntity.BOLD, offset=0, length=1), + MessageEntity(type=MessageEntity.UNDERLINE, offset=0, length=1), + ) + + @pytest.mark.parametrize( + ("target_chat_id", "expected"), + argvalues=[ + (None, 3), + (3, 3), + (-1003, -1003), + ("@username", "@username"), + ], + ) + def test_build_reply_arguments_chat_id_and_message_id(self, message, target_chat_id, expected): + message.chat.id = 3 + reply_kwargs = message.build_reply_arguments(target_chat_id=target_chat_id) + assert reply_kwargs["chat_id"] == expected + assert reply_kwargs["reply_parameters"].chat_id == (None if expected == 3 else 3) + assert reply_kwargs["reply_parameters"].message_id == message.message_id + + @pytest.mark.parametrize( + ("target_chat_id", "message_thread_id", "expected"), + argvalues=[ + (None, None, True), + (None, 123, True), + (None, 0, False), + (None, -1, False), + (3, None, True), + (3, 123, True), + (3, 0, False), + (3, -1, False), + (-1003, None, False), + (-1003, 123, False), + (-1003, 0, False), + (-1003, -1, False), + ("@username", None, True), + ("@username", 123, True), + ("@username", 0, False), + ("@username", -1, False), + ("@other_username", None, False), + ("@other_username", 123, False), + ("@other_username", 0, False), + ("@other_username", -1, False), + ], + ) + def test_build_reply_arguments_aswr( + self, message, target_chat_id, message_thread_id, expected + ): + message.chat.id = 3 + message.chat.username = "username" + message.message_thread_id = 123 + assert ( + message.build_reply_arguments( + target_chat_id=target_chat_id, message_thread_id=message_thread_id + )["reply_parameters"].allow_sending_without_reply + is not None + ) == expected + + assert ( + message.build_reply_arguments( + target_chat_id=target_chat_id, + message_thread_id=message_thread_id, + allow_sending_without_reply="custom", + )["reply_parameters"].allow_sending_without_reply + ) == ("custom" if expected else None) + + def test_build_reply_arguments_quote(self, message, monkeypatch): + reply_parameters = message.build_reply_arguments()["reply_parameters"] + assert reply_parameters.quote is None + assert reply_parameters.quote_entities == () + assert reply_parameters.quote_position is None + assert not reply_parameters.quote_parse_mode + + quote_obj = object() + quote_index = object() + quote_entities = (object(), object()) + quote_position = object() + + def mock_compute(quote, index): + if quote is quote_obj and index is quote_index: + return quote_position, quote_entities + return False, False + + monkeypatch.setattr(message, "compute_quote_position_and_entities", mock_compute) + reply_parameters = message.build_reply_arguments(quote=quote_obj, quote_index=quote_index)[ + "reply_parameters" + ] + + assert reply_parameters.quote is quote_obj + assert reply_parameters.quote_entities is quote_entities + assert reply_parameters.quote_position is quote_position + assert not reply_parameters.quote_parse_mode + async def test_reply_text(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id text = kwargs["text"] == "test" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and text and reply + return id_ and text assert check_shortcut_signature( - Message.reply_text, Bot.send_message, ["chat_id"], ["quote"] + Message.reply_text, + Bot.send_message, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_text, + message.get_bot(), + "send_message", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_text, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_shortcut_call(message.reply_text, message.get_bot(), "send_message") - assert await check_defaults_handling(message.reply_text, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_message", make_assertion) assert await message.reply_text("test") - assert await message.reply_text("test", quote=True) - assert await message.reply_text("test", reply_to_message_id=message.message_id, quote=True) + await self.check_quote_parsing( + message, message.reply_text, "send_message", ["test"], monkeypatch + ) + + await self.check_thread_id_parsing( + message, message.reply_text, "send_message", ["test"], monkeypatch + ) async def test_reply_markdown(self, monkeypatch, message): test_md_string = ( @@ -1045,26 +1691,40 @@ async def make_assertion(*_, **kwargs): cid = kwargs["chat_id"] == message.chat_id markdown_text = kwargs["text"] == test_md_string markdown_enabled = kwargs["parse_mode"] == ParseMode.MARKDOWN - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return all([cid, markdown_text, reply, markdown_enabled]) + return all([cid, markdown_text, markdown_enabled]) assert check_shortcut_signature( - Message.reply_markdown, Bot.send_message, ["chat_id", "parse_mode"], ["quote"] + Message.reply_markdown, + Bot.send_message, + [ + "chat_id", + "parse_mode", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_text, + message.get_bot(), + "send_message", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_text, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_shortcut_call(message.reply_text, message.get_bot(), "send_message") - assert await check_defaults_handling(message.reply_text, message.get_bot()) text_markdown = self.test_message.text_markdown assert text_markdown == test_md_string monkeypatch.setattr(message.get_bot(), "send_message", make_assertion) assert await message.reply_markdown(self.test_message.text_markdown) - assert await message.reply_markdown(self.test_message.text_markdown, quote=True) - assert await message.reply_markdown( - self.test_message.text_markdown, reply_to_message_id=message.message_id, quote=True + + await self.check_thread_id_parsing( + message, message.reply_markdown, "send_message", ["test"], monkeypatch ) async def test_reply_markdown_v2(self, monkeypatch, message): @@ -1073,35 +1733,56 @@ async def test_reply_markdown_v2(self, monkeypatch, message): "[links](http://github.com/abc\\\\\\)def), " "[text\\-mention](tg://user?id=123456789) and ```\\`\\\\pre```\\. " r"http://google\.com and _bold *nested in ~strk\>trgh~ nested in* italic_\. " - "```python\nPython pre```\\. ||Spoiled||\\. ![👍](tg://emoji?id=1)\\." + "```python\nPython pre```\\. ||Spoiled||\\. ![👍](tg://emoji?id=1)\\.\n" + ">Multiline\n" + ">block quote\n" + r">with *nested*\." + "\n\n>Multiline\n" + ">expandable\n" + r">block quote\.||" ) async def make_assertion(*_, **kwargs): cid = kwargs["chat_id"] == message.chat_id markdown_text = kwargs["text"] == test_md_string markdown_enabled = kwargs["parse_mode"] == ParseMode.MARKDOWN_V2 - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return all([cid, markdown_text, reply, markdown_enabled]) + return all([cid, markdown_text, markdown_enabled]) assert check_shortcut_signature( - Message.reply_markdown_v2, Bot.send_message, ["chat_id", "parse_mode"], ["quote"] + Message.reply_markdown_v2, + Bot.send_message, + [ + "chat_id", + "parse_mode", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_text, + message.get_bot(), + "send_message", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_text, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_shortcut_call(message.reply_text, message.get_bot(), "send_message") - assert await check_defaults_handling(message.reply_text, message.get_bot()) text_markdown = self.test_message_v2.text_markdown_v2 assert text_markdown == test_md_string monkeypatch.setattr(message.get_bot(), "send_message", make_assertion) assert await message.reply_markdown_v2(self.test_message_v2.text_markdown_v2) - assert await message.reply_markdown_v2(self.test_message_v2.text_markdown_v2, quote=True) - assert await message.reply_markdown_v2( - self.test_message_v2.text_markdown_v2, - reply_to_message_id=message.message_id, - quote=True, + await self.check_quote_parsing( + message, message.reply_markdown_v2, "send_message", [test_md_string], monkeypatch + ) + + await self.check_thread_id_parsing( + message, message.reply_markdown_v2, "send_message", ["test"], monkeypatch ) async def test_reply_html(self, monkeypatch, message): @@ -1114,321 +1795,674 @@ async def test_reply_html(self, monkeypatch, message): "and bold nested in strk>trgh nested in italic. " '
Python pre
. ' 'Spoiled. ' - '👍.' + '👍.\n' + "
Multiline\nblock quote\nwith nested.
\n\n" + "
Multiline\nexpandable\nblock quote.
" ) async def make_assertion(*_, **kwargs): cid = kwargs["chat_id"] == message.chat_id html_text = kwargs["text"] == test_html_string html_enabled = kwargs["parse_mode"] == ParseMode.HTML - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return all([cid, html_text, reply, html_enabled]) + return all([cid, html_text, html_enabled]) assert check_shortcut_signature( - Message.reply_html, Bot.send_message, ["chat_id", "parse_mode"], ["quote"] + Message.reply_html, + Bot.send_message, + [ + "chat_id", + "parse_mode", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_text, + message.get_bot(), + "send_message", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_text, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_shortcut_call(message.reply_text, message.get_bot(), "send_message") - assert await check_defaults_handling(message.reply_text, message.get_bot()) text_html = self.test_message_v2.text_html assert text_html == test_html_string monkeypatch.setattr(message.get_bot(), "send_message", make_assertion) assert await message.reply_html(self.test_message_v2.text_html) - assert await message.reply_html(self.test_message_v2.text_html, quote=True) - assert await message.reply_html( - self.test_message_v2.text_html, reply_to_message_id=message.message_id, quote=True + await self.check_quote_parsing( + message, message.reply_html, "send_message", [test_html_string], monkeypatch + ) + + await self.check_thread_id_parsing( + message, message.reply_html, "send_message", ["test"], monkeypatch + ) + + async def test_reply_text_draft(self, monkeypatch, message): + async def make_assertion(*_, **kwargs): + id_ = kwargs["chat_id"] == message.chat_id + text = kwargs["text"] == "test" + return id_ and text + + assert check_shortcut_signature( + Message.reply_text_draft, + Bot.send_message_draft, + ["chat_id"], + [], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_text_draft, + message.get_bot(), + "send_message_draft", + skip_params=[""], + shortcut_kwargs=["chat_id"], + ) + assert await check_defaults_handling( + message.reply_text_draft, message.get_bot(), no_default_kwargs={"message_thread_id"} + ) + + monkeypatch.setattr(message.get_bot(), "send_message_draft", make_assertion) + assert await message.reply_text_draft(draft_id=1, text="test") + + await self.check_thread_id_parsing( + message, message.reply_text_draft, "send_message_draft", [1, "test"], monkeypatch ) async def test_reply_media_group(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id media = kwargs["media"] == "reply_media_group" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and media and reply + return id_ and media assert check_shortcut_signature( - Message.reply_media_group, Bot.send_media_group, ["chat_id"], ["quote"] + Message.reply_media_group, + Bot.send_media_group, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( - message.reply_media_group, message.get_bot(), "send_media_group" + message.reply_media_group, + message.get_bot(), + "send_media_group", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_media_group, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_defaults_handling(message.reply_media_group, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_media_group", make_assertion) assert await message.reply_media_group(media="reply_media_group") - assert await message.reply_media_group(media="reply_media_group", quote=True) + await self.check_quote_parsing( + message, + message.reply_media_group, + "send_media_group", + ["reply_media_group"], + monkeypatch, + ) + + await self.check_thread_id_parsing( + message, + message.reply_media_group, + "send_media_group", + ["reply_media_group"], + monkeypatch, + ) async def test_reply_photo(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id photo = kwargs["photo"] == "test_photo" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and photo and reply + return id_ and photo assert check_shortcut_signature( - Message.reply_photo, Bot.send_photo, ["chat_id"], ["quote"] + Message.reply_photo, + Bot.send_photo, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_photo, + message.get_bot(), + "send_photo", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_photo, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_shortcut_call(message.reply_photo, message.get_bot(), "send_photo") - assert await check_defaults_handling(message.reply_photo, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_photo", make_assertion) assert await message.reply_photo(photo="test_photo") - assert await message.reply_photo(photo="test_photo", quote=True) + await self.check_quote_parsing( + message, message.reply_photo, "send_photo", ["test_photo"], monkeypatch + ) + + await self.check_thread_id_parsing( + message, message.reply_photo, "send_photo", ["test_photo"], monkeypatch + ) async def test_reply_audio(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id audio = kwargs["audio"] == "test_audio" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and audio and reply + return id_ and audio assert check_shortcut_signature( - Message.reply_audio, Bot.send_audio, ["chat_id"], ["quote"] + Message.reply_audio, + Bot.send_audio, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_audio, + message.get_bot(), + "send_audio", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_audio, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_shortcut_call(message.reply_audio, message.get_bot(), "send_audio") - assert await check_defaults_handling(message.reply_audio, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_audio", make_assertion) assert await message.reply_audio(audio="test_audio") - assert await message.reply_audio(audio="test_audio", quote=True) + await self.check_quote_parsing( + message, message.reply_audio, "send_audio", ["test_audio"], monkeypatch + ) + + await self.check_thread_id_parsing( + message, message.reply_audio, "send_audio", ["test_audio"], monkeypatch + ) async def test_reply_document(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id document = kwargs["document"] == "test_document" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and document and reply + return id_ and document assert check_shortcut_signature( - Message.reply_document, Bot.send_document, ["chat_id"], ["quote"] + Message.reply_document, + Bot.send_document, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( - message.reply_document, message.get_bot(), "send_document" + message.reply_document, + message.get_bot(), + "send_document", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_document, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_defaults_handling(message.reply_document, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_document", make_assertion) assert await message.reply_document(document="test_document") - assert await message.reply_document(document="test_document", quote=True) + await self.check_quote_parsing( + message, message.reply_document, "send_document", ["test_document"], monkeypatch + ) + + await self.check_thread_id_parsing( + message, message.reply_document, "send_document", ["test_document"], monkeypatch + ) async def test_reply_animation(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id animation = kwargs["animation"] == "test_animation" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and animation and reply + return id_ and animation assert check_shortcut_signature( - Message.reply_animation, Bot.send_animation, ["chat_id"], ["quote"] + Message.reply_animation, + Bot.send_animation, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( - message.reply_animation, message.get_bot(), "send_animation" + message.reply_animation, + message.get_bot(), + "send_animation", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_animation, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_defaults_handling(message.reply_animation, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_animation", make_assertion) assert await message.reply_animation(animation="test_animation") - assert await message.reply_animation(animation="test_animation", quote=True) + await self.check_quote_parsing( + message, message.reply_animation, "send_animation", ["test_animation"], monkeypatch + ) + + await self.check_thread_id_parsing( + message, message.reply_animation, "send_animation", ["test_animation"], monkeypatch + ) async def test_reply_sticker(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id sticker = kwargs["sticker"] == "test_sticker" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and sticker and reply + return id_ and sticker assert check_shortcut_signature( - Message.reply_sticker, Bot.send_sticker, ["chat_id"], ["quote"] + Message.reply_sticker, + Bot.send_sticker, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_sticker, + message.get_bot(), + "send_sticker", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_sticker, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_shortcut_call(message.reply_sticker, message.get_bot(), "send_sticker") - assert await check_defaults_handling(message.reply_sticker, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_sticker", make_assertion) assert await message.reply_sticker(sticker="test_sticker") - assert await message.reply_sticker(sticker="test_sticker", quote=True) + await self.check_quote_parsing( + message, message.reply_sticker, "send_sticker", ["test_sticker"], monkeypatch + ) + + await self.check_thread_id_parsing( + message, message.reply_sticker, "send_sticker", ["test_sticker"], monkeypatch + ) async def test_reply_video(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id video = kwargs["video"] == "test_video" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and video and reply + return id_ and video assert check_shortcut_signature( - Message.reply_video, Bot.send_video, ["chat_id"], ["quote"] + Message.reply_video, + Bot.send_video, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_video, + message.get_bot(), + "send_video", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_video, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_shortcut_call(message.reply_video, message.get_bot(), "send_video") - assert await check_defaults_handling(message.reply_video, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_video", make_assertion) assert await message.reply_video(video="test_video") - assert await message.reply_video(video="test_video", quote=True) + await self.check_quote_parsing( + message, message.reply_video, "send_video", ["test_video"], monkeypatch + ) + + await self.check_thread_id_parsing( + message, message.reply_video, "send_video", ["test_video"], monkeypatch + ) async def test_reply_video_note(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id video_note = kwargs["video_note"] == "test_video_note" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and video_note and reply + return id_ and video_note assert check_shortcut_signature( - Message.reply_video_note, Bot.send_video_note, ["chat_id"], ["quote"] + Message.reply_video_note, + Bot.send_video_note, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( - message.reply_video_note, message.get_bot(), "send_video_note" + message.reply_video_note, + message.get_bot(), + "send_video_note", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_video_note, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_defaults_handling(message.reply_video_note, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_video_note", make_assertion) assert await message.reply_video_note(video_note="test_video_note") - assert await message.reply_video_note(video_note="test_video_note", quote=True) + await self.check_quote_parsing( + message, message.reply_video_note, "send_video_note", ["test_video_note"], monkeypatch + ) + + await self.check_thread_id_parsing( + message, message.reply_video_note, "send_video_note", ["test_video_note"], monkeypatch + ) async def test_reply_voice(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id voice = kwargs["voice"] == "test_voice" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and voice and reply + return id_ and voice assert check_shortcut_signature( - Message.reply_voice, Bot.send_voice, ["chat_id"], ["quote"] + Message.reply_voice, + Bot.send_voice, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_voice, + message.get_bot(), + "send_voice", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_voice, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_shortcut_call(message.reply_voice, message.get_bot(), "send_voice") - assert await check_defaults_handling(message.reply_voice, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_voice", make_assertion) assert await message.reply_voice(voice="test_voice") - assert await message.reply_voice(voice="test_voice", quote=True) + await self.check_quote_parsing( + message, message.reply_voice, "send_voice", ["test_voice"], monkeypatch + ) + + await self.check_thread_id_parsing( + message, message.reply_voice, "send_voice", ["test_voice"], monkeypatch + ) async def test_reply_location(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id location = kwargs["location"] == "test_location" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and location and reply + return id_ and location assert check_shortcut_signature( - Message.reply_location, Bot.send_location, ["chat_id"], ["quote"] + Message.reply_location, + Bot.send_location, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( - message.reply_location, message.get_bot(), "send_location" + message.reply_location, + message.get_bot(), + "send_location", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_location, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_defaults_handling(message.reply_location, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_location", make_assertion) assert await message.reply_location(location="test_location") - assert await message.reply_location(location="test_location", quote=True) + await self.check_quote_parsing( + message, message.reply_location, "send_location", ["test_location"], monkeypatch + ) + + await self.check_thread_id_parsing( + message, message.reply_location, "send_location", ["test_location"], monkeypatch + ) async def test_reply_venue(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id venue = kwargs["venue"] == "test_venue" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and venue and reply + return id_ and venue assert check_shortcut_signature( - Message.reply_venue, Bot.send_venue, ["chat_id"], ["quote"] + Message.reply_venue, + Bot.send_venue, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_venue, + message.get_bot(), + "send_venue", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_venue, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_shortcut_call(message.reply_venue, message.get_bot(), "send_venue") - assert await check_defaults_handling(message.reply_venue, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_venue", make_assertion) assert await message.reply_venue(venue="test_venue") - assert await message.reply_venue(venue="test_venue", quote=True) + await self.check_quote_parsing( + message, message.reply_venue, "send_venue", ["test_venue"], monkeypatch + ) + + await self.check_thread_id_parsing( + message, message.reply_venue, "send_venue", ["test_venue"], monkeypatch + ) async def test_reply_contact(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id contact = kwargs["contact"] == "test_contact" - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and contact and reply + return id_ and contact assert check_shortcut_signature( - Message.reply_contact, Bot.send_contact, ["chat_id"], ["quote"] + Message.reply_contact, + Bot.send_contact, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_contact, + message.get_bot(), + "send_contact", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_contact, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_shortcut_call(message.reply_contact, message.get_bot(), "send_contact") - assert await check_defaults_handling(message.reply_contact, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_contact", make_assertion) assert await message.reply_contact(contact="test_contact") - assert await message.reply_contact(contact="test_contact", quote=True) + await self.check_quote_parsing( + message, message.reply_contact, "send_contact", ["test_contact"], monkeypatch + ) + + await self.check_thread_id_parsing( + message, message.reply_contact, "send_contact", ["test_contact"], monkeypatch + ) async def test_reply_poll(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id question = kwargs["question"] == "test_poll" options = kwargs["options"] == ["1", "2", "3"] - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and question and options and reply + return id_ and question and options - assert check_shortcut_signature(Message.reply_poll, Bot.send_poll, ["chat_id"], ["quote"]) - assert await check_shortcut_call(message.reply_poll, message.get_bot(), "send_poll") - assert await check_defaults_handling(message.reply_poll, message.get_bot()) + assert check_shortcut_signature( + Message.reply_poll, + Bot.send_poll, + ["chat_id", "reply_to_message_id", "business_connection_id"], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_poll, + message.get_bot(), + "send_poll", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_poll, message.get_bot(), no_default_kwargs={"message_thread_id"} + ) monkeypatch.setattr(message.get_bot(), "send_poll", make_assertion) assert await message.reply_poll(question="test_poll", options=["1", "2", "3"]) - assert await message.reply_poll(question="test_poll", quote=True, options=["1", "2", "3"]) + await self.check_quote_parsing( + message, message.reply_poll, "send_poll", ["test_poll", ["1", "2", "3"]], monkeypatch + ) + + await self.check_thread_id_parsing( + message, message.reply_poll, "send_poll", ["test_poll", ["1", "2", "3"]], monkeypatch + ) async def test_reply_dice(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id contact = kwargs["disable_notification"] is True - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True - return id_ and contact and reply + return id_ and contact - assert check_shortcut_signature(Message.reply_dice, Bot.send_dice, ["chat_id"], ["quote"]) - assert await check_shortcut_call(message.reply_dice, message.get_bot(), "send_dice") - assert await check_defaults_handling(message.reply_dice, message.get_bot()) + assert check_shortcut_signature( + Message.reply_dice, + Bot.send_dice, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_dice, + message.get_bot(), + "send_dice", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_dice, message.get_bot(), no_default_kwargs={"message_thread_id"} + ) monkeypatch.setattr(message.get_bot(), "send_dice", make_assertion) assert await message.reply_dice(disable_notification=True) - assert await message.reply_dice(disable_notification=True, quote=True) + await self.check_quote_parsing( + message, + message.reply_dice, + "send_dice", + [], + monkeypatch, + ) + + await self.check_thread_id_parsing( + message, message.reply_dice, "send_dice", [], monkeypatch + ) + + async def test_reply_checklist(self, monkeypatch, message): + checklist = InputChecklist(title="My Checklist", tasks=[InputChecklistTask(1, "Task 1")]) + + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == message.chat_id + and kwargs["business_connection_id"] == message.business_connection_id + and kwargs["checklist"] == checklist + and kwargs["disable_notification"] is True + ) + + assert check_shortcut_signature( + Message.reply_checklist, + Bot.send_checklist, + ["chat_id", "business_connection_id", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], + ) + assert await check_shortcut_call( + message.reply_checklist, + message.get_bot(), + "send_checklist", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["chat_id", "business_connection_id"], + ) + assert await check_defaults_handling(message.reply_checklist, message.get_bot()) + + monkeypatch.setattr(message.get_bot(), "send_checklist", make_assertion) + assert await message.reply_checklist(checklist, disable_notification=True) + await self.check_quote_parsing( + message, + message.reply_checklist, + "send_checklist", + [checklist, True], + monkeypatch, + ) async def test_reply_action(self, monkeypatch, message: Message): async def make_assertion(*_, **kwargs): @@ -1437,29 +2471,70 @@ async def make_assertion(*_, **kwargs): return id_ and action assert check_shortcut_signature( - Message.reply_chat_action, Bot.send_chat_action, ["chat_id"], [] + Message.reply_chat_action, + Bot.send_chat_action, + ["chat_id", "reply_to_message_id", "business_connection_id"], + [], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( - message.reply_chat_action, message.get_bot(), "send_chat_action" + message.reply_chat_action, + message.get_bot(), + "send_chat_action", + shortcut_kwargs=["business_connection_id"], + ) + assert await check_defaults_handling( + message.reply_chat_action, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_defaults_handling(message.reply_chat_action, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_chat_action", make_assertion) assert await message.reply_chat_action(action=ChatAction.TYPING) + await self.check_thread_id_parsing( + message, + message.reply_chat_action, + "send_chat_action", + [ChatAction.TYPING], + monkeypatch, + ) + async def test_reply_game(self, monkeypatch, message): async def make_assertion(*_, **kwargs): return ( kwargs["chat_id"] == message.chat_id and kwargs["game_short_name"] == "test_game" ) - assert check_shortcut_signature(Message.reply_game, Bot.send_game, ["chat_id"], ["quote"]) - assert await check_shortcut_call(message.reply_game, message.get_bot(), "send_game") - assert await check_defaults_handling(message.reply_game, message.get_bot()) + assert check_shortcut_signature( + Message.reply_game, + Bot.send_game, + ["chat_id", "reply_to_message_id", "business_connection_id"], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_game, + message.get_bot(), + "send_game", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id"], + ) + assert await check_defaults_handling( + message.reply_game, message.get_bot(), no_default_kwargs={"message_thread_id"} + ) monkeypatch.setattr(message.get_bot(), "send_game", make_assertion) assert await message.reply_game(game_short_name="test_game") - assert await message.reply_game(game_short_name="test_game", quote=True) + await self.check_quote_parsing( + message, message.reply_game, "send_game", ["test_game"], monkeypatch + ) + + await self.check_thread_id_parsing( + message, + message.reply_game, + "send_game", + ["test_game"], + monkeypatch, + ) async def test_reply_invoice(self, monkeypatch, message): async def make_assertion(*_, **kwargs): @@ -1473,28 +2548,51 @@ async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == message.chat_id and args assert check_shortcut_signature( - Message.reply_invoice, Bot.send_invoice, ["chat_id"], ["quote"] + Message.reply_invoice, + Bot.send_invoice, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_invoice, + message.get_bot(), + "send_invoice", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_invoice, message.get_bot(), no_default_kwargs={"message_thread_id"} ) - assert await check_shortcut_call(message.reply_invoice, message.get_bot(), "send_invoice") - assert await check_defaults_handling(message.reply_invoice, message.get_bot()) monkeypatch.setattr(message.get_bot(), "send_invoice", make_assertion) assert await message.reply_invoice( "title", "description", "payload", - "provider_token", "currency", "prices", - ) - assert await message.reply_invoice( - "title", - "description", - "payload", "provider_token", - "currency", - "prices", - quote=True, + ) + await self.check_quote_parsing( + message, + message.reply_invoice, + "send_invoice", + ["title", "description", "payload", "provider_token", "currency", "prices"], + monkeypatch, + ) + + await self.check_thread_id_parsing( + message, + message.reply_invoice, + "send_invoice", + ["title", "description", "payload", "provider_token", "currency", "prices"], + monkeypatch, ) @pytest.mark.parametrize(("disable_notification", "protected"), [(False, True), (True, False)]) @@ -1508,9 +2606,17 @@ async def make_assertion(*_, **kwargs): return chat_id and from_chat and message_id and notification and protected_cont assert check_shortcut_signature( - Message.forward, Bot.forward_message, ["from_chat_id", "message_id"], [] + Message.forward, + Bot.forward_message, + ["from_chat_id", "message_id", "direct_messages_topic_id"], + [], + ) + assert await check_shortcut_call( + message.forward, + message.get_bot(), + "forward_message", + shortcut_kwargs=["direct_messages_topic_id"], ) - assert await check_shortcut_call(message.forward, message.get_bot(), "forward_message") assert await check_defaults_handling(message.forward, message.get_bot()) monkeypatch.setattr(message.get_bot(), "forward_message", make_assertion) @@ -1543,9 +2649,17 @@ async def make_assertion(*_, **kwargs): ) assert check_shortcut_signature( - Message.copy, Bot.copy_message, ["from_chat_id", "message_id"], [] + Message.copy, + Bot.copy_message, + ["from_chat_id", "message_id", "direct_messages_topic_id"], + [], + ) + assert await check_shortcut_call( + message.copy, + message.get_bot(), + "copy_message", + shortcut_kwargs=["direct_messages_topic_id"], ) - assert await check_shortcut_call(message.copy, message.get_bot(), "copy_message") assert await check_defaults_handling(message.copy, message.get_bot()) monkeypatch.setattr(message.get_bot(), "copy_message", make_assertion) @@ -1574,24 +2688,33 @@ async def make_assertion(*_, **kwargs): reply_markup = kwargs["reply_markup"] is keyboard else: reply_markup = True - if kwargs.get("reply_to_message_id") is not None: - reply = kwargs["reply_to_message_id"] == message.message_id - else: - reply = True return ( chat_id and from_chat and message_id and notification and reply_markup - and reply and is_protected ) assert check_shortcut_signature( - Message.reply_copy, Bot.copy_message, ["chat_id"], ["quote"] + Message.reply_copy, + Bot.copy_message, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.copy, + message.get_bot(), + "copy_message", + shortcut_kwargs=["direct_messages_topic_id"], ) - assert await check_shortcut_call(message.copy, message.get_bot(), "copy_message") assert await check_defaults_handling(message.copy, message.get_bot()) monkeypatch.setattr(message.get_bot(), "copy_message", make_assertion) @@ -1605,20 +2728,62 @@ async def make_assertion(*_, **kwargs): disable_notification=disable_notification, protect_content=protected, ) - assert await message.reply_copy( - 123456, - 456789, - quote=True, - disable_notification=disable_notification, - protect_content=protected, + await self.check_quote_parsing( + message, + message.reply_copy, + "copy_message", + [123456, 456789], + monkeypatch, ) - assert await message.reply_copy( - 123456, - 456789, - quote=True, - reply_to_message_id=message.message_id, - disable_notification=disable_notification, - protect_content=protected, + + await self.check_thread_id_parsing( + message, + message.reply_copy, + "copy_message", + [123456, 456789], + monkeypatch, + ) + + async def test_reply_paid_media(self, monkeypatch, message): + async def make_assertion(*_, **kwargs): + id_ = kwargs["chat_id"] == message.chat_id + media = kwargs["media"][0].media == "media" + star_count = kwargs["star_count"] == 5 + return id_ and media and star_count + + assert check_shortcut_signature( + Message.reply_paid_media, + Bot.send_paid_media, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_paid_media, + message.get_bot(), + "send_paid_media", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_paid_media, message.get_bot(), no_default_kwargs={"message_thread_id"} + ) + + monkeypatch.setattr(message.get_bot(), "send_paid_media", make_assertion) + assert await message.reply_paid_media( + star_count=5, media=[InputPaidMediaPhoto(media="media")] + ) + await self.check_quote_parsing( + message, + message.reply_paid_media, + "send_paid_media", + ["test", [InputPaidMediaPhoto(media="media")]], + monkeypatch, ) async def test_edit_text(self, monkeypatch, message): @@ -1631,7 +2796,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( @@ -1639,7 +2804,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()) @@ -1656,7 +2821,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( @@ -1664,13 +2829,41 @@ 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()) monkeypatch.setattr(message.get_bot(), "edit_message_caption", make_assertion) assert await message.edit_caption(caption="new caption") + async def test_edit_checklist(self, monkeypatch, message): + checklist = InputChecklist(title="My Checklist", tasks=[InputChecklistTask(1, "Task 1")]) + + async def make_assertion(*_, **kwargs): + return ( + kwargs["business_connection_id"] == message.business_connection_id + and kwargs["chat_id"] == message.chat_id + and kwargs["message_id"] == message.message_id + and kwargs["checklist"] == checklist + ) + + assert check_shortcut_signature( + Message.edit_checklist, + Bot.edit_message_checklist, + ["chat_id", "message_id", "business_connection_id"], + [], + ) + assert await check_shortcut_call( + message.edit_checklist, + message.get_bot(), + "edit_message_checklist", + shortcut_kwargs=["chat_id", "message_id", "business_connection_id"], + ) + assert await check_defaults_handling(message.edit_checklist, message.get_bot()) + + monkeypatch.setattr(message.get_bot(), "edit_message_checklist", make_assertion) + assert await message.edit_checklist(checklist=checklist) + async def test_edit_media(self, monkeypatch, message): async def make_assertion(*_, **kwargs): chat_id = kwargs["chat_id"] == message.chat_id @@ -1681,7 +2874,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( @@ -1689,7 +2882,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()) @@ -1706,7 +2899,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( @@ -1714,7 +2907,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()) @@ -1727,12 +2920,13 @@ async def make_assertion(*_, **kwargs): message_id = kwargs["message_id"] == message.message_id latitude = kwargs["latitude"] == 1 longitude = kwargs["longitude"] == 2 - return chat_id and message_id and longitude and latitude + live = kwargs["live_period"] == 900 + return chat_id and message_id and longitude and latitude and live 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( @@ -1740,12 +2934,12 @@ 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()) monkeypatch.setattr(message.get_bot(), "edit_message_live_location", make_assertion) - assert await message.edit_live_location(latitude=1, longitude=2) + assert await message.edit_live_location(latitude=1, longitude=2, live_period=900) async def test_stop_live_location(self, monkeypatch, message): async def make_assertion(*_, **kwargs): @@ -1756,7 +2950,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( @@ -1764,7 +2958,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()) @@ -1822,20 +3016,49 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(message.get_bot(), "get_game_high_scores", make_assertion) assert await message.get_game_high_scores(user_id=1) - async def test_delete(self, monkeypatch, message): + @pytest.mark.parametrize("business_connection_id", [None, "123456789"]) + async def test_delete(self, monkeypatch, message, business_connection_id): + message = deepcopy(message) + message.business_connection_id = business_connection_id + async def make_assertion(*_, **kwargs): - chat_id = kwargs["chat_id"] == message.chat_id - message_id = kwargs["message_id"] == message.message_id - return chat_id and message_id + url: str = kwargs.get("url") + data = kwargs.get("request_data").parameters + + if not message.business_connection_id: + endpoint = url.endswith("deleteMessage") + chat_id = data.get("chat_id") == message.chat_id + message_id = data.get("message_id") == message.message_id + return endpoint and chat_id and message_id + + endpoint = url.endswith("deleteBusinessMessages") + business_connection_id = ( + data.get("business_connection_id") == message.business_connection_id + ) + message_ids = data.get("message_ids") == [message.message_id] + return business_connection_id and message_ids and endpoint - assert check_shortcut_signature( - Message.delete, Bot.delete_message, ["chat_id", "message_id"], [] - ) - assert await check_shortcut_call(message.delete, message.get_bot(), "delete_message") - assert await check_defaults_handling(message.delete, message.get_bot()) + monkeypatch.setattr(message.get_bot().request, "post", make_assertion) - monkeypatch.setattr(message.get_bot(), "delete_message", make_assertion) - assert await message.delete() + if not message.business_connection_id: + assert check_shortcut_signature( + Message.delete, Bot.delete_message, ["chat_id", "message_id"], [] + ) + assert await check_shortcut_call(message.delete, message.get_bot(), "delete_message") + assert await check_defaults_handling(message.delete, message.get_bot()) + assert await message.delete() + else: + assert check_shortcut_signature( + Message.delete, + Bot.delete_business_messages, + ["business_connection_id", "message_ids"], + [], + ) + assert await check_shortcut_call( + message.delete, message.get_bot(), "delete_business_messages" + ) + assert await check_defaults_handling(message.delete, message.get_bot()) + assert await message.delete() async def test_stop_poll(self, monkeypatch, message): async def make_assertion(*_, **kwargs): @@ -1844,9 +3067,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) @@ -1859,9 +3090,17 @@ async def make_assertion(*args, **kwargs): return chat_id and message_id assert check_shortcut_signature( - Message.pin, Bot.pin_chat_message, ["chat_id", "message_id"], [] + Message.pin, + Bot.pin_chat_message, + ["chat_id", "message_id", "business_connection_id"], + [], + ) + assert await check_shortcut_call( + message.pin, + message.get_bot(), + "pin_chat_message", + shortcut_kwargs=["chat_id", "message_id", "business_connection_id"], ) - assert await check_shortcut_call(message.pin, message.get_bot(), "pin_chat_message") assert await check_defaults_handling(message.pin, message.get_bot()) monkeypatch.setattr(message.get_bot(), "pin_chat_message", make_assertion) @@ -1874,37 +3113,58 @@ async def make_assertion(*args, **kwargs): return chat_id and message_id assert check_shortcut_signature( - Message.unpin, Bot.unpin_chat_message, ["chat_id", "message_id"], [] + Message.unpin, + Bot.unpin_chat_message, + ["chat_id", "message_id", "business_connection_id"], + [], ) assert await check_shortcut_call( message.unpin, message.get_bot(), "unpin_chat_message", - shortcut_kwargs=["chat_id", "message_id"], + shortcut_kwargs=["chat_id", "message_id", "business_connection_id"], ) assert await check_defaults_handling(message.unpin, message.get_bot()) monkeypatch.setattr(message.get_bot(), "unpin_chat_message", make_assertion) assert await message.unpin() - def test_default_quote(self, message): - message.get_bot()._defaults = Defaults() - - try: - message.get_bot().defaults._quote = False - assert message._quote(None, None) is None + @pytest.mark.parametrize( + ("default_quote", "chat_type", "expected"), + [ + (False, Chat.PRIVATE, False), + (None, Chat.PRIVATE, False), + (True, Chat.PRIVATE, True), + (False, Chat.GROUP, False), + (None, Chat.GROUP, True), + (True, Chat.GROUP, True), + (False, Chat.SUPERGROUP, False), + (None, Chat.SUPERGROUP, True), + (True, Chat.SUPERGROUP, True), + (False, Chat.CHANNEL, False), + (None, Chat.CHANNEL, True), + (True, Chat.CHANNEL, True), + ], + ) + async def test_default_do_quote( + self, offline_bot, message, default_quote, chat_type, expected, monkeypatch + ): + original_bot = message.get_bot() + temp_bot = PytestExtBot(token=offline_bot.token, defaults=Defaults(do_quote=default_quote)) + message.set_bot(temp_bot) - message.get_bot().defaults._quote = True - assert message._quote(None, None) == message.message_id + async def make_assertion(*_, **kwargs): + reply_parameters = kwargs.get("reply_parameters") or ReplyParameters(message_id=False) + condition = reply_parameters.message_id == message.message_id + return condition == expected - message.get_bot().defaults._quote = None - message.chat.type = Chat.PRIVATE - assert message._quote(None, None) is None + monkeypatch.setattr(message.get_bot(), "send_message", make_assertion) - message.chat.type = Chat.GROUP - assert message._quote(None, None) + try: + 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): @@ -2023,3 +3283,90 @@ 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() + + async def test_read_business_message(self, monkeypatch, message): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == message.chat_id + and kwargs["business_connection_id"] == message.business_connection_id + and kwargs["message_id"] == message.message_id, + ) + + assert check_shortcut_signature( + Message.read_business_message, + Bot.read_business_message, + ["chat_id", "message_id", "business_connection_id"], + [], + ) + assert await check_shortcut_call( + message.read_business_message, + message.get_bot(), + "read_business_message", + shortcut_kwargs=["chat_id", "message_id", "business_connection_id"], + ) + assert await check_defaults_handling(message.read_business_message, message.get_bot()) + + monkeypatch.setattr(message.get_bot(), "read_business_message", make_assertion) + assert await message.read_business_message() + + 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__ + + async def test_approve_suggested_post(self, monkeypatch, message): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == message.chat_id + and kwargs["message_id"] == message.message_id + and kwargs["send_date"] == 1234567890 + ) + + assert check_shortcut_signature( + Message.approve_suggested_post, + Bot.approve_suggested_post, + ["chat_id", "message_id"], + [], + ) + assert await check_shortcut_call( + message.approve_suggested_post, + message.get_bot(), + "approve_suggested_post", + shortcut_kwargs=["chat_id", "message_id"], + ) + assert await check_defaults_handling(message.approve_suggested_post, message.get_bot()) + + monkeypatch.setattr(message.get_bot(), "approve_suggested_post", make_assertion) + assert await message.approve_suggested_post(send_date=1234567890) + + async def test_decline_suggested_post(self, monkeypatch, message): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == message.chat_id + and kwargs["message_id"] == message.message_id + and kwargs["comment"] == "some comment" + ) + + assert check_shortcut_signature( + Message.decline_suggested_post, + Bot.decline_suggested_post, + ["chat_id", "message_id"], + [], + ) + assert await check_shortcut_call( + message.decline_suggested_post, + message.get_bot(), + "decline_suggested_post", + shortcut_kwargs=["chat_id", "message_id"], + ) + assert await check_defaults_handling(message.decline_suggested_post, message.get_bot()) + + monkeypatch.setattr(message.get_bot(), "decline_suggested_post", make_assertion) + assert await message.decline_suggested_post(comment="some comment") diff --git a/tests/test_messageautodeletetimerchanged.py b/tests/test_messageautodeletetimerchanged.py index bc9e02e1886..fc92af4fc7c 100644 --- a/tests/test_messageautodeletetimerchanged.py +++ b/tests/test_messageautodeletetimerchanged.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,12 +16,15 @@ # 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 as dtm + from telegram import MessageAutoDeleteTimerChanged, VideoChatEnded +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots class TestMessageAutoDeleteTimerChangedWithoutRequest: - message_auto_delete_time = 100 + message_auto_delete_time = dtm.timedelta(seconds=100) def test_slot_behaviour(self): action = MessageAutoDeleteTimerChanged(self.message_auto_delete_time) @@ -30,18 +33,47 @@ def test_slot_behaviour(self): assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" def test_de_json(self): - json_dict = {"message_auto_delete_time": self.message_auto_delete_time} + json_dict = { + "message_auto_delete_time": int(self.message_auto_delete_time.total_seconds()) + } madtc = MessageAutoDeleteTimerChanged.de_json(json_dict, None) assert madtc.api_kwargs == {} - assert madtc.message_auto_delete_time == self.message_auto_delete_time + assert madtc._message_auto_delete_time == self.message_auto_delete_time def test_to_dict(self): madtc = MessageAutoDeleteTimerChanged(self.message_auto_delete_time) madtc_dict = madtc.to_dict() assert isinstance(madtc_dict, dict) - assert madtc_dict["message_auto_delete_time"] == self.message_auto_delete_time + assert madtc_dict["message_auto_delete_time"] == int( + self.message_auto_delete_time.total_seconds() + ) + assert isinstance(madtc_dict["message_auto_delete_time"], int) + + def test_time_period_properties(self, PTB_TIMEDELTA): + message_auto_delete_time = MessageAutoDeleteTimerChanged( + self.message_auto_delete_time + ).message_auto_delete_time + + if PTB_TIMEDELTA: + assert message_auto_delete_time == self.message_auto_delete_time + assert isinstance(message_auto_delete_time, dtm.timedelta) + else: + assert message_auto_delete_time == int(self.message_auto_delete_time.total_seconds()) + assert isinstance(message_auto_delete_time, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA): + MessageAutoDeleteTimerChanged(self.message_auto_delete_time).message_auto_delete_time + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`message_auto_delete_time` will be of type `datetime.timedelta`" in str( + recwarn[0].message + ) + assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): a = MessageAutoDeleteTimerChanged(100) diff --git a/tests/test_messageentity.py b/tests/test_messageentity.py index 8162a8a44d4..63350790048 100644 --- a/tests/test_messageentity.py +++ b/tests/test_messageentity.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,6 +16,8 @@ # # 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 + import pytest from telegram import MessageEntity, User @@ -38,23 +40,23 @@ def message_entity(request): return MessageEntity(type_, 1, 3, url=url, user=user, language=language) -class TestMessageEntityBase: +class MessageEntityTestBase: type_ = "url" offset = 1 length = 2 url = "url" -class TestMessageEntityWithoutRequest(TestMessageEntityBase): +class TestMessageEntityWithoutRequest(MessageEntityTestBase): def test_slot_behaviour(self, message_entity): inst = message_entity 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): + def test_de_json(self, offline_bot): json_dict = {"type": self.type_, "offset": self.offset, "length": self.length} - entity = MessageEntity.de_json(json_dict, bot) + entity = MessageEntity.de_json(json_dict, offline_bot) assert entity.api_kwargs == {} assert entity.type == self.type_ @@ -81,6 +83,75 @@ 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, strict=False): + _, output = input_output + offset, length = output + assert out_entity.offset == offset + assert out_entity.length == length + + @pytest.mark.parametrize("by", [6, "prefix", "𝛙𝌢𑁍"]) + def test_shift_entities(self, by): + kwargs = { + "url": "url", + "user": 42, + "language": "python", + "custom_emoji_id": "custom_emoji_id", + } + entities = [ + MessageEntity(MessageEntity.BOLD, 2, 3, **kwargs), + MessageEntity(MessageEntity.BOLD, 5, 6, **kwargs), + ] + shifted = MessageEntity.shift_entities(by, entities) + assert shifted[0].offset == 8 + assert shifted[1].offset == 11 + + assert shifted[0] is not entities[0] + assert shifted[1] is not entities[1] + + for entity in shifted: + for key, value in kwargs.items(): + assert getattr(entity, key) == value + + def test_concatenate(self): + kwargs = { + "url": "url", + "user": 42, + "language": "python", + "custom_emoji_id": "custom_emoji_id", + } + first_entity = MessageEntity(MessageEntity.BOLD, 0, 6, **kwargs) + second_entity = MessageEntity(MessageEntity.ITALIC, 0, 4, **kwargs) + third_entity = MessageEntity(MessageEntity.UNDERLINE, 3, 6, **kwargs) + + first = ("prefix 𝛙𝌢𑁍 | ", [first_entity], True) + second = ("text 𝛙𝌢𑁍", [second_entity], False) + third = (" | suffix 𝛙𝌢𑁍", [third_entity]) + + new_text, new_entities = MessageEntity.concatenate(first, second, third) + + assert new_text == "prefix 𝛙𝌢𑁍 | text 𝛙𝌢𑁍 | suffix 𝛙𝌢𑁍" + assert [entity.offset for entity in new_entities] == [0, 16, 30] + for old, new in zip( + [first_entity, second_entity, third_entity], new_entities, strict=False + ): + assert new is not old + assert new.type == old.type + for key, value in kwargs.items(): + assert getattr(new, key) == value + def test_equality(self): a = MessageEntity(MessageEntity.BOLD, 2, 3) b = MessageEntity(MessageEntity.BOLD, 2, 3) diff --git a/tests/test_messageid.py b/tests/test_messageid.py index efb980de45d..85053d82ba9 100644 --- a/tests/test_messageid.py +++ b/tests/test_messageid.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_messageorigin.py b/tests/test_messageorigin.py new file mode 100644 index 00000000000..0666c85bf2b --- /dev/null +++ b/tests/test_messageorigin.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm +import inspect +from copy import deepcopy + +import pytest + +from telegram import ( + Chat, + Dice, + MessageOrigin, + MessageOriginChannel, + MessageOriginChat, + MessageOriginHiddenUser, + MessageOriginUser, + User, +) +from telegram._utils.datetime import UTC, to_timestamp +from tests.auxil.slots import mro_slots + +ignored = ["self", "api_kwargs"] + + +class MODefaults: + date: dtm.datetime = to_timestamp(dtm.datetime.utcnow()) + chat = Chat(1, Chat.CHANNEL) + message_id = 123 + author_signautre = "PTB" + sender_chat = Chat(1, Chat.CHANNEL) + sender_user_name = "PTB" + sender_user = User(1, "user", False) + + +def message_origin_channel(): + return MessageOriginChannel( + MODefaults.date, MODefaults.chat, MODefaults.message_id, MODefaults.author_signautre + ) + + +def message_origin_chat(): + return MessageOriginChat( + MODefaults.date, + MODefaults.sender_chat, + MODefaults.author_signautre, + ) + + +def message_origin_hidden_user(): + return MessageOriginHiddenUser(MODefaults.date, MODefaults.sender_user_name) + + +def message_origin_user(): + return MessageOriginUser(MODefaults.date, MODefaults.sender_user) + + +def make_json_dict(instance: MessageOrigin, include_optional_args: bool = False) -> dict: + """Used to make the json dict which we use for testing de_json. Similar to iter_args()""" + json_dict = {"type": instance.type} + sig = inspect.signature(instance.__class__.__init__) + + for param in sig.parameters.values(): + if param.name in ignored: # ignore irrelevant params + continue + + val = getattr(instance, param.name) + # Compulsory args- + if param.default is inspect.Parameter.empty: + if hasattr(val, "to_dict"): # convert the user object or any future ones to dict. + val = val.to_dict() + json_dict[param.name] = val + + # If we want to test all args (for de_json)- + elif param.default is not inspect.Parameter.empty and include_optional_args: + json_dict[param.name] = val + return json_dict + + +def iter_args( + instance: MessageOrigin, de_json_inst: MessageOrigin, include_optional: bool = False +): + """ + We accept both the regular instance and de_json created instance and iterate over them for + easy one line testing later one. + """ + yield instance.type, de_json_inst.type # yield this here cause it's not available in sig. + + sig = inspect.signature(instance.__class__.__init__) + for param in sig.parameters.values(): + if param.name in ignored: + continue + inst_at, json_at = getattr(instance, param.name), getattr(de_json_inst, param.name) + if isinstance(json_at, dtm.datetime): # Convert datetime to int + json_at = to_timestamp(json_at) + if ( + param.default is not inspect.Parameter.empty and include_optional + ) or param.default is inspect.Parameter.empty: + yield inst_at, json_at + + +@pytest.fixture +def message_origin_type(request): + return request.param() + + +@pytest.mark.parametrize( + "message_origin_type", + [ + message_origin_channel, + message_origin_chat, + message_origin_hidden_user, + message_origin_user, + ], + indirect=True, +) +class TestMessageOriginTypesWithoutRequest: + def test_slot_behaviour(self, message_origin_type): + inst = message_origin_type + 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_required_args(self, offline_bot, message_origin_type): + cls = message_origin_type.__class__ + + json_dict = make_json_dict(message_origin_type) + const_message_origin = MessageOrigin.de_json(json_dict, offline_bot) + assert const_message_origin.api_kwargs == {} + + assert isinstance(const_message_origin, MessageOrigin) + assert isinstance(const_message_origin, cls) + for msg_origin_type_at, const_msg_origin_at in iter_args( + message_origin_type, const_message_origin + ): + assert msg_origin_type_at == const_msg_origin_at + + def test_de_json_all_args(self, offline_bot, message_origin_type): + json_dict = make_json_dict(message_origin_type, include_optional_args=True) + const_message_origin = MessageOrigin.de_json(json_dict, offline_bot) + + assert const_message_origin.api_kwargs == {} + + assert isinstance(const_message_origin, MessageOrigin) + assert isinstance(const_message_origin, message_origin_type.__class__) + for msg_origin_type_at, const_msg_origin_at in iter_args( + message_origin_type, const_message_origin, True + ): + assert msg_origin_type_at == const_msg_origin_at + + def test_de_json_messageorigin_localization( + self, message_origin_type, tz_bot, offline_bot, raw_bot + ): + json_dict = make_json_dict(message_origin_type, include_optional_args=True) + msgorigin_raw = MessageOrigin.de_json(json_dict, raw_bot) + msgorigin_bot = MessageOrigin.de_json(json_dict, offline_bot) + msgorigin_tz = MessageOrigin.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + msgorigin_offset = msgorigin_tz.date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset(msgorigin_tz.date.replace(tzinfo=None)) + + assert msgorigin_raw.date.tzinfo == UTC + assert msgorigin_bot.date.tzinfo == UTC + assert msgorigin_offset == tz_bot_offset + + def test_de_json_invalid_type(self, message_origin_type, offline_bot): + json_dict = {"type": "invalid", "date": MODefaults.date} + message_origin_type = MessageOrigin.de_json(json_dict, offline_bot) + + assert type(message_origin_type) is MessageOrigin + assert message_origin_type.type == "invalid" + + def test_de_json_subclass(self, message_origin_type, offline_bot, chat_id): + """This makes sure that e.g. MessageOriginChat(data, offline_bot) never returns a + MessageOriginUser instance.""" + cls = message_origin_type.__class__ + json_dict = make_json_dict(message_origin_type, True) + assert type(cls.de_json(json_dict, offline_bot)) is cls + + def test_to_dict(self, message_origin_type): + message_origin_dict = message_origin_type.to_dict() + + assert isinstance(message_origin_dict, dict) + assert message_origin_dict["type"] == message_origin_type.type + assert message_origin_dict["date"] == message_origin_type.date + + for slot in message_origin_type.__slots__: # additional verification for the optional args + if slot in ("chat", "sender_chat", "sender_user"): + assert (getattr(message_origin_type, slot)).to_dict() == message_origin_dict[slot] + continue + assert getattr(message_origin_type, slot) == message_origin_dict[slot] + + def test_equality(self, message_origin_type): + a = MessageOrigin(type="type", date=MODefaults.date) + b = MessageOrigin(type="type", date=MODefaults.date) + c = message_origin_type + d = deepcopy(message_origin_type) + 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) diff --git a/tests/test_meta.py b/tests/test_meta.py index 683c6c1e3e0..6e8d59dccfe 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -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_modules.py b/tests/test_modules.py index 519bdda9545..3666c2b5c8b 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,13 +19,16 @@ """This tests whether our submodules have __all__ or not. Additionally also tests if all public submodules are included in __all__ for __init__'s. """ + import importlib import os from pathlib import Path +from tests.auxil.files import SOURCE_ROOT_PATH + def test_public_submodules_dunder_all(): - modules_to_search = list(Path("telegram").rglob("*.py")) + modules_to_search = list(SOURCE_ROOT_PATH.rglob("*.py")) if not modules_to_search: raise AssertionError("No modules found to search through, please modify this test.") @@ -52,6 +55,7 @@ def test_public_submodules_dunder_all(): def load_module(path: Path): + path = path.relative_to(SOURCE_ROOT_PATH.parent) if path.name == "__init__.py": mod_name = str(path.parent).replace(os.sep, ".") # telegram(.ext) format else: diff --git a/tests/test_official.py b/tests/test_official.py deleted file mode 100644 index a3cba293aa9..00000000000 --- a/tests/test_official.py +++ /dev/null @@ -1,337 +0,0 @@ -#!/usr/bin/env python -# -# A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 -# 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 inspect -import os -import re -from typing import Dict, List, Set - -import httpx -import pytest -from bs4 import BeautifulSoup - -import telegram -from telegram._utils.defaultvalue import DefaultValue -from tests.auxil.envvars import env_var_2_bool - -IGNORED_OBJECTS = ("ResponseParameters", "CallbackGame") -GLOBALLY_IGNORED_PARAMETERS = { - "self", - "read_timeout", - "write_timeout", - "connect_timeout", - "pool_timeout", - "bot", - "api_kwargs", -} - -# Arguments *added* to the official API -PTB_EXTRA_PARAMS = { - "send_contact": {"contact"}, - "send_location": {"location"}, - "edit_message_live_location": {"location"}, - "send_venue": {"venue"}, - "answer_inline_query": {"current_offset"}, - "send_media_group": {"caption", "parse_mode", "caption_entities"}, - "send_(animation|audio|document|photo|video(_note)?|voice)": {"filename"}, - "InlineQueryResult": {"id", "type"}, # attributes common to all subclasses - "ChatMember": {"user", "status"}, # attributes common to all subclasses - "BotCommandScope": {"type"}, # attributes common to all subclasses - "MenuButton": {"type"}, # attributes common to all subclasses - "PassportFile": {"credentials"}, - "EncryptedPassportElement": {"credentials"}, - "PassportElementError": {"source", "type", "message"}, - "InputMedia": {"caption", "caption_entities", "media", "media_type", "parse_mode"}, - "InputMedia(Animation|Audio|Document|Photo|Video|VideoNote|Voice)": {"filename"}, - "InputFile": {"attach", "filename", "obj"}, -} - - -def _get_params_base(object_name: str, search_dict: Dict[str, Set[str]]) -> Set[str]: - """Helper function for the *_params functions below. - Given an object name and a search dict, goes through the keys of the search dict and checks if - the object name matches any of the regexes (keys). The union of all the sets (values) of the - matching regexes is returned. `object_name` may be a CamelCase or snake_case name. - """ - out = set() - for regex, params in search_dict.items(): - if re.fullmatch(regex, object_name): - out.update(params) - # also check the snake_case version - snake_case_name = re.sub(r"(? Set[str]: - return _get_params_base(object_name, PTB_EXTRA_PARAMS) - - -# Arguments *removed* from the official API -PTB_IGNORED_PARAMS = { - r"InlineQueryResult\w+": {"type"}, - r"ChatMember\w+": {"status"}, - r"PassportElementError\w+": {"source"}, - "ForceReply": {"force_reply"}, - "ReplyKeyboardRemove": {"remove_keyboard"}, - r"BotCommandScope\w+": {"type"}, - r"MenuButton\w+": {"type"}, - r"InputMedia\w+": {"type"}, -} - - -def ptb_ignored_params(object_name) -> Set[str]: - return _get_params_base(object_name, PTB_IGNORED_PARAMS) - - -IGNORED_PARAM_REQUIREMENTS = { - # Ignore these since there's convenience params in them (eg. Venue) - # <---- - "send_location": {"latitude", "longitude"}, - "edit_message_live_location": {"latitude", "longitude"}, - "send_venue": {"latitude", "longitude", "title", "address"}, - "send_contact": {"phone_number", "first_name"}, - # ----> - # These are optional for now for backwards compatibility - # <---- - "InlineQueryResult(Article|Photo|Gif|Mpeg4Gif|Video|Document|Location|Venue)": { - "thumbnail_url", - }, - "InlineQueryResultVideo": {"title"}, - # ----> -} - - -def ignored_param_requirements(object_name) -> Set[str]: - return _get_params_base(object_name, IGNORED_PARAM_REQUIREMENTS) - - -# Arguments that are optional arguments for now for backwards compatibility -BACKWARDS_COMPAT_KWARGS = { - "create_new_sticker_set": { - "stickers", - "sticker_format", - "emojis", - "png_sticker", - "tgs_sticker", - "mask_position", - "webm_sticker", - }, - "add_sticker_to_set": { - "sticker", - "tgs_sticker", - "png_sticker", - "webm_sticker", - "mask_position", - "emojis", - }, - "upload_sticker_file": {"sticker", "sticker_format", "png_sticker"}, - "send_(animation|audio|document|video(_note)?)": {"thumb"}, - "(Animation|Audio|Document|Photo|Sticker(Set)?|Video|VideoNote|Voice)": {"thumb"}, - "InputMedia(Animation|Audio|Document|Video)": {"thumb"}, - "Chat(MemberRestricted|Permissions)": {"can_send_media_messages"}, - "InlineQueryResult(Article|Contact|Document|Location|Venue)": { - "thumb_height", - "thumb_width", - }, - "InlineQueryResult(Article|Photo|Gif|Mpeg4Gif|Video|Contact|Document|Location|Venue)": { - "thumb_url", - }, - "InlineQueryResult(Game|Gif|Mpeg4Gif)": {"thumb_mime_type"}, - "answer_inline_query": {"switch_pm_text", "switch_pm_parameter"}, -} - - -def backwards_compat_kwargs(object_name: str) -> Set[str]: - return _get_params_base(object_name, BACKWARDS_COMPAT_KWARGS) - - -IGNORED_PARAM_REQUIREMENTS.update(BACKWARDS_COMPAT_KWARGS) - - -def find_next_sibling_until(tag, name, until): - for sibling in tag.next_siblings: - if sibling is until: - return None - if sibling.name == name: - return sibling - return None - - -def parse_table(h4) -> List[List[str]]: - """Parses the Telegram doc table and has an output of a 2D list.""" - table = find_next_sibling_until(h4, "table", h4.find_next_sibling("h4")) - if not table: - return [] - t = [] - for tr in table.find_all("tr")[1:]: - t.append([td.text for td in tr.find_all("td")]) - return t - - -def check_method(h4): - name = h4.text # name of the method in telegram's docs. - method = getattr(telegram.Bot, name) # Retrieve our lib method - table = parse_table(h4) - - # Check arguments based on source - sig = inspect.signature(method, follow_wrapped=True) - checked = [] - for tg_parameter in table: # Iterates through each row in the table - # Check if parameter is present in our method - param = sig.parameters.get( - tg_parameter[0] # parameter[0] is first element (the param name) - ) - if param is None: - raise AssertionError(f"Parameter {tg_parameter[0]} not found in {method.__name__}") - - # TODO: Check type via docstring - # Now check if the parameter is required or not - if not check_required_param(tg_parameter, param, method.__name__): - raise AssertionError( - f"Param {param.name!r} of method {method.__name__!r} requirement mismatch!" - ) - - # Now we will check that we don't pass default values if the parameter is not required. - if param.default is not inspect.Parameter.empty: # If there is a default argument... - default_arg_none = check_defaults_type(param) # check if it's None - if not default_arg_none: - raise AssertionError(f"Param {param.name!r} of {method.__name__!r} should be None") - checked.append(tg_parameter[0]) - - expected_additional_args = GLOBALLY_IGNORED_PARAMETERS.copy() - expected_additional_args |= ptb_extra_params(name) - expected_additional_args |= backwards_compat_kwargs(name) - - unexpected_args = (sig.parameters.keys() ^ checked) - expected_additional_args - if unexpected_args != set(): - raise AssertionError( - f"In {method.__qualname__}, unexpected args were found: {unexpected_args}." - ) - - kw_or_positional_args = [ - p.name for p in sig.parameters.values() if p.kind != inspect.Parameter.KEYWORD_ONLY - ] - non_kw_only_args = set(kw_or_positional_args).difference(checked).difference(["self"]) - non_kw_only_args -= backwards_compat_kwargs(name) - if non_kw_only_args != set(): - raise AssertionError( - f"In {method.__qualname__}, extra args should be keyword only " - f"(compared to {name} in API)" - ) - - -def check_object(h4): - name = h4.text - obj = getattr(telegram, name) - table = parse_table(h4) - - # Check arguments based on source. Makes sure to only check __init__'s signature & nothing else - sig = inspect.signature(obj.__init__, follow_wrapped=True) - - checked = set() - fields_removed_by_ptb = ptb_ignored_params(name) - for tg_parameter in table: - field: str = tg_parameter[0] # From telegram docs - - if field in fields_removed_by_ptb: - continue - - if field == "from": - field = "from_user" - - param = sig.parameters.get(field) - if param is None: - raise AssertionError(f"Attribute {field} not found in {obj.__name__}") - # TODO: Check type via docstring - if not check_required_param(tg_parameter, param, obj.__name__): - raise AssertionError(f"{obj.__name__!r} parameter {param.name!r} requirement mismatch") - - if param.default is not inspect.Parameter.empty: # If there is a default argument... - default_arg_none = check_defaults_type(param) # check if its None - if not default_arg_none: - raise AssertionError(f"Param {param.name!r} of {obj.__name__!r} should be `None`") - - checked.add(field) - - expected_additional_args = GLOBALLY_IGNORED_PARAMETERS.copy() - expected_additional_args |= ptb_extra_params(name) - expected_additional_args |= backwards_compat_kwargs(name) - - unexpected_args = (sig.parameters.keys() ^ checked) - expected_additional_args - if unexpected_args != set(): - raise AssertionError(f"In {name}, unexpected args were found: {unexpected_args}.") - - -def is_parameter_required_by_tg(field: str) -> bool: - if field in {"Required", "Yes"}: - return True - return field.split(".", 1)[0] != "Optional" # splits the sentence and extracts first word - - -def check_required_param( - param_desc: List[str], param: inspect.Parameter, method_or_obj_name: str -) -> bool: - """Checks if the method/class parameter is a required/optional param as per Telegram docs. - - Returns: - :obj:`bool`: The boolean returned represents whether our parameter's requirement (optional - or required) is the same as Telegram's or not. - """ - is_ours_required = param.default is inspect.Parameter.empty - telegram_requires = is_parameter_required_by_tg(param_desc[2]) - # Handle cases where we provide convenience intentionally- - if param.name in ignored_param_requirements(method_or_obj_name): - return True - return telegram_requires is is_ours_required - - -def check_defaults_type(ptb_param: inspect.Parameter) -> bool: - return DefaultValue.get_value(ptb_param.default) is None - - -to_run = env_var_2_bool(os.getenv("TEST_OFFICIAL")) -argvalues = [] -names = [] - -if to_run: - argvalues = [] - names = [] - request = httpx.get("https://core.telegram.org/bots/api") - soup = BeautifulSoup(request.text, "html.parser") - - for thing in soup.select("h4 > a.anchor"): - # Methods and types don't have spaces in them, luckily all other sections of the docs do - # TODO: don't depend on that - if "-" not in thing["name"]: - h4 = thing.parent - - # Is it a method - if h4.text[0].lower() == h4.text[0]: - argvalues.append((check_method, h4)) - names.append(h4.text) - elif h4.text not in IGNORED_OBJECTS: # Or a type/object - argvalues.append((check_object, h4)) - names.append(h4.text) - - -@pytest.mark.skipif(not to_run, reason="test_official is not enabled") -@pytest.mark.parametrize(("method", "data"), argvalues=argvalues, ids=names) -def test_official(method, data): - method(data) diff --git a/tests/test_official/__init__.py b/tests/test_official/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_official/arg_type_checker.py b/tests/test_official/arg_type_checker.py new file mode 100644 index 00000000000..98d6d8ca564 --- /dev/null +++ b/tests/test_official/arg_type_checker.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 functions which confirm that the parameters of our methods and classes +match the official API. It also checks if the type annotations are correct and if the parameters +are required or not.""" + +import datetime as dtm +import inspect +import logging +import re +from collections.abc import Sequence +from types import FunctionType +from typing import Any + +from telegram._utils.defaultvalue import DefaultValue +from telegram._utils.types import FileInput, ODVInput +from telegram.ext import Defaults +from tests.test_official.exceptions import ParamTypeCheckingExceptions as PTCE +from tests.test_official.exceptions import ignored_param_requirements +from tests.test_official.helpers import ( + _extract_words, + _get_params_base, + _unionizer, + cached_type_hints, + extract_mappings, + resolve_forward_refs_in_type, + wrap_with_none, +) +from tests.test_official.scraper import TelegramParameter + +ARRAY_OF_PATTERN = r"Array of(?: Array of)? ([\w\,\s]*)" + +# In order to evaluate the type annotation, we need to first have a mapping of the types +# specified in the official API to our types. The keys are types in the column of official API. +TYPE_MAPPING: dict[str, set[Any]] = { + "Integer or String": {int | str}, + "Integer": {int}, + "String": {str}, + r"Boolean|True": {bool}, + r"Float(?: number)?": {float}, + # Distinguishing 1D and 2D Sequences and finding the inner type is done later. + ARRAY_OF_PATTERN: {Sequence}, + r"InputFile(?: or String)?": {resolve_forward_refs_in_type(FileInput)}, +} + +ALL_DEFAULTS = inspect.getmembers(Defaults, lambda x: isinstance(x, property)) + +DATETIME_REGEX = re.compile( + r"""([_]+|\b) # check for word boundary or underscore + date # check for "date" + [^\w]*\b # optionally check for a word after 'date' + """, + re.VERBOSE, +) +TIMEDELTA_REGEX = re.compile(r"((in|number of) seconds)|(\w+_period$)") + +log = logging.debug + + +def check_required_param( + tg_param: TelegramParameter, param: inspect.Parameter, method_or_obj_name: str +) -> bool: + """Checks if the method/class parameter is a required/optional param as per Telegram docs. + + Returns: + :obj:`bool`: The boolean returned represents whether our parameter's requirement (optional + or required) is the same as Telegram's or not. + """ + is_ours_required = param.default is inspect.Parameter.empty + # Handle cases where we provide convenience intentionally- + if param.name in ignored_param_requirements(method_or_obj_name): + return True + return tg_param.param_required is is_ours_required + + +def check_defaults_type(ptb_param: inspect.Parameter) -> bool: + return DefaultValue.get_value(ptb_param.default) is None + + +def check_param_type( + ptb_param: inspect.Parameter, + tg_parameter: TelegramParameter, + obj: FunctionType | type, +) -> tuple[bool, type]: + """This function checks whether the type annotation of the parameter is the same as the one + specified in the official API. It also checks for some special cases where we accept more types + + Args: + ptb_param: The parameter object from our methods/classes + tg_parameter: The table row corresponding to the parameter from official API. + obj: The object (method/class) that we are checking. + + Returns: + :obj:`tuple`: A tuple containing: + * :obj:`bool`: The boolean returned represents whether our parameter's type annotation + is the same as Telegram's or not. + * :obj:`type`: The expected type annotation of the parameter. + """ + # PRE-PROCESSING: + tg_param_type: str = tg_parameter.param_type + is_class = inspect.isclass(obj) + ptb_annotation = cached_type_hints(obj, is_class).get(ptb_param.name) + + # Let's check for a match: + # In order to evaluate the type annotation, we need to first have a mapping of the types + # (see TYPE_MAPPING comment defined at the top level of this module) + mapped: set[type] = _get_params_base(tg_param_type, TYPE_MAPPING) + + # We should have a maximum of one match. + assert len(mapped) <= 1, f"More than one match found for {tg_param_type}" + + # it may be a list of objects, so let's extract them using _extract_words: + org_mapped_type = _unionizer(_extract_words(tg_param_type)) if not mapped else mapped.pop() + # If the parameter is not required by TG, `None` should be added to `mapped_type` + mapped_type = wrap_with_none(tg_parameter, org_mapped_type, obj) + + log( + "At the end of PRE-PROCESSING, the values of variables are:\n" + "Parameter name: %s\n" + "ptb_annotation= %s\n" + "mapped_type= %s\n" + "tg_param_type= %s\n" + "tg_parameter.param_required= %s\n", + ptb_param.name, + ptb_annotation, + mapped_type, + tg_param_type, + tg_parameter.param_required, + ) + + # CHECKING: + # Each branch manipulates the `mapped_type` (except for 5) ) to match the `ptb_annotation`. + + # 1) HANDLING ARRAY TYPES: + # Now let's do the checking, starting with "Array of ..." types. + if "Array of " in tg_param_type: + # For exceptions just check if they contain the annotation + if any(ptb_param.name in key for key in PTCE.ARRAY_OF_EXCEPTIONS): + for (p_name, is_expected_class), exception_type in PTCE.ARRAY_OF_EXCEPTIONS.items(): + if ptb_param.name == p_name and is_class is is_expected_class: + log("Checking that `%s` is an exception!\n", ptb_param.name) + return exception_type in str(ptb_annotation), Sequence + + obj_match: re.Match | None = re.search(ARRAY_OF_PATTERN, tg_param_type) + if obj_match is None: + raise AssertionError(f"Array of {tg_param_type} not found in {ptb_param.name}") + obj_str: str = obj_match.group(1) + # is obj a regular type like str? + array_map: set[type] = _get_params_base(obj_str, TYPE_MAPPING) + + mapped_type = _unionizer(_extract_words(obj_str)) if not array_map else array_map.pop() + + if "Array of Array of" in tg_param_type: + log("Array of Array of type found in `%s`\n", tg_param_type) + mapped_type = Sequence[Sequence[mapped_type]] + else: + log("Array of type found in `%s`\n", tg_param_type) + mapped_type = Sequence[mapped_type] + + # 2) HANDLING OTHER TYPES: + # Special case for send_* methods where we accept more types than the official API: + elif additional_types := extract_mappings(PTCE.ADDITIONAL_TYPES, obj, ptb_param.name): + log("Checking that `%s` accepts additional types for some parameters!\n", obj.__name__) + for at in additional_types: + log("Checking that `%s` is an additional type for `%s`!\n", at, ptb_param.name) + mapped_type = mapped_type | at + + # 3) HANDLING DATETIMES: + elif ( + re.search( + DATETIME_REGEX, + ptb_param.name, + ) + or "Unix time" in tg_parameter.param_description + ): + log("Checking that `%s` is a datetime!\n", ptb_param.name) + # If it's a class, we only accept datetime as the parameter + mapped_type = dtm.datetime if is_class else mapped_type | dtm.datetime + + # 4) HANDLING TIMEDELTA: + elif re.search(TIMEDELTA_REGEX, tg_parameter.param_description) or re.search( + TIMEDELTA_REGEX, ptb_param.name + ): + log("Checking that `%s` is a timedelta!\n", ptb_param.name) + mapped_type = mapped_type | dtm.timedelta + + # 5) COMPLEX TYPES: + # Some types are too complicated, so we replace our annotation with a simpler type: + elif overrides := extract_mappings(PTCE.COMPLEX_TYPES, obj, ptb_param.name): + exception_type = overrides[0] + log("Converting `%s` to a simpler type!\n", ptb_param.name) + ptb_annotation = wrap_with_none(tg_parameter, exception_type, obj) + + # 6) HANDLING DEFAULTS PARAMETERS: + # Classes whose parameters are all ODVInput should be converted and checked. + elif obj.__name__ in PTCE.IGNORED_DEFAULTS_CLASSES: + log("Checking that `%s`'s param is ODVInput:\n", obj.__name__) + # We have to use org_mapped_type here, because ODVInput will not take a None value as well + mapped_type = ODVInput[org_mapped_type] + elif not ( + # Defaults checking should not be done for: + # 1. Parameters that have name conflict with `Defaults.name` + is_class + and obj.__name__ in ("ReplyParameters", "Message", "ExternalReplyInfo") + and ptb_param.name in PTCE.IGNORED_DEFAULTS_PARAM_NAMES + ): + # Now let's check if the parameter is a Defaults parameter, it should be + for name, _ in ALL_DEFAULTS: + if name == ptb_param.name or "parse_mode" in ptb_param.name: + log("Checking that `%s` is a Defaults parameter!\n", ptb_param.name) + # We have to use org_mapped_type here, because ODVInput will not take a None value + mapped_type = ODVInput[org_mapped_type] + break + + # RESULTS:- + mapped_type = wrap_with_none(tg_parameter, mapped_type, obj) + mapped_type = resolve_forward_refs_in_type(mapped_type) + log( + "At RESULTS, we are comparing:\nptb_annotation= %s\nmapped_type= %s\n", + ptb_annotation, + mapped_type, + ) + return mapped_type == ptb_annotation, mapped_type diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py new file mode 100644 index 00000000000..bfc1f065374 --- /dev/null +++ b/tests/test_official/exceptions.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 exceptions to our API compared to the official API.""" + +from telegram import Animation, Audio, Document, Gift, PhotoSize, Sticker, Video, VideoNote, Voice +from tests.test_official.helpers import _get_params_base + +IGNORED_OBJECTS = ("ResponseParameters",) +GLOBALLY_IGNORED_PARAMETERS = { + "self", + "read_timeout", + "write_timeout", + "connect_timeout", + "pool_timeout", + "bot", + "api_kwargs", +} + + +class ParamTypeCheckingExceptions: + # Types for certain parameters accepted by PTB but not in the official API + # structure: method/class_name/regex: {param_name/regex: type} + ADDITIONAL_TYPES = { + r"send_\w*": { + "photo$": PhotoSize, + "video$": Video, + "video_note": VideoNote, + "audio": Audio, + "document": Document, + "animation": Animation, + "voice": Voice, + "sticker": Sticker, + "gift_id": Gift, + }, + "(delete|set)_sticker.*": { + "sticker$": Sticker, + }, + "replace_sticker_in_set": { + "old_sticker$": Sticker, + }, + } + + # TODO: Look into merging this with COMPLEX_TYPES + # Exceptions to the "Array of" types, where we accept more types than the official API + # key: (parameter name, is_class), value: type which must be present in the annotation + ARRAY_OF_EXCEPTIONS = { + ("results", False): "InlineQueryResult", # + Callable + ("commands", False): "BotCommand", # + tuple[str, str] + ("keyboard", True): "KeyboardButton", # + sequence[sequence[str]] + ("reaction", False): "ReactionType", # + str + ("options", False): "InputPollOption", # + str + } + + # Special cases for other parameters that accept more types than the official API, and are + # too complex to compare/predict with official API + # structure: class/method_name: {param_name: reduced form of annotation} + COMPLEX_TYPES = { + "send_poll": {"correct_option_id": int}, # actual: Literal + "get_file": { + "file_id": str, # actual: Union[str, objs_with_file_id_attr] + }, + r"\w+invite_link": { + "invite_link": str, # actual: Union[str, ChatInviteLink] + }, + "send_invoice|create_invoice_link": { + "provider_data": str, # actual: Union[str, obj] + }, + "InlineKeyboardButton": { + "callback_data": str, # actual: Union[str, obj] + }, + "Input(Paid)?Media.*": { + "media": str, # actual: Union[str, InputMedia*, FileInput] + # see also https://github.com/tdlib/telegram-bot-api/issues/707 + "thumbnail": str, # actual: Union[str, FileInput] + "cover": str, # actual: Union[str, FileInput] + }, + "InputProfilePhotoStatic": { + "photo": str, # actual: Union[str, FileInput] + }, + "InputProfilePhotoAnimated": { + "animation": str, # actual: Union[str, FileInput] + }, + "InputSticker": { + "sticker": str, # actual: Union[str, FileInput] + }, + "InputStoryContent.*": { + "photo": str, # actual: Union[str, FileInput] + "video": str, # actual: Union[str, FileInput] + }, + "EncryptedPassportElement": { + "data": str, # actual: Union[IdDocumentData, PersonalDetails, ResidentialAddress] + }, + } + + # param names ignored in the param type checking in classes for the `tg.Defaults` case. + IGNORED_DEFAULTS_PARAM_NAMES = { + "quote", + "link_preview_options", + } + + # These classes' params are all ODVInput, so we ignore them in the defaults type checking. + IGNORED_DEFAULTS_CLASSES = {"LinkPreviewOptions"} + + +# Arguments *added* to the official API +PTB_EXTRA_PARAMS = { + "send_contact": {"contact"}, + "send_location": {"location"}, + "(send_message|edit_message_text)": { # convenience parameters + "disable_web_page_preview", + }, + r"(send|copy)_\w+": { # convenience parameters + "reply_to_message_id", + "allow_sending_without_reply", + }, + "edit_message_live_location": {"location"}, + "send_venue": {"venue"}, + "answer_inline_query": {"current_offset"}, + "send_media_group": {"caption", "parse_mode", "caption_entities"}, + "send_(animation|audio|document|photo|video(_note)?|voice)": {"filename"}, + "InlineQueryResult": {"id", "type"}, # attributes common to all subclasses + "ChatMember": {"user", "status"}, # attributes common to all subclasses + "BotCommandScope": {"type"}, # attributes common to all subclasses + "MenuButton": {"type"}, # attributes common to all subclasses + "PassportFile": {"credentials"}, + "EncryptedPassportElement": {"credentials"}, + "PassportElementError": {"source", "type", "message"}, + "InputMedia": {"caption", "caption_entities", "media", "media_type", "parse_mode"}, + "InputMedia(Animation|Audio|Document|Photo|Video|VideoNote|Voice)": {"filename"}, + "InputFile": {"attach", "filename", "obj", "read_file_handle"}, + "MaybeInaccessibleMessage": {"date", "message_id", "chat"}, # attributes common to all subcls + "ChatBoostSource": {"source"}, # attributes common to all subclasses + "MessageOrigin": {"type", "date"}, # attributes common to all subclasses + "ReactionType": {"type"}, # attributes common to all subclasses + "BackgroundType": {"type"}, # attributes common to all subclasses + "BackgroundFill": {"type"}, # attributes common to all subclasses + "OwnedGift": {"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 + "InputStoryContent": {"type"}, # attributes common to all subclasses + "StoryAreaType": {"type"}, # attributes common to all subclasses + "InputProfilePhoto": {"type"}, # attributes common to all subclasses + # backwards compatibility for api 9.3 changes + # tags: deprecated 22.6, bot api 9.3 + "UniqueGiftInfo": {"last_resale_star_count"}, +} + + +def ptb_extra_params(object_name: str) -> set[str]: + return _get_params_base(object_name, PTB_EXTRA_PARAMS) + + +# Arguments *removed* from the official API +# Mostly due to the value being fixed anyway +PTB_IGNORED_PARAMS = { + r"InlineQueryResult\w+": {"type"}, + r"ChatMember\w+": {"status"}, + r"PassportElementError\w+": {"source"}, + "ForceReply": {"force_reply"}, + "ReplyKeyboardRemove": {"remove_keyboard"}, + r"BotCommandScope\w+": {"type"}, + r"MenuButton\w+": {"type"}, + r"InputMedia\w+": {"type"}, + "InaccessibleMessage": {"date"}, + r"MessageOrigin\w+": {"type"}, + r"ChatBoostSource\w+": {"source"}, + 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"}, + r"InputProfilePhoto\w+": {"type"}, + r"OwnedGift\w+": {"type"}, + r"InputStoryContent\w+": {"type"}, + r"StoryAreaType\w+": {"type"}, +} + + +def ptb_ignored_params(object_name: str) -> set[str]: + return _get_params_base(object_name, PTB_IGNORED_PARAMS) + + +IGNORED_PARAM_REQUIREMENTS = { + # Ignore these since there's convenience params in them (eg. Venue) + # <---- + "send_location": {"latitude", "longitude"}, + "edit_message_live_location": {"latitude", "longitude"}, + "send_venue": {"latitude", "longitude", "title", "address"}, + "send_contact": {"phone_number", "first_name"}, + # ----> + # backwards compatibility for api 9.3 changes + # tags: deprecated 22.6, bot api 9.3 + "UniqueGift": {"gift_id"}, +} + + +def ignored_param_requirements(object_name: str) -> set[str]: + return _get_params_base(object_name, IGNORED_PARAM_REQUIREMENTS) + + +# Arguments that are optional arguments for now for backwards compatibility +BACKWARDS_COMPAT_KWARGS: dict[str, set[str]] = { + # tags: deprecated 22.6, bot api 9.3 + "get_business_account_gifts": {"exclude_limited"}, +} + + +def backwards_compat_kwargs(object_name: str) -> set[str]: + return _get_params_base(object_name, BACKWARDS_COMPAT_KWARGS) + + +IGNORED_PARAM_REQUIREMENTS.update(BACKWARDS_COMPAT_KWARGS) diff --git a/tests/test_official/helpers.py b/tests/test_official/helpers.py new file mode 100644 index 00000000000..2257adb942a --- /dev/null +++ b/tests/test_official/helpers.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 helper functions for the official API tests used in the other modules.""" + +import functools +import re +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, TypeVar, _eval_type, get_type_hints + +from bs4 import PageElement, Tag + +import telegram +import telegram._utils.defaultvalue +import telegram._utils.types + +if TYPE_CHECKING: + from tests.test_official.scraper import TelegramParameter + + +tg_objects = vars(telegram) +tg_objects.update(vars(telegram._utils.types)) +tg_objects.update(vars(telegram._utils.defaultvalue)) + + +def _get_params_base(object_name: str, search_dict: dict[str, set[Any]]) -> set[Any]: + """Helper function for the *_params functions below. + Given an object name and a search dict, goes through the keys of the search dict and checks if + the object name matches any of the regexes (keys). The union of all the sets (values) of the + matching regexes is returned. `object_name` may be a CamelCase or snake_case name. + """ + out = set() + for regex, params in search_dict.items(): + if re.fullmatch(regex, object_name): + out.update(params) + # also check the snake_case version + snake_case_name = re.sub(r"(? set[str]: + """Extracts all words from a string, removing all punctuation and words like 'and' & 'or'.""" + return set(re.sub(r"[^\w\s]", "", text).split()) - {"and", "or"} + + +def _unionizer(annotation: Sequence[Any] | set[Any]) -> Any: + """Returns a union of all the types in the annotation. Also imports objects from lib.""" + union = None + for t in annotation: + if isinstance(t, str): # we have to import objects from lib + t = getattr(telegram, t) # noqa: PLW2901 + union = t if union is None else union | t + return union + + +def find_next_sibling_until(tag: Tag, name: str, until: Tag) -> PageElement | None: + for sibling in tag.next_siblings: + if sibling is until: + return None + if sibling.name == name: + return sibling + return None + + +def is_pascal_case(s): + "PascalCase. Starts with a capital letter and has no spaces. Useful for identifying classes." + return bool(re.match(r"^[A-Z][a-zA-Z\d]*$", s)) + + +def is_parameter_required_by_tg(field: str) -> bool: + if field in {"Required", "Yes"}: + return True + return field.split(".", 1)[0] != "Optional" # splits the sentence and extracts first word + + +def wrap_with_none(tg_parameter: "TelegramParameter", mapped_type: Any, obj: object) -> type: + """Adds `None` to type annotation if the parameter isn't required. Respects ignored params.""" + # have to import here to avoid circular imports + from tests.test_official.exceptions import ignored_param_requirements # noqa: PLC0415 + + if tg_parameter.param_name in ignored_param_requirements(obj.__name__): + return mapped_type | type(None) + return mapped_type | type(None) if not tg_parameter.param_required else mapped_type + + +@functools.cache +def cached_type_hints(obj: Any, is_class: bool) -> dict[str, Any]: + """Returns type hints of a class, method, or function, with forward refs evaluated.""" + return get_type_hints(obj.__init__ if is_class else obj, localns=tg_objects) + + +@functools.cache +def resolve_forward_refs_in_type(obj: type) -> type: + """Resolves forward references in a type hint.""" + return _eval_type(obj, localns=tg_objects, globalns=None) + + +T = TypeVar("T") + + +def extract_mappings( + exceptions: dict[str, dict[str, T]], obj: object, param_name: str +) -> list[T] | None: + mappings = ( + mapping for pattern, mapping in exceptions.items() if (re.match(pattern, obj.__name__)) + ) + out = [ + value + for mapping in mappings + for key, value in mapping.items() + if re.match(key, param_name) + ] + + return None or out diff --git a/tests/test_official/scraper.py b/tests/test_official/scraper.py new file mode 100644 index 00000000000..f32e5070243 --- /dev/null +++ b/tests/test_official/scraper.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 functions which are used to scrape the official Bot API documentation.""" + +import asyncio +from dataclasses import dataclass +from typing import Literal, overload + +import httpx +from bs4 import BeautifulSoup, Tag + +from tests.test_official.exceptions import IGNORED_OBJECTS +from tests.test_official.helpers import ( + find_next_sibling_until, + is_parameter_required_by_tg, + is_pascal_case, +) + + +@dataclass(slots=True, frozen=True) +class TelegramParameter: + """Represents the scraped Telegram parameter. Contains all relevant attributes needed for + comparison. Relevant for both TelegramMethod and TelegramClass.""" + + param_name: str + param_type: str + param_required: bool + param_description: str + + +@dataclass(slots=True, frozen=True) +class TelegramClass: + """Represents the scraped Telegram class. Contains all relevant attributes needed for + comparison.""" + + class_name: str + class_parameters: list[TelegramParameter] + # class_description: str + + +@dataclass(slots=True, frozen=True) +class TelegramMethod: + """Represents the scraped Telegram method. Contains all relevant attributes needed for + comparison.""" + + method_name: str + method_parameters: list[TelegramParameter] + # method_description: str + + +@dataclass(slots=True, frozen=False) +class Scraper: + request: httpx.Response | None = None + soup: BeautifulSoup | None = None + + async def make_request(self) -> None: + async with httpx.AsyncClient() as client: + self.request = await client.get("https://core.telegram.org/bots/api", timeout=10) + self.soup = BeautifulSoup(self.request.text, "html.parser") + + @overload + def parse_docs( + self, doc_type: Literal["method"] + ) -> tuple[list[TelegramMethod], list[str]]: ... + + @overload + def parse_docs(self, doc_type: Literal["class"]) -> tuple[list[TelegramClass], list[str]]: ... + + def parse_docs(self, doc_type): + argvalues = [] + names: list[str] = [] + if self.request is None: + asyncio.run(self.make_request()) + + for unparsed in self.soup.select("h4 > a.anchor"): + if "-" not in unparsed["name"]: + h4: Tag | None = unparsed.parent + name = h4.text + if h4 is None: + raise AssertionError("h4 is None") + if doc_type == "method" and name[0].lower() == name[0]: + params = parse_table_for_params(h4) + obj = TelegramMethod(method_name=name, method_parameters=params) + argvalues.append(obj) + names.append(name) + elif doc_type == "class" and is_pascal_case(name) and name not in IGNORED_OBJECTS: + params = parse_table_for_params(h4) + obj = TelegramClass(class_name=name, class_parameters=params) + argvalues.append(obj) + names.append(name) + + return argvalues, names + + def collect_methods(self) -> tuple[list[TelegramMethod], list[str]]: + return self.parse_docs("method") + + def collect_classes(self) -> tuple[list[TelegramClass], list[str]]: + return self.parse_docs("class") + + +def parse_table_for_params(h4: Tag) -> list[TelegramParameter]: + """Parses the Telegram doc table and outputs a list of TelegramParameter objects.""" + table = find_next_sibling_until(h4, "table", h4.find_next_sibling("h4")) + if not table: + return [] + + params = [] + for tr in table.find_all("tr")[1:]: + fields = [] + for td in tr.find_all("td"): + param = td.text + fields.append(param) + + param_name = fields[0] + param_type = fields[1] + param_required = is_parameter_required_by_tg(fields[2]) + param_desc = fields[-1] # since length can be 2 or 3, but desc is always the last + params.append(TelegramParameter(param_name, param_type, param_required, param_desc)) + + return params diff --git a/tests/test_official/test_official.py b/tests/test_official/test_official.py new file mode 100644 index 00000000000..2c3fd03d6b1 --- /dev/null +++ b/tests/test_official/test_official.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 inspect +from typing import TYPE_CHECKING + +import pytest + +import telegram +from tests.auxil.envvars import RUN_TEST_OFFICIAL +from tests.test_official.arg_type_checker import ( + check_defaults_type, + check_param_type, + check_required_param, +) +from tests.test_official.exceptions import ( + GLOBALLY_IGNORED_PARAMETERS, + backwards_compat_kwargs, + ptb_extra_params, + ptb_ignored_params, +) +from tests.test_official.scraper import Scraper, TelegramClass, TelegramMethod + +if TYPE_CHECKING: + from types import FunctionType + +# Will skip all tests in this file if the env var is False +pytestmark = pytest.mark.skipif(not RUN_TEST_OFFICIAL, reason="test_official is not enabled") + +methods, method_ids, classes, class_ids = [], [], [], [] # not needed (just for completeness) + +if RUN_TEST_OFFICIAL: + scraper = Scraper() + methods, method_ids = scraper.collect_methods() + classes, class_ids = scraper.collect_classes() + + +@pytest.mark.parametrize("tg_method", argvalues=methods, ids=method_ids) +def test_check_method(tg_method: TelegramMethod) -> None: + """This function checks for the following things compared to the official API docs: + + - Method existence + - Parameter existence + - Parameter requirement correctness + - Parameter type annotation existence + - Parameter type annotation correctness + - Parameter default value correctness + - No unexpected parameters + - Extra parameters should be keyword only + """ + ptb_method: FunctionType | None = getattr(telegram.Bot, tg_method.method_name, None) + assert ptb_method, f"Method {tg_method.method_name} not found in telegram.Bot" + + # Check arguments based on source + sig = inspect.signature(ptb_method, follow_wrapped=True) + checked = [] + + for tg_parameter in tg_method.method_parameters: + # Check if parameter is present in our method + ptb_param = sig.parameters.get(tg_parameter.param_name) + assert ptb_param is not None, ( + f"Parameter {tg_parameter.param_name} not found in {ptb_method.__name__}" + ) + + # Now check if the parameter is required or not + assert check_required_param(tg_parameter, ptb_param, ptb_method.__name__), ( + f"Param {ptb_param.name!r} of {ptb_method.__name__!r} requirement mismatch" + ) + + # Check if type annotation is present + assert ptb_param.annotation is not inspect.Parameter.empty, ( + f"Param {ptb_param.name!r} of {ptb_method.__name__!r} should have a type annotation!" + ) + # Check if type annotation is correct + correct_type_hint, expected_type_hint = check_param_type( + ptb_param, + tg_parameter, + ptb_method, + ) + assert correct_type_hint, ( + f"Type hint of param {ptb_param.name!r} of {ptb_method.__name__!r} should be " + f"{expected_type_hint!r} or something else!" + ) + + # Now we will check that we don't pass default values if the parameter is not required. + if ptb_param.default is not inspect.Parameter.empty: # If there is a default argument... + default_arg_none = check_defaults_type(ptb_param) # check if it's None + assert default_arg_none, ( + f"Param {ptb_param.name!r} of {ptb_method.__name__!r} should be `None`" + ) + checked.append(tg_parameter.param_name) + + expected_additional_args = GLOBALLY_IGNORED_PARAMETERS.copy() + expected_additional_args |= ptb_extra_params(tg_method.method_name) + expected_additional_args |= backwards_compat_kwargs(tg_method.method_name) + + unexpected_args = (sig.parameters.keys() ^ checked) - expected_additional_args + assert unexpected_args == set(), ( + f"In {ptb_method.__qualname__}, unexpected args were found: {unexpected_args}." + ) + + kw_or_positional_args = [ + p.name for p in sig.parameters.values() if p.kind != inspect.Parameter.KEYWORD_ONLY + ] + non_kw_only_args = set(kw_or_positional_args).difference(checked).difference(["self"]) + non_kw_only_args -= backwards_compat_kwargs(tg_method.method_name) + assert non_kw_only_args == set(), ( + f"In {ptb_method.__qualname__}, extra args should be keyword only (compared to " + f"{tg_method.method_name} in API)" + ) + + +@pytest.mark.parametrize("tg_class", argvalues=classes, ids=class_ids) +def test_check_object(tg_class: TelegramClass) -> None: + """This function checks for the following things compared to the official API docs: + + - Class existence + - Parameter existence + - Parameter requirement correctness + - Parameter type annotation existence + - Parameter type annotation correctness + - Parameter default value correctness + - No unexpected parameters + """ + obj = getattr(telegram, tg_class.class_name) + + # Check arguments based on source. Makes sure to only check __init__'s signature & nothing else + sig = inspect.signature(obj.__init__, follow_wrapped=True) + + checked = set() + fields_removed_by_ptb = ptb_ignored_params(tg_class.class_name) + + for tg_parameter in tg_class.class_parameters: + field: str = tg_parameter.param_name + + if field in fields_removed_by_ptb: + continue + + if field == "from": + field = "from_user" + + ptb_param = sig.parameters.get(field) + assert ptb_param is not None, f"Attribute {field} not found in {obj.__name__}" + + # Now check if the parameter is required or not + assert check_required_param(tg_parameter, ptb_param, obj.__name__), ( + f"Param {ptb_param.name!r} of {obj.__name__!r} requirement mismatch" + ) + + # Check if type annotation is present + assert ptb_param.annotation is not inspect.Parameter.empty, ( + f"Param {ptb_param.name!r} of {obj.__name__!r} should have a type annotation" + ) + + # Check if type annotation is correct + correct_type_hint, expected_type_hint = check_param_type(ptb_param, tg_parameter, obj) + assert correct_type_hint, ( + f"Type hint of param {ptb_param.name!r} of {obj.__name__!r} should be " + f"{expected_type_hint!r} or something else!" + ) + + # Now we will check that we don't pass default values if the parameter is not required. + if ptb_param.default is not inspect.Parameter.empty: # If there is a default argument... + default_arg_none = check_defaults_type(ptb_param) # check if its None + assert default_arg_none, ( + f"Param {ptb_param.name!r} of {obj.__name__!r} should be `None`" + ) + + checked.add(field) + + expected_additional_args = GLOBALLY_IGNORED_PARAMETERS.copy() + expected_additional_args |= ptb_extra_params(tg_class.class_name) + expected_additional_args |= backwards_compat_kwargs(tg_class.class_name) + + unexpected_args = (sig.parameters.keys() ^ checked) - expected_additional_args + assert unexpected_args == set(), ( + f"In {tg_class.class_name}, unexpected args were found: {unexpected_args}." + ) diff --git a/tests/test_ownedgift.py b/tests/test_ownedgift.py new file mode 100644 index 00000000000..ed6c6bbd9da --- /dev/null +++ b/tests/test_ownedgift.py @@ -0,0 +1,478 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm +from copy import deepcopy + +import pytest + +from telegram import Dice, User +from telegram._files.sticker import Sticker +from telegram._gifts import Gift +from telegram._messageentity import MessageEntity +from telegram._ownedgift import OwnedGift, OwnedGiftRegular, OwnedGifts, OwnedGiftUnique +from telegram._uniquegift import ( + UniqueGift, + UniqueGiftBackdrop, + UniqueGiftBackdropColors, + UniqueGiftModel, + UniqueGiftSymbol, +) +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import OwnedGiftType +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def owned_gift(): + return OwnedGift(type=OwnedGiftTestBase.type) + + +class OwnedGiftTestBase: + type = OwnedGiftType.REGULAR + gift = Gift( + id="some_id", + sticker=Sticker( + file_id="file_id", + file_unique_id="file_unique_id", + width=512, + height=512, + is_animated=False, + is_video=False, + type="regular", + ), + star_count=5, + ) + unique_gift = UniqueGift( + gift_id="gift_id", + base_name="human_readable", + name="unique_name", + number=10, + model=UniqueGiftModel( + name="model_name", + sticker=Sticker("file_id1", "file_unique_id1", 512, 512, False, False, "regular"), + rarity_per_mille=10, + ), + symbol=UniqueGiftSymbol( + name="symbol_name", + sticker=Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + rarity_per_mille=20, + ), + backdrop=UniqueGiftBackdrop( + name="backdrop_name", + colors=UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F), + rarity_per_mille=30, + ), + ) + send_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + owned_gift_id = "not_real_id" + sender_user = User(1, "test user", False) + text = "test text" + entities = ( + MessageEntity(MessageEntity.BOLD, 0, 4), + MessageEntity(MessageEntity.ITALIC, 5, 8), + ) + is_private = True + is_saved = True + can_be_upgraded = True + was_refunded = False + convert_star_count = 100 + prepaid_upgrade_star_count = 200 + can_be_transferred = True + transfer_star_count = 300 + next_transfer_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + is_upgrade_separate = False + unique_gift_number = 37 + + +class TestOwnedGiftWithoutRequest(OwnedGiftTestBase): + def test_slot_behaviour(self, owned_gift): + inst = owned_gift + 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_type_enum_conversion(self, owned_gift): + assert type(OwnedGift("regular").type) is OwnedGiftType + assert OwnedGift("unknown").type == "unknown" + + def test_de_json(self, offline_bot): + data = {"type": "unknown"} + paid_media = OwnedGift.de_json(data, offline_bot) + assert paid_media.api_kwargs == {} + assert paid_media.type == "unknown" + + @pytest.mark.parametrize( + ("og_type", "subclass", "gift"), + [ + ("regular", OwnedGiftRegular, OwnedGiftTestBase.gift), + ("unique", OwnedGiftUnique, OwnedGiftTestBase.unique_gift), + ], + ) + def test_de_json_subclass(self, offline_bot, og_type, subclass, gift): + json_dict = { + "type": og_type, + "gift": gift.to_dict(), + "send_date": to_timestamp(self.send_date), + "owned_gift_id": self.owned_gift_id, + "sender_user": self.sender_user.to_dict(), + "text": self.text, + "entities": [e.to_dict() for e in self.entities], + "is_private": self.is_private, + "is_saved": self.is_saved, + "can_be_upgraded": self.can_be_upgraded, + "was_refunded": self.was_refunded, + "convert_star_count": self.convert_star_count, + "prepaid_upgrade_star_count": self.prepaid_upgrade_star_count, + "can_be_transferred": self.can_be_transferred, + "transfer_star_count": self.transfer_star_count, + "next_transfer_date": to_timestamp(self.next_transfer_date), + } + og = OwnedGift.de_json(json_dict, offline_bot) + + assert type(og) is subclass + assert set(og.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { + "type" + } + assert og.type == og_type + + def test_to_dict(self, owned_gift): + assert owned_gift.to_dict() == {"type": owned_gift.type} + + def test_equality(self, owned_gift): + a = owned_gift + b = OwnedGift(self.type) + c = OwnedGift("unknown") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def owned_gift_regular(): + return OwnedGiftRegular( + gift=TestOwnedGiftRegularWithoutRequest.gift, + send_date=TestOwnedGiftRegularWithoutRequest.send_date, + owned_gift_id=TestOwnedGiftRegularWithoutRequest.owned_gift_id, + sender_user=TestOwnedGiftRegularWithoutRequest.sender_user, + text=TestOwnedGiftRegularWithoutRequest.text, + entities=TestOwnedGiftRegularWithoutRequest.entities, + is_private=TestOwnedGiftRegularWithoutRequest.is_private, + is_saved=TestOwnedGiftRegularWithoutRequest.is_saved, + can_be_upgraded=TestOwnedGiftRegularWithoutRequest.can_be_upgraded, + was_refunded=TestOwnedGiftRegularWithoutRequest.was_refunded, + convert_star_count=TestOwnedGiftRegularWithoutRequest.convert_star_count, + prepaid_upgrade_star_count=TestOwnedGiftRegularWithoutRequest.prepaid_upgrade_star_count, + is_upgrade_separate=TestOwnedGiftRegularWithoutRequest.is_upgrade_separate, + unique_gift_number=TestOwnedGiftRegularWithoutRequest.unique_gift_number, + ) + + +class TestOwnedGiftRegularWithoutRequest(OwnedGiftTestBase): + type = OwnedGiftType.REGULAR + + def test_slot_behaviour(self, owned_gift_regular): + inst = owned_gift_regular + 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, offline_bot): + json_dict = { + "gift": self.gift.to_dict(), + "send_date": to_timestamp(self.send_date), + "owned_gift_id": self.owned_gift_id, + "sender_user": self.sender_user.to_dict(), + "text": self.text, + "entities": [e.to_dict() for e in self.entities], + "is_private": self.is_private, + "is_saved": self.is_saved, + "can_be_upgraded": self.can_be_upgraded, + "was_refunded": self.was_refunded, + "convert_star_count": self.convert_star_count, + "prepaid_upgrade_star_count": self.prepaid_upgrade_star_count, + "is_upgrade_separate": self.is_upgrade_separate, + "unique_gift_number": self.unique_gift_number, + } + ogr = OwnedGiftRegular.de_json(json_dict, offline_bot) + assert ogr.gift == self.gift + assert ogr.send_date == self.send_date + assert ogr.owned_gift_id == self.owned_gift_id + assert ogr.sender_user == self.sender_user + assert ogr.text == self.text + assert ogr.entities == self.entities + assert ogr.is_private == self.is_private + assert ogr.is_saved == self.is_saved + assert ogr.can_be_upgraded == self.can_be_upgraded + assert ogr.was_refunded == self.was_refunded + assert ogr.convert_star_count == self.convert_star_count + assert ogr.prepaid_upgrade_star_count == self.prepaid_upgrade_star_count + assert ogr.is_upgrade_separate == self.is_upgrade_separate + assert ogr.unique_gift_number == self.unique_gift_number + assert ogr.api_kwargs == {} + + def test_to_dict(self, owned_gift_regular): + json_dict = owned_gift_regular.to_dict() + assert isinstance(json_dict, dict) + assert json_dict["type"] == self.type + assert json_dict["gift"] == self.gift.to_dict() + assert json_dict["send_date"] == to_timestamp(self.send_date) + assert json_dict["owned_gift_id"] == self.owned_gift_id + assert json_dict["sender_user"] == self.sender_user.to_dict() + assert json_dict["text"] == self.text + assert json_dict["entities"] == [e.to_dict() for e in self.entities] + assert json_dict["is_private"] == self.is_private + assert json_dict["is_saved"] == self.is_saved + assert json_dict["can_be_upgraded"] == self.can_be_upgraded + assert json_dict["was_refunded"] == self.was_refunded + assert json_dict["convert_star_count"] == self.convert_star_count + assert json_dict["prepaid_upgrade_star_count"] == self.prepaid_upgrade_star_count + assert json_dict["is_upgrade_separate"] == self.is_upgrade_separate + assert json_dict["unique_gift_number"] == self.unique_gift_number + + def test_parse_entity(self, owned_gift_regular): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + + assert owned_gift_regular.parse_entity(entity) == "test" + + with pytest.raises(RuntimeError, match="OwnedGiftRegular has no"): + OwnedGiftRegular( + gift=self.gift, + send_date=self.send_date, + ).parse_entity(entity) + + def test_parse_entities(self, owned_gift_regular): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + entity_2 = MessageEntity(MessageEntity.ITALIC, 5, 8) + + assert owned_gift_regular.parse_entities(MessageEntity.BOLD) == {entity: "test"} + assert owned_gift_regular.parse_entities() == {entity: "test", entity_2: "text"} + + with pytest.raises(RuntimeError, match="OwnedGiftRegular has no"): + OwnedGiftRegular( + gift=self.gift, + send_date=self.send_date, + ).parse_entities() + + def test_equality(self, owned_gift_regular): + a = owned_gift_regular + b = OwnedGiftRegular(deepcopy(self.gift), deepcopy(self.send_date)) + c = OwnedGiftRegular(self.gift, self.send_date + dtm.timedelta(seconds=1)) + d = Dice(5, "test") + + 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) + + +@pytest.fixture +def owned_gift_unique(): + return OwnedGiftUnique( + gift=TestOwnedGiftUniqueWithoutRequest.unique_gift, + send_date=TestOwnedGiftUniqueWithoutRequest.send_date, + owned_gift_id=TestOwnedGiftUniqueWithoutRequest.owned_gift_id, + sender_user=TestOwnedGiftUniqueWithoutRequest.sender_user, + is_saved=TestOwnedGiftUniqueWithoutRequest.is_saved, + can_be_transferred=TestOwnedGiftUniqueWithoutRequest.can_be_transferred, + transfer_star_count=TestOwnedGiftUniqueWithoutRequest.transfer_star_count, + next_transfer_date=TestOwnedGiftUniqueWithoutRequest.next_transfer_date, + ) + + +class TestOwnedGiftUniqueWithoutRequest(OwnedGiftTestBase): + type = OwnedGiftType.UNIQUE + + def test_slot_behaviour(self, owned_gift_unique): + inst = owned_gift_unique + 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, offline_bot): + json_dict = { + "gift": self.unique_gift.to_dict(), + "send_date": to_timestamp(self.send_date), + "owned_gift_id": self.owned_gift_id, + "sender_user": self.sender_user.to_dict(), + "is_saved": self.is_saved, + "can_be_transferred": self.can_be_transferred, + "transfer_star_count": self.transfer_star_count, + "next_transfer_date": to_timestamp(self.next_transfer_date), + } + ogu = OwnedGiftUnique.de_json(json_dict, offline_bot) + assert ogu.gift == self.unique_gift + assert ogu.send_date == self.send_date + assert ogu.owned_gift_id == self.owned_gift_id + assert ogu.sender_user == self.sender_user + assert ogu.is_saved == self.is_saved + assert ogu.can_be_transferred == self.can_be_transferred + assert ogu.transfer_star_count == self.transfer_star_count + assert ogu.next_transfer_date == self.next_transfer_date + assert ogu.api_kwargs == {} + + def test_to_dict(self, owned_gift_unique): + json_dict = owned_gift_unique.to_dict() + assert isinstance(json_dict, dict) + assert json_dict["type"] == self.type + assert json_dict["gift"] == self.unique_gift.to_dict() + assert json_dict["send_date"] == to_timestamp(self.send_date) + assert json_dict["owned_gift_id"] == self.owned_gift_id + assert json_dict["sender_user"] == self.sender_user.to_dict() + assert json_dict["is_saved"] == self.is_saved + assert json_dict["can_be_transferred"] == self.can_be_transferred + assert json_dict["transfer_star_count"] == self.transfer_star_count + assert json_dict["next_transfer_date"] == to_timestamp(self.next_transfer_date) + + def test_equality(self, owned_gift_unique): + a = owned_gift_unique + b = OwnedGiftUnique(deepcopy(self.unique_gift), deepcopy(self.send_date)) + c = OwnedGiftUnique(self.unique_gift, self.send_date + dtm.timedelta(seconds=1)) + d = Dice(5, "test") + + 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) + + +@pytest.fixture +def owned_gifts(request): + return OwnedGifts( + total_count=OwnedGiftsTestBase.total_count, + gifts=OwnedGiftsTestBase.gifts, + next_offset=OwnedGiftsTestBase.next_offset, + ) + + +class OwnedGiftsTestBase: + total_count = 2 + next_offset = "next_offset_str" + gifts: list[OwnedGift] = [ + OwnedGiftRegular( + gift=Gift( + id="id1", + sticker=Sticker( + file_id="file_id", + file_unique_id="file_unique_id", + width=512, + height=512, + is_animated=False, + is_video=False, + type="regular", + ), + star_count=5, + total_count=5, + remaining_count=5, + upgrade_star_count=5, + ), + send_date=dtm.datetime.now(tz=UTC).replace(microsecond=0), + owned_gift_id="some_id_1", + ), + OwnedGiftUnique( + gift=UniqueGift( + gift_id="gift_id", + base_name="human_readable", + name="unique_name", + number=10, + model=UniqueGiftModel( + name="model_name", + sticker=Sticker( + "file_id1", "file_unique_id1", 512, 512, False, False, "regular" + ), + rarity_per_mille=10, + ), + symbol=UniqueGiftSymbol( + name="symbol_name", + sticker=Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + rarity_per_mille=20, + ), + backdrop=UniqueGiftBackdrop( + name="backdrop_name", + colors=UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F), + rarity_per_mille=30, + ), + ), + send_date=dtm.datetime.now(tz=UTC).replace(microsecond=0), + owned_gift_id="some_id_2", + ), + ] + + +class TestOwnedGiftsWithoutRequest(OwnedGiftsTestBase): + def test_slot_behaviour(self, owned_gifts): + for attr in owned_gifts.__slots__: + assert getattr(owned_gifts, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(owned_gifts)) == len(set(mro_slots(owned_gifts))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "total_count": self.total_count, + "gifts": [gift.to_dict() for gift in self.gifts], + "next_offset": self.next_offset, + } + owned_gifts = OwnedGifts.de_json(json_dict, offline_bot) + assert owned_gifts.api_kwargs == {} + + assert owned_gifts.total_count == self.total_count + assert owned_gifts.gifts == tuple(self.gifts) + assert type(owned_gifts.gifts[0]) is OwnedGiftRegular + assert type(owned_gifts.gifts[1]) is OwnedGiftUnique + assert owned_gifts.next_offset == self.next_offset + + def test_to_dict(self, owned_gifts): + gifts_dict = owned_gifts.to_dict() + + assert isinstance(gifts_dict, dict) + assert gifts_dict["total_count"] == self.total_count + assert gifts_dict["gifts"] == [gift.to_dict() for gift in self.gifts] + assert gifts_dict["next_offset"] == self.next_offset + + def test_equality(self, owned_gifts): + a = owned_gifts + b = OwnedGifts(self.total_count, self.gifts) + c = OwnedGifts(self.total_count - 1, self.gifts[:1]) + d = Dice(5, "test") + + 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/test_paidmedia.py b/tests/test_paidmedia.py new file mode 100644 index 00000000000..536fcc1f2ad --- /dev/null +++ b/tests/test_paidmedia.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm +from copy import deepcopy + +import pytest + +from telegram import ( + Dice, + PaidMedia, + PaidMediaInfo, + PaidMediaPhoto, + PaidMediaPreview, + PaidMediaPurchased, + PaidMediaVideo, + PhotoSize, + User, + Video, +) +from telegram.constants import PaidMediaType +from telegram.warnings import PTBDeprecationWarning +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def paid_media(): + return PaidMedia(type=PaidMediaType.PHOTO) + + +class PaidMediaTestBase: + type = PaidMediaType.PHOTO + width = 640 + height = 480 + duration = dtm.timedelta(60) + video = Video( + file_id="video_file_id", + width=640, + height=480, + file_unique_id="file_unique_id", + duration=dtm.timedelta(seconds=60), + ) + photo = ( + PhotoSize( + file_id="photo_file_id", + width=640, + height=480, + file_unique_id="file_unique_id", + ), + ) + + +class TestPaidMediaWithoutRequest(PaidMediaTestBase): + 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_type_enum_conversion(self, paid_media): + assert type(PaidMedia("photo").type) is PaidMediaType + assert PaidMedia("unknown").type == "unknown" + + def test_de_json(self, offline_bot): + data = {"type": "unknown"} + paid_media = PaidMedia.de_json(data, offline_bot) + assert paid_media.api_kwargs == {} + assert paid_media.type == "unknown" + + @pytest.mark.parametrize( + ("pm_type", "subclass"), + [ + ("photo", PaidMediaPhoto), + ("video", PaidMediaVideo), + ("preview", PaidMediaPreview), + ], + ) + def test_de_json_subclass(self, offline_bot, pm_type, subclass): + json_dict = { + "type": pm_type, + "video": self.video.to_dict(), + "photo": [p.to_dict() for p in self.photo], + "width": self.width, + "height": self.height, + "duration": int(self.duration.total_seconds()), + } + pm = PaidMedia.de_json(json_dict, offline_bot) + + # TODO: Should be removed when the timedelta migartion is complete + extra_slots = {"duration"} if subclass is PaidMediaPreview else set() + + assert type(pm) is subclass + assert set(pm.api_kwargs.keys()) == set(json_dict.keys()) - ( + set(subclass.__slots__) | extra_slots + ) - {"type"} + assert pm.type == pm_type + + def test_to_dict(self, paid_media): + assert paid_media.to_dict() == {"type": paid_media.type} + + def test_equality(self, paid_media): + a = paid_media + b = PaidMedia(self.type) + c = PaidMedia("unknown") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def paid_media_photo(): + return PaidMediaPhoto( + photo=TestPaidMediaPhotoWithoutRequest.photo, + ) + + +class TestPaidMediaPhotoWithoutRequest(PaidMediaTestBase): + type = PaidMediaType.PHOTO + + def test_slot_behaviour(self, paid_media_photo): + inst = 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_de_json(self, offline_bot): + json_dict = { + "photo": [p.to_dict() for p in self.photo], + } + pmp = PaidMediaPhoto.de_json(json_dict, offline_bot) + assert pmp.photo == tuple(self.photo) + assert pmp.api_kwargs == {} + + def test_to_dict(self, paid_media_photo): + assert paid_media_photo.to_dict() == { + "type": paid_media_photo.type, + "photo": [p.to_dict() for p in self.photo], + } + + def test_equality(self, paid_media_photo): + a = paid_media_photo + b = PaidMediaPhoto(deepcopy(self.photo)) + c = PaidMediaPhoto([PhotoSize("file_id", 640, 480, "file_unique_id")]) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def paid_media_video(): + return PaidMediaVideo( + video=TestPaidMediaVideoWithoutRequest.video, + ) + + +class TestPaidMediaVideoWithoutRequest(PaidMediaTestBase): + type = PaidMediaType.VIDEO + + def test_slot_behaviour(self, paid_media_video): + inst = 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_de_json(self, offline_bot): + json_dict = { + "video": self.video.to_dict(), + } + pmv = PaidMediaVideo.de_json(json_dict, offline_bot) + assert pmv.video == self.video + assert pmv.api_kwargs == {} + + def test_to_dict(self, paid_media_video): + assert paid_media_video.to_dict() == { + "type": self.type, + "video": paid_media_video.video.to_dict(), + } + + def test_equality(self, paid_media_video): + a = paid_media_video + b = PaidMediaVideo( + video=deepcopy(self.video), + ) + c = PaidMediaVideo( + video=Video("test", "test_unique", 640, 480, 60), + ) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def paid_media_preview(): + return PaidMediaPreview( + width=TestPaidMediaPreviewWithoutRequest.width, + height=TestPaidMediaPreviewWithoutRequest.height, + duration=TestPaidMediaPreviewWithoutRequest.duration, + ) + + +class TestPaidMediaPreviewWithoutRequest(PaidMediaTestBase): + type = PaidMediaType.PREVIEW + + def test_slot_behaviour(self, paid_media_preview): + inst = paid_media_preview + 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, offline_bot): + json_dict = { + "width": self.width, + "height": self.height, + "duration": int(self.duration.total_seconds()), + } + pmp = PaidMediaPreview.de_json(json_dict, offline_bot) + assert pmp.width == self.width + assert pmp.height == self.height + assert pmp._duration == self.duration + assert pmp.api_kwargs == {} + + def test_to_dict(self, paid_media_preview): + paid_media_preview_dict = paid_media_preview.to_dict() + + assert isinstance(paid_media_preview_dict, dict) + assert paid_media_preview_dict["type"] == paid_media_preview.type + assert paid_media_preview_dict["width"] == paid_media_preview.width + assert paid_media_preview_dict["height"] == paid_media_preview.height + assert paid_media_preview_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(paid_media_preview_dict["duration"], int) + + def test_equality(self, paid_media_preview): + a = paid_media_preview + b = PaidMediaPreview( + width=self.width, + height=self.height, + duration=self.duration, + ) + x = PaidMediaPreview( + width=self.width, + height=self.height, + duration=int(self.duration.total_seconds()), + ) + c = PaidMediaPreview( + width=100, + height=100, + duration=100, + ) + d = Dice(5, "test") + + assert a == b + assert b == x + assert hash(a) == hash(b) + assert hash(b) == hash(x) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + def test_time_period_properties(self, PTB_TIMEDELTA, paid_media_preview): + duration = paid_media_preview.duration + + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, paid_media_preview): + paid_media_preview.duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + + +# =========================================================================================== +# =========================================================================================== +# =========================================================================================== +# =========================================================================================== +# =========================================================================================== +# =========================================================================================== +# =========================================================================================== +# =========================================================================================== +# =========================================================================================== +# =========================================================================================== + + +@pytest.fixture(scope="module") +def paid_media_info(): + return PaidMediaInfo( + star_count=PaidMediaInfoTestBase.star_count, + paid_media=PaidMediaInfoTestBase.paid_media, + ) + + +@pytest.fixture(scope="module") +def paid_media_purchased(): + return PaidMediaPurchased( + from_user=PaidMediaPurchasedTestBase.from_user, + paid_media_payload=PaidMediaPurchasedTestBase.paid_media_payload, + ) + + +class PaidMediaInfoTestBase: + star_count = 200 + paid_media = [ + PaidMediaVideo( + video=Video( + file_id="video_file_id", + width=640, + height=480, + file_unique_id="file_unique_id", + duration=60, + ) + ), + PaidMediaPhoto( + photo=[ + PhotoSize( + file_id="photo_file_id", + width=640, + height=480, + file_unique_id="file_unique_id", + ) + ] + ), + ] + + +class TestPaidMediaInfoWithoutRequest(PaidMediaInfoTestBase): + 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, offline_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, offline_bot) + assert pmi.paid_media == tuple(self.paid_media) + assert pmi.star_count == self.star_count + + 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=self.paid_media) + pmi2 = PaidMediaInfo(star_count=self.star_count, paid_media=self.paid_media) + pmi3 = PaidMediaInfo(star_count=100, paid_media=[self.paid_media[0]]) + + assert pmi1 == pmi2 + assert hash(pmi1) == hash(pmi2) + + assert pmi1 != pmi3 + assert hash(pmi1) != hash(pmi3) + + +class PaidMediaPurchasedTestBase: + from_user = User(1, "user", False) + paid_media_payload = "payload" + + +class TestPaidMediaPurchasedWithoutRequest(PaidMediaPurchasedTestBase): + def test_slot_behaviour(self, paid_media_purchased): + inst = paid_media_purchased + 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 = { + "from": self.from_user.to_dict(), + "paid_media_payload": self.paid_media_payload, + } + pmp = PaidMediaPurchased.de_json(json_dict, bot) + assert pmp.from_user == self.from_user + assert pmp.paid_media_payload == self.paid_media_payload + assert pmp.api_kwargs == {} + + def test_to_dict(self, paid_media_purchased): + assert paid_media_purchased.to_dict() == { + "from": self.from_user.to_dict(), + "paid_media_payload": self.paid_media_payload, + } + + def test_equality(self): + pmp1 = PaidMediaPurchased( + from_user=self.from_user, + paid_media_payload=self.paid_media_payload, + ) + pmp2 = PaidMediaPurchased( + from_user=self.from_user, + paid_media_payload=self.paid_media_payload, + ) + pmp3 = PaidMediaPurchased( + from_user=User(2, "user", False), + paid_media_payload="other", + ) + + assert pmp1 == pmp2 + assert hash(pmp1) == hash(pmp2) + + assert pmp1 != pmp3 + assert hash(pmp1) != hash(pmp3) diff --git a/tests/test_paidmessagepricechanged.py b/tests/test_paidmessagepricechanged.py new file mode 100644 index 00000000000..4ee0e39fb3e --- /dev/null +++ b/tests/test_paidmessagepricechanged.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 Dice, PaidMessagePriceChanged +from tests.auxil.slots import mro_slots + + +class PaidMessagePriceChangedTestBase: + paid_message_star_count = 291 + + +@pytest.fixture(scope="module") +def paid_message_price_changed(): + return PaidMessagePriceChanged(PaidMessagePriceChangedTestBase.paid_message_star_count) + + +class TestPaidMessagePriceChangedWithoutRequest(PaidMessagePriceChangedTestBase): + def test_slot_behaviour(self, paid_message_price_changed): + for attr in paid_message_price_changed.__slots__: + assert getattr(paid_message_price_changed, attr, "err") != "err", ( + f"got extra slot '{attr}'" + ) + assert len(mro_slots(paid_message_price_changed)) == len( + set(mro_slots(paid_message_price_changed)) + ), "duplicate slot" + + def test_to_dict(self, paid_message_price_changed): + pmpc_dict = paid_message_price_changed.to_dict() + assert isinstance(pmpc_dict, dict) + assert pmpc_dict["paid_message_star_count"] == self.paid_message_star_count + + def test_de_json(self, offline_bot): + json_dict = {"paid_message_star_count": self.paid_message_star_count} + pmpc = PaidMessagePriceChanged.de_json(json_dict, offline_bot) + assert isinstance(pmpc, PaidMessagePriceChanged) + assert pmpc.paid_message_star_count == self.paid_message_star_count + assert pmpc.api_kwargs == {} + + def test_equality(self): + pmpc1 = PaidMessagePriceChanged(self.paid_message_star_count) + pmpc2 = PaidMessagePriceChanged(self.paid_message_star_count) + pmpc3 = PaidMessagePriceChanged(3) + dice = Dice(5, "emoji") + + assert pmpc1 == pmpc2 + assert hash(pmpc1) == hash(pmpc2) + + assert pmpc1 != pmpc3 + assert hash(pmpc1) != hash(pmpc3) + + assert pmpc1 != dice + assert hash(pmpc1) != hash(dice) diff --git a/tests/test_poll.py b/tests/test_poll.py index d17f667368e..2edc93d3d9f 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -15,29 +15,114 @@ # # 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 datetime import datetime, timedelta, timezone +import datetime as dtm import pytest -from telegram import MessageEntity, Poll, PollAnswer, PollOption, User +from telegram import Chat, InputPollOption, MessageEntity, Poll, PollAnswer, PollOption, User from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import PollType +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots +@pytest.fixture(scope="module") +def input_poll_option(): + out = InputPollOption( + text=InputPollOptionTestBase.text, + text_parse_mode=InputPollOptionTestBase.text_parse_mode, + text_entities=InputPollOptionTestBase.text_entities, + ) + out._unfreeze() + return out + + +class InputPollOptionTestBase: + text = "test option" + text_parse_mode = "MarkdownV2" + text_entities = [ + MessageEntity(0, 4, MessageEntity.BOLD), + MessageEntity(5, 7, MessageEntity.ITALIC), + ] + + +class TestInputPollOptionWithoutRequest(InputPollOptionTestBase): + def test_slot_behaviour(self, input_poll_option): + for attr in input_poll_option.__slots__: + assert getattr(input_poll_option, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(input_poll_option)) == len(set(mro_slots(input_poll_option))), ( + "duplicate slot" + ) + + def test_de_json(self): + json_dict = { + "text": self.text, + "text_parse_mode": self.text_parse_mode, + "text_entities": [e.to_dict() for e in self.text_entities], + } + input_poll_option = InputPollOption.de_json(json_dict, None) + assert input_poll_option.api_kwargs == {} + + assert input_poll_option.text == self.text + assert input_poll_option.text_parse_mode == self.text_parse_mode + assert input_poll_option.text_entities == tuple(self.text_entities) + + def test_to_dict(self, input_poll_option): + input_poll_option_dict = input_poll_option.to_dict() + + assert isinstance(input_poll_option_dict, dict) + assert input_poll_option_dict["text"] == input_poll_option.text + assert input_poll_option_dict["text_parse_mode"] == input_poll_option.text_parse_mode + assert input_poll_option_dict["text_entities"] == [ + e.to_dict() for e in input_poll_option.text_entities + ] + + # Test that the default-value parameter is handled correctly + input_poll_option = InputPollOption("text") + input_poll_option_dict = input_poll_option.to_dict() + assert "text_parse_mode" not in input_poll_option_dict + + def test_equality(self): + a = InputPollOption("text") + b = InputPollOption("text", self.text_parse_mode) + c = InputPollOption("text", text_entities=self.text_entities) + d = InputPollOption("different_text") + e = Poll(123, "question", ["O1", "O2"], 1, False, True, Poll.REGULAR, True) + + 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) + + @pytest.fixture(scope="module") def poll_option(): - out = PollOption(text=TestPollOptionBase.text, voter_count=TestPollOptionBase.voter_count) + out = PollOption( + text=PollOptionTestBase.text, + voter_count=PollOptionTestBase.voter_count, + text_entities=PollOptionTestBase.text_entities, + ) out._unfreeze() return out -class TestPollOptionBase: +class PollOptionTestBase: text = "test option" voter_count = 3 + text_entities = [ + MessageEntity(MessageEntity.BOLD, 0, 4), + MessageEntity(MessageEntity.ITALIC, 5, 6), + ] -class TestPollOptionWithoutRequest(TestPollOptionBase): +class TestPollOptionWithoutRequest(PollOptionTestBase): def test_slot_behaviour(self, poll_option): for attr in poll_option.__slots__: assert getattr(poll_option, attr, "err") != "err", f"got extra slot '{attr}'" @@ -51,12 +136,43 @@ def test_de_json(self): assert poll_option.text == self.text assert poll_option.voter_count == self.voter_count + def test_de_json_all(self): + json_dict = { + "text": self.text, + "voter_count": self.voter_count, + "text_entities": [e.to_dict() for e in self.text_entities], + } + poll_option = PollOption.de_json(json_dict, None) + + assert poll_option.api_kwargs == {} + + assert poll_option.text == self.text + assert poll_option.voter_count == self.voter_count + assert poll_option.text_entities == tuple(self.text_entities) + def test_to_dict(self, poll_option): poll_option_dict = poll_option.to_dict() assert isinstance(poll_option_dict, dict) assert poll_option_dict["text"] == poll_option.text assert poll_option_dict["voter_count"] == poll_option.voter_count + assert poll_option_dict["text_entities"] == [ + e.to_dict() for e in poll_option.text_entities + ] + + def test_parse_entity(self, poll_option): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + poll_option.text_entities = [entity] + + assert poll_option.parse_entity(entity) == "test" + + def test_parse_entities(self, poll_option): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + entity_2 = MessageEntity(MessageEntity.ITALIC, 5, 6) + poll_option.text_entities = [entity, entity_2] + + assert poll_option.parse_entities(MessageEntity.BOLD) == {entity: "test"} + assert poll_option.parse_entities() == {entity: "test", entity_2: "option"} def test_equality(self): a = PollOption("text", 1) @@ -81,50 +197,58 @@ def test_equality(self): @pytest.fixture(scope="module") def poll_answer(): return PollAnswer( - TestPollAnswerBase.poll_id, TestPollAnswerBase.user, TestPollAnswerBase.poll_id + PollAnswerTestBase.poll_id, + PollAnswerTestBase.option_ids, + PollAnswerTestBase.user, + PollAnswerTestBase.voter_chat, ) -class TestPollAnswerBase: +class PollAnswerTestBase: poll_id = "id" - user = User(1, "", False) option_ids = [2] + user = User(1, "", False) + voter_chat = Chat(1, "") -class TestPollAnswerWithoutRequest(TestPollAnswerBase): +class TestPollAnswerWithoutRequest(PollAnswerTestBase): def test_de_json(self): json_dict = { "poll_id": self.poll_id, - "user": self.user.to_dict(), "option_ids": self.option_ids, + "user": self.user.to_dict(), + "voter_chat": self.voter_chat.to_dict(), } poll_answer = PollAnswer.de_json(json_dict, None) assert poll_answer.api_kwargs == {} assert poll_answer.poll_id == self.poll_id - assert poll_answer.user == self.user assert poll_answer.option_ids == tuple(self.option_ids) + assert poll_answer.user == self.user + assert poll_answer.voter_chat == self.voter_chat def test_to_dict(self, poll_answer): poll_answer_dict = poll_answer.to_dict() assert isinstance(poll_answer_dict, dict) assert poll_answer_dict["poll_id"] == poll_answer.poll_id - assert poll_answer_dict["user"] == poll_answer.user.to_dict() assert poll_answer_dict["option_ids"] == list(poll_answer.option_ids) + assert poll_answer_dict["user"] == poll_answer.user.to_dict() + assert poll_answer_dict["voter_chat"] == poll_answer.voter_chat.to_dict() def test_equality(self): - a = PollAnswer(123, self.user, [2]) - b = PollAnswer(123, User(1, "first", False), [2]) - c = PollAnswer(123, self.user, [1, 2]) - d = PollAnswer(456, self.user, [2]) - e = PollOption("Text", 1) + a = PollAnswer(123, [2], self.user, self.voter_chat) + b = PollAnswer(123, [2], self.user, Chat(1, "")) + c = PollAnswer(123, [2], User(1, "first", False), self.voter_chat) + d = PollAnswer(123, [1, 2], self.user, self.voter_chat) + e = PollAnswer(456, [2], self.user, self.voter_chat) + f = PollOption("Text", 1) assert a == b assert hash(a) == hash(b) - assert a != c - assert hash(a) != hash(c) + assert a == c + assert hash(a) == hash(c) assert a != d assert hash(a) != hash(d) @@ -132,30 +256,34 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) + assert a != f + assert hash(a) != hash(f) + @pytest.fixture(scope="module") def poll(): poll = Poll( - TestPollBase.id_, - TestPollBase.question, - TestPollBase.options, - TestPollBase.total_voter_count, - TestPollBase.is_closed, - TestPollBase.is_anonymous, - TestPollBase.type, - TestPollBase.allows_multiple_answers, - explanation=TestPollBase.explanation, - explanation_entities=TestPollBase.explanation_entities, - open_period=TestPollBase.open_period, - close_date=TestPollBase.close_date, + PollTestBase.id_, + PollTestBase.question, + PollTestBase.options, + PollTestBase.total_voter_count, + PollTestBase.is_closed, + PollTestBase.is_anonymous, + PollTestBase.type, + PollTestBase.allows_multiple_answers, + explanation=PollTestBase.explanation, + explanation_entities=PollTestBase.explanation_entities, + open_period=PollTestBase.open_period, + close_date=PollTestBase.close_date, + question_entities=PollTestBase.question_entities, ) poll._unfreeze() return poll -class TestPollBase: +class PollTestBase: id_ = "id" - question = "Test?" + question = "Test Question?" options = [PollOption("test", 10), PollOption("test2", 11)] total_voter_count = 0 is_closed = True @@ -167,12 +295,16 @@ class TestPollBase: b"\\u200d\\U0001f467\\U0001f431http://google.com" ).decode("unicode-escape") explanation_entities = [MessageEntity(13, 17, MessageEntity.URL)] - open_period = 42 - close_date = datetime.now(timezone.utc) + open_period = dtm.timedelta(seconds=42) + close_date = dtm.datetime.now(dtm.timezone.utc) + question_entities = [ + MessageEntity(MessageEntity.BOLD, 0, 4), + MessageEntity(MessageEntity.ITALIC, 5, 8), + ] -class TestPollWithoutRequest(TestPollBase): - def test_de_json(self, bot): +class TestPollWithoutRequest(PollTestBase): + def test_de_json(self, offline_bot): json_dict = { "id": self.id_, "question": self.question, @@ -184,10 +316,11 @@ def test_de_json(self, bot): "allows_multiple_answers": self.allows_multiple_answers, "explanation": self.explanation, "explanation_entities": [self.explanation_entities[0].to_dict()], - "open_period": self.open_period, + "open_period": int(self.open_period.total_seconds()), "close_date": to_timestamp(self.close_date), + "question_entities": [e.to_dict() for e in self.question_entities], } - poll = Poll.de_json(json_dict, bot) + poll = Poll.de_json(json_dict, offline_bot) assert poll.api_kwargs == {} assert poll.id == self.id_ @@ -204,11 +337,12 @@ def test_de_json(self, bot): assert poll.allows_multiple_answers == self.allows_multiple_answers assert poll.explanation == self.explanation assert poll.explanation_entities == tuple(self.explanation_entities) - assert poll.open_period == self.open_period - assert abs(poll.close_date - self.close_date) < timedelta(seconds=1) + assert poll._open_period == self.open_period + assert abs(poll.close_date - self.close_date) < dtm.timedelta(seconds=1) assert to_timestamp(poll.close_date) == to_timestamp(self.close_date) + assert poll.question_entities == tuple(self.question_entities) - def test_de_json_localization(self, tz_bot, bot, raw_bot): + def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): json_dict = { "id": self.id_, "question": self.question, @@ -220,12 +354,13 @@ def test_de_json_localization(self, tz_bot, bot, raw_bot): "allows_multiple_answers": self.allows_multiple_answers, "explanation": self.explanation, "explanation_entities": [self.explanation_entities[0].to_dict()], - "open_period": self.open_period, + "open_period": int(self.open_period.total_seconds()), "close_date": to_timestamp(self.close_date), + "question_entities": [e.to_dict() for e in self.question_entities], } poll_raw = Poll.de_json(json_dict, raw_bot) - poll_bot = Poll.de_json(json_dict, bot) + poll_bot = Poll.de_json(json_dict, offline_bot) poll_bot_tz = Poll.de_json(json_dict, tz_bot) # comparing utcoffsets because comparing timezones is unpredicatable @@ -252,8 +387,27 @@ def test_to_dict(self, poll): assert poll_dict["allows_multiple_answers"] == poll.allows_multiple_answers assert poll_dict["explanation"] == poll.explanation assert poll_dict["explanation_entities"] == [poll.explanation_entities[0].to_dict()] - assert poll_dict["open_period"] == poll.open_period + assert poll_dict["open_period"] == int(self.open_period.total_seconds()) assert poll_dict["close_date"] == to_timestamp(poll.close_date) + assert poll_dict["question_entities"] == [e.to_dict() for e in poll.question_entities] + + def test_time_period_properties(self, PTB_TIMEDELTA, poll): + if PTB_TIMEDELTA: + assert poll.open_period == self.open_period + assert isinstance(poll.open_period, dtm.timedelta) + else: + assert poll.open_period == int(self.open_period.total_seconds()) + assert isinstance(poll.open_period, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, poll): + poll.open_period + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`open_period` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): a = Poll(123, "question", ["O1", "O2"], 1, False, True, Poll.REGULAR, True) @@ -294,7 +448,7 @@ def test_enum_init(self): ) assert poll.type is PollType.QUIZ - def test_parse_entity(self, poll): + def test_parse_explanation_entity(self, poll): entity = MessageEntity(type=MessageEntity.URL, offset=13, length=17) poll.explanation_entities = [entity] @@ -312,10 +466,36 @@ def test_parse_entity(self, poll): allows_multiple_answers=False, ).parse_explanation_entity(entity) - def test_parse_entities(self, poll): + def test_parse_explanation_entities(self, poll): entity = MessageEntity(type=MessageEntity.URL, offset=13, length=17) entity_2 = MessageEntity(type=MessageEntity.BOLD, offset=13, length=1) poll.explanation_entities = [entity_2, entity] assert poll.parse_explanation_entities(MessageEntity.URL) == {entity: "http://google.com"} assert poll.parse_explanation_entities() == {entity: "http://google.com", entity_2: "h"} + + with pytest.raises(RuntimeError, match="Poll has no"): + Poll( + "id", + "question", + [PollOption("text", voter_count=0)], + total_voter_count=0, + is_closed=False, + is_anonymous=False, + type=Poll.QUIZ, + allows_multiple_answers=False, + ).parse_explanation_entities() + + def test_parse_question_entity(self, poll): + entity = MessageEntity(MessageEntity.ITALIC, 5, 8) + poll.question_entities = [entity] + + assert poll.parse_question_entity(entity) == "Question" + + def test_parse_question_entities(self, poll): + entity = MessageEntity(MessageEntity.ITALIC, 5, 8) + entity_2 = MessageEntity(MessageEntity.BOLD, 0, 4) + poll.question_entities = [entity_2, entity] + + assert poll.parse_question_entities(MessageEntity.ITALIC) == {entity: "Question"} + assert poll.parse_question_entities() == {entity: "Question", entity_2: "Test"} diff --git a/tests/test_proximityalerttriggered.py b/tests/test_proximityalerttriggered.py index 6a1207cf52c..c8464b284c3 100644 --- a/tests/test_proximityalerttriggered.py +++ b/tests/test_proximityalerttriggered.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,32 +25,32 @@ @pytest.fixture(scope="module") def proximity_alert_triggered(): return ProximityAlertTriggered( - TestProximityAlertTriggeredBase.traveler, - TestProximityAlertTriggeredBase.watcher, - TestProximityAlertTriggeredBase.distance, + ProximityAlertTriggeredTestBase.traveler, + ProximityAlertTriggeredTestBase.watcher, + ProximityAlertTriggeredTestBase.distance, ) -class TestProximityAlertTriggeredBase: +class ProximityAlertTriggeredTestBase: traveler = User(1, "foo", False) watcher = User(2, "bar", False) distance = 42 -class TestProximityAlertTriggeredWithoutRequest(TestProximityAlertTriggeredBase): +class TestProximityAlertTriggeredWithoutRequest(ProximityAlertTriggeredTestBase): def test_slot_behaviour(self, proximity_alert_triggered): inst = proximity_alert_triggered 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): + def test_de_json(self, offline_bot): json_dict = { "traveler": self.traveler.to_dict(), "watcher": self.watcher.to_dict(), "distance": self.distance, } - proximity_alert_triggered = ProximityAlertTriggered.de_json(json_dict, bot) + proximity_alert_triggered = ProximityAlertTriggered.de_json(json_dict, offline_bot) assert proximity_alert_triggered.api_kwargs == {} assert proximity_alert_triggered.traveler == self.traveler diff --git a/tests/test_reaction.py b/tests/test_reaction.py new file mode 100644 index 00000000000..be64ae17a83 --- /dev/null +++ b/tests/test_reaction.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 ( + BotCommand, + Dice, + ReactionCount, + ReactionType, + ReactionTypeCustomEmoji, + ReactionTypeEmoji, + ReactionTypePaid, + constants, +) +from telegram.constants import ReactionEmoji +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def reaction_type(): + return ReactionType(type=TestReactionTypeWithoutRequest.type) + + +class ReactionTypeTestBase: + type = "emoji" + emoji = "some_emoji" + custom_emoji_id = "some_custom_emoji_id" + + +class TestReactionTypeWithoutRequest(ReactionTypeTestBase): + def test_slot_behaviour(self, reaction_type): + inst = reaction_type + 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_type_enum_conversion(self): + assert type(ReactionType("emoji").type) is constants.ReactionType + assert ReactionType("unknown").type == "unknown" + + def test_de_json(self, offline_bot): + json_dict = {"type": "unknown"} + reaction_type = ReactionType.de_json(json_dict, offline_bot) + assert reaction_type.api_kwargs == {} + assert reaction_type.type == "unknown" + + @pytest.mark.parametrize( + ("rt_type", "subclass"), + [ + ("emoji", ReactionTypeEmoji), + ("custom_emoji", ReactionTypeCustomEmoji), + ("paid", ReactionTypePaid), + ], + ) + def test_de_json_subclass(self, offline_bot, rt_type, subclass): + json_dict = { + "type": rt_type, + "emoji": self.emoji, + "custom_emoji_id": self.custom_emoji_id, + } + rt = ReactionType.de_json(json_dict, offline_bot) + + assert type(rt) is subclass + assert set(rt.api_kwargs.keys()) == set(json_dict.keys()) - set(subclass.__slots__) - { + "type" + } + assert rt.type == rt_type + + def test_to_dict(self, reaction_type): + reaction_type_dict = reaction_type.to_dict() + assert isinstance(reaction_type_dict, dict) + assert reaction_type_dict["type"] == reaction_type.type + + +@pytest.fixture(scope="module") +def reaction_type_emoji(): + return ReactionTypeEmoji(emoji=TestReactionTypeEmojiWithoutRequest.emoji) + + +class TestReactionTypeEmojiWithoutRequest(ReactionTypeTestBase): + type = constants.ReactionType.EMOJI + + def test_slot_behaviour(self, reaction_type_emoji): + inst = reaction_type_emoji + 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, offline_bot): + json_dict = {"emoji": self.emoji} + reaction_type_emoji = ReactionTypeEmoji.de_json(json_dict, offline_bot) + assert reaction_type_emoji.api_kwargs == {} + assert reaction_type_emoji.type == self.type + assert reaction_type_emoji.emoji == self.emoji + + def test_to_dict(self, reaction_type_emoji): + reaction_type_emoji_dict = reaction_type_emoji.to_dict() + assert isinstance(reaction_type_emoji_dict, dict) + assert reaction_type_emoji_dict["type"] == reaction_type_emoji.type + assert reaction_type_emoji_dict["emoji"] == reaction_type_emoji.emoji + + def test_equality(self, reaction_type_emoji): + a = reaction_type_emoji + b = ReactionTypeEmoji(emoji=self.emoji) + c = ReactionTypeEmoji(emoji="other_emoji") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture(scope="module") +def reaction_type_custom_emoji(): + return ReactionTypeCustomEmoji( + custom_emoji_id=TestReactionTypeCustomEmojiWithoutRequest.custom_emoji_id + ) + + +class TestReactionTypeCustomEmojiWithoutRequest(ReactionTypeTestBase): + type = constants.ReactionType.CUSTOM_EMOJI + + def test_slot_behaviour(self, reaction_type_custom_emoji): + inst = reaction_type_custom_emoji + 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, offline_bot): + json_dict = {"custom_emoji_id": self.custom_emoji_id} + reaction_type_custom_emoji = ReactionTypeCustomEmoji.de_json(json_dict, offline_bot) + assert reaction_type_custom_emoji.api_kwargs == {} + assert reaction_type_custom_emoji.type == self.type + assert reaction_type_custom_emoji.custom_emoji_id == self.custom_emoji_id + + def test_to_dict(self, reaction_type_custom_emoji): + reaction_type_custom_emoji_dict = reaction_type_custom_emoji.to_dict() + assert isinstance(reaction_type_custom_emoji_dict, dict) + assert reaction_type_custom_emoji_dict["type"] == reaction_type_custom_emoji.type + assert ( + reaction_type_custom_emoji_dict["custom_emoji_id"] + == reaction_type_custom_emoji.custom_emoji_id + ) + + def test_equality(self, reaction_type_custom_emoji): + a = reaction_type_custom_emoji + b = ReactionTypeCustomEmoji(custom_emoji_id=self.custom_emoji_id) + c = ReactionTypeCustomEmoji(custom_emoji_id="other_custom_emoji_id") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture(scope="module") +def reaction_type_paid(): + return ReactionTypePaid() + + +class TestReactionTypePaidWithoutRequest(ReactionTypeTestBase): + type = constants.ReactionType.PAID + + def test_slot_behaviour(self, reaction_type_paid): + inst = reaction_type_paid + 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, offline_bot): + json_dict = {} + reaction_type_paid = ReactionTypePaid.de_json(json_dict, offline_bot) + assert reaction_type_paid.api_kwargs == {} + assert reaction_type_paid.type == self.type + + def test_to_dict(self, reaction_type_paid): + reaction_type_paid_dict = reaction_type_paid.to_dict() + assert isinstance(reaction_type_paid_dict, dict) + assert reaction_type_paid_dict["type"] == reaction_type_paid.type + + +@pytest.fixture(scope="module") +def reaction_count(): + return ReactionCount( + type=TestReactionCountWithoutRequest.type, + total_count=TestReactionCountWithoutRequest.total_count, + ) + + +class TestReactionCountWithoutRequest: + type = ReactionTypeEmoji(ReactionEmoji.THUMBS_UP) + total_count = 42 + + def test_slot_behaviour(self, reaction_count): + for attr in reaction_count.__slots__: + assert getattr(reaction_count, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(reaction_count)) == len(set(mro_slots(reaction_count))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "type": self.type.to_dict(), + "total_count": self.total_count, + } + + reaction_count = ReactionCount.de_json(json_dict, offline_bot) + assert reaction_count.api_kwargs == {} + + assert isinstance(reaction_count, ReactionCount) + assert reaction_count.type == self.type + assert reaction_count.type.type == self.type.type + assert reaction_count.type.emoji == self.type.emoji + assert reaction_count.total_count == self.total_count + + def test_to_dict(self, reaction_count): + reaction_count_dict = reaction_count.to_dict() + + assert isinstance(reaction_count_dict, dict) + assert reaction_count_dict["type"] == reaction_count.type.to_dict() + assert reaction_count_dict["total_count"] == reaction_count.total_count + + def test_equality(self, reaction_count): + a = reaction_count + b = ReactionCount( + type=self.type, + total_count=self.total_count, + ) + c = ReactionCount( + type=self.type, + total_count=self.total_count + 1, + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_reply.py b/tests/test_reply.py new file mode 100644 index 00000000000..16e9da49957 --- /dev/null +++ b/tests/test_reply.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm + +import pytest + +from telegram import ( + BotCommand, + Chat, + Checklist, + ChecklistTask, + ExternalReplyInfo, + Giveaway, + LinkPreviewOptions, + MessageEntity, + MessageOriginUser, + PaidMediaInfo, + PaidMediaPreview, + ReplyParameters, + TextQuote, + User, +) +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def external_reply_info(): + return ExternalReplyInfo( + origin=ExternalReplyInfoTestBase.origin, + chat=ExternalReplyInfoTestBase.chat, + message_id=ExternalReplyInfoTestBase.message_id, + link_preview_options=ExternalReplyInfoTestBase.link_preview_options, + giveaway=ExternalReplyInfoTestBase.giveaway, + paid_media=ExternalReplyInfoTestBase.paid_media, + checklist=ExternalReplyInfoTestBase.checklist, + ) + + +class ExternalReplyInfoTestBase: + origin = MessageOriginUser( + dtm.datetime.now(dtm.timezone.utc).replace(microsecond=0), User(1, "user", False) + ) + chat = Chat(1, Chat.SUPERGROUP) + message_id = 123 + link_preview_options = LinkPreviewOptions(True) + giveaway = Giveaway( + (Chat(1, Chat.CHANNEL), Chat(2, Chat.SUPERGROUP)), + dtm.datetime.now(dtm.timezone.utc).replace(microsecond=0), + 1, + ) + paid_media = PaidMediaInfo(5, [PaidMediaPreview(10, 10, 10)]) + checklist = Checklist( + title="Checklist Title", + tasks=[ + ChecklistTask(text="Item 1", id=1), + ChecklistTask(text="Item 2", id=2), + ], + ) + + +class TestExternalReplyInfoWithoutRequest(ExternalReplyInfoTestBase): + def test_slot_behaviour(self, external_reply_info): + for attr in external_reply_info.__slots__: + assert getattr(external_reply_info, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(external_reply_info)) == len(set(mro_slots(external_reply_info))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "origin": self.origin.to_dict(), + "chat": self.chat.to_dict(), + "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(), + "checklist": self.checklist.to_dict(), + } + + external_reply_info = ExternalReplyInfo.de_json(json_dict, offline_bot) + assert external_reply_info.api_kwargs == {} + + assert external_reply_info.origin == self.origin + assert external_reply_info.chat == self.chat + 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 external_reply_info.checklist == self.checklist + + def test_to_dict(self, external_reply_info): + ext_reply_info_dict = external_reply_info.to_dict() + + assert isinstance(ext_reply_info_dict, dict) + assert ext_reply_info_dict["origin"] == self.origin.to_dict() + assert ext_reply_info_dict["chat"] == self.chat.to_dict() + 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() + assert ext_reply_info_dict["checklist"] == self.checklist.to_dict() + + def test_equality(self, external_reply_info): + a = external_reply_info + b = ExternalReplyInfo(origin=self.origin) + c = ExternalReplyInfo( + origin=MessageOriginUser(dtm.datetime.utcnow(), User(2, "user", False)) + ) + + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture(scope="module") +def text_quote(): + return TextQuote( + text=TextQuoteTestBase.text, + position=TextQuoteTestBase.position, + entities=TextQuoteTestBase.entities, + is_manual=TextQuoteTestBase.is_manual, + ) + + +class TextQuoteTestBase: + text = "text" + position = 1 + entities = [ + MessageEntity(MessageEntity.MENTION, 1, 2), + MessageEntity(MessageEntity.EMAIL, 3, 4), + ] + is_manual = True + + +class TestTextQuoteWithoutRequest(TextQuoteTestBase): + def test_slot_behaviour(self, text_quote): + for attr in text_quote.__slots__: + assert getattr(text_quote, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(text_quote)) == len(set(mro_slots(text_quote))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "text": self.text, + "position": self.position, + "entities": [entity.to_dict() for entity in self.entities], + "is_manual": self.is_manual, + } + + text_quote = TextQuote.de_json(json_dict, offline_bot) + assert text_quote.api_kwargs == {} + + assert text_quote.text == self.text + assert text_quote.position == self.position + assert text_quote.entities == tuple(self.entities) + assert text_quote.is_manual == self.is_manual + + def test_to_dict(self, text_quote): + text_quote_dict = text_quote.to_dict() + + assert isinstance(text_quote_dict, dict) + assert text_quote_dict["text"] == self.text + assert text_quote_dict["position"] == self.position + assert text_quote_dict["entities"] == [entity.to_dict() for entity in self.entities] + assert text_quote_dict["is_manual"] == self.is_manual + + def test_equality(self, text_quote): + a = text_quote + b = TextQuote(text=self.text, position=self.position) + c = TextQuote(text="foo", position=self.position) + d = TextQuote(text=self.text, position=7) + + e = BotCommand("start", "description") + + 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) + + +@pytest.fixture(scope="module") +def reply_parameters(): + return ReplyParameters( + message_id=ReplyParametersTestBase.message_id, + chat_id=ReplyParametersTestBase.chat_id, + allow_sending_without_reply=ReplyParametersTestBase.allow_sending_without_reply, + quote=ReplyParametersTestBase.quote, + quote_parse_mode=ReplyParametersTestBase.quote_parse_mode, + quote_entities=ReplyParametersTestBase.quote_entities, + quote_position=ReplyParametersTestBase.quote_position, + checklist_task_id=ReplyParametersTestBase.checklist_task_id, + ) + + +class ReplyParametersTestBase: + message_id = 123 + chat_id = 456 + allow_sending_without_reply = True + quote = "foo" + quote_parse_mode = "html" + quote_entities = [ + MessageEntity(MessageEntity.MENTION, 1, 2), + MessageEntity(MessageEntity.EMAIL, 3, 4), + ] + quote_position = 5 + checklist_task_id = 9 + + +class TestReplyParametersWithoutRequest(ReplyParametersTestBase): + def test_slot_behaviour(self, reply_parameters): + for attr in reply_parameters.__slots__: + assert getattr(reply_parameters, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(reply_parameters)) == len(set(mro_slots(reply_parameters))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "message_id": self.message_id, + "chat_id": self.chat_id, + "allow_sending_without_reply": self.allow_sending_without_reply, + "quote": self.quote, + "quote_parse_mode": self.quote_parse_mode, + "quote_entities": [entity.to_dict() for entity in self.quote_entities], + "quote_position": self.quote_position, + "checklist_task_id": self.checklist_task_id, + } + + reply_parameters = ReplyParameters.de_json(json_dict, offline_bot) + assert reply_parameters.api_kwargs == {} + + assert reply_parameters.message_id == self.message_id + assert reply_parameters.chat_id == self.chat_id + assert reply_parameters.allow_sending_without_reply == self.allow_sending_without_reply + assert reply_parameters.quote == self.quote + assert reply_parameters.quote_parse_mode == self.quote_parse_mode + assert reply_parameters.quote_entities == tuple(self.quote_entities) + assert reply_parameters.quote_position == self.quote_position + assert reply_parameters.checklist_task_id == self.checklist_task_id + + def test_to_dict(self, reply_parameters): + reply_parameters_dict = reply_parameters.to_dict() + + assert isinstance(reply_parameters_dict, dict) + assert reply_parameters_dict["message_id"] == self.message_id + assert reply_parameters_dict["chat_id"] == self.chat_id + assert ( + reply_parameters_dict["allow_sending_without_reply"] + == self.allow_sending_without_reply + ) + assert reply_parameters_dict["quote"] == self.quote + assert reply_parameters_dict["quote_parse_mode"] == self.quote_parse_mode + assert reply_parameters_dict["quote_entities"] == [ + entity.to_dict() for entity in self.quote_entities + ] + assert reply_parameters_dict["quote_position"] == self.quote_position + assert reply_parameters_dict["checklist_task_id"] == self.checklist_task_id + + def test_equality(self, reply_parameters): + a = reply_parameters + b = ReplyParameters(message_id=self.message_id) + c = ReplyParameters(message_id=7) + + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) diff --git a/tests/test_replykeyboardmarkup.py b/tests/test_replykeyboardmarkup.py index b0f329b983e..19750cc41bd 100644 --- a/tests/test_replykeyboardmarkup.py +++ b/tests/test_replykeyboardmarkup.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -26,15 +26,15 @@ @pytest.fixture(scope="module") def reply_keyboard_markup(): return ReplyKeyboardMarkup( - TestReplyKeyboardMarkupBase.keyboard, - resize_keyboard=TestReplyKeyboardMarkupBase.resize_keyboard, - one_time_keyboard=TestReplyKeyboardMarkupBase.one_time_keyboard, - selective=TestReplyKeyboardMarkupBase.selective, - is_persistent=TestReplyKeyboardMarkupBase.is_persistent, + ReplyKeyboardMarkupTestBase.keyboard, + resize_keyboard=ReplyKeyboardMarkupTestBase.resize_keyboard, + one_time_keyboard=ReplyKeyboardMarkupTestBase.one_time_keyboard, + selective=ReplyKeyboardMarkupTestBase.selective, + is_persistent=ReplyKeyboardMarkupTestBase.is_persistent, ) -class TestReplyKeyboardMarkupBase: +class ReplyKeyboardMarkupTestBase: keyboard = [[KeyboardButton("button1"), KeyboardButton("button2")]] resize_keyboard = True one_time_keyboard = True @@ -42,7 +42,7 @@ class TestReplyKeyboardMarkupBase: is_persistent = True -class TestReplyKeyboardMarkupWithoutRequest(TestReplyKeyboardMarkupBase): +class TestReplyKeyboardMarkupWithoutRequest(ReplyKeyboardMarkupTestBase): def test_slot_behaviour(self, reply_keyboard_markup): inst = reply_keyboard_markup for attr in inst.__slots__: @@ -154,7 +154,7 @@ def test_from_column(self): assert len(reply_keyboard_markup[1]) == 1 -class TestReplyKeyboardMarkupWithRequest(TestReplyKeyboardMarkupBase): +class TestReplyKeyboardMarkupWithRequest(ReplyKeyboardMarkupTestBase): async def test_send_message_with_reply_keyboard_markup( self, bot, chat_id, reply_keyboard_markup ): diff --git a/tests/test_replykeyboardremove.py b/tests/test_replykeyboardremove.py index 92df80cb143..4c5ada7736c 100644 --- a/tests/test_replykeyboardremove.py +++ b/tests/test_replykeyboardremove.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -24,15 +24,15 @@ @pytest.fixture(scope="module") def reply_keyboard_remove(): - return ReplyKeyboardRemove(selective=TestReplyKeyboardRemoveBase.selective) + return ReplyKeyboardRemove(selective=ReplyKeyboardRemoveTestBase.selective) -class TestReplyKeyboardRemoveBase: +class ReplyKeyboardRemoveTestBase: remove_keyboard = True selective = True -class TestReplyKeyboardRemoveWithoutRequest(TestReplyKeyboardRemoveBase): +class TestReplyKeyboardRemoveWithoutRequest(ReplyKeyboardRemoveTestBase): def test_slot_behaviour(self, reply_keyboard_remove): inst = reply_keyboard_remove for attr in inst.__slots__: @@ -52,7 +52,7 @@ def test_to_dict(self, reply_keyboard_remove): assert reply_keyboard_remove_dict["selective"] == reply_keyboard_remove.selective -class TestReplyKeyboardRemoveWithRequest(TestReplyKeyboardRemoveBase): +class TestReplyKeyboardRemoveWithRequest(ReplyKeyboardRemoveTestBase): async def test_send_message_with_reply_keyboard_remove( self, bot, chat_id, reply_keyboard_remove ): diff --git a/tests/test_sentwebappmessage.py b/tests/test_sentwebappmessage.py index 59afeaeb252..b820ae72892 100644 --- a/tests/test_sentwebappmessage.py +++ b/tests/test_sentwebappmessage.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,14 +25,14 @@ @pytest.fixture(scope="module") def sent_web_app_message(): - return SentWebAppMessage(inline_message_id=TestSentWebAppMessageBase.inline_message_id) + return SentWebAppMessage(inline_message_id=SentWebAppMessageTestBase.inline_message_id) -class TestSentWebAppMessageBase: +class SentWebAppMessageTestBase: inline_message_id = "123" -class TestSentWebAppMessageWithoutRequest(TestSentWebAppMessageBase): +class TestSentWebAppMessageWithoutRequest(SentWebAppMessageTestBase): def test_slot_behaviour(self, sent_web_app_message): inst = sent_web_app_message for attr in inst.__slots__: @@ -45,7 +45,7 @@ def test_to_dict(self, sent_web_app_message): assert isinstance(sent_web_app_message_dict, dict) assert sent_web_app_message_dict["inline_message_id"] == self.inline_message_id - def test_de_json(self, bot): + def test_de_json(self, offline_bot): data = {"inline_message_id": self.inline_message_id} m = SentWebAppMessage.de_json(data, None) assert m.api_kwargs == {} diff --git a/tests/test_shared.py b/tests/test_shared.py index 8b57a85d701..a35e5f4ee6c 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -19,52 +19,52 @@ import pytest -from telegram import ChatShared, UserShared +from telegram import ChatShared, PhotoSize, SharedUser, UsersShared from tests.auxil.slots import mro_slots @pytest.fixture(scope="class") -def user_shared(): - return UserShared( - TestUserSharedBase.request_id, - TestUserSharedBase.user_id, - ) +def users_shared(): + return UsersShared(UsersSharedTestBase.request_id, users=UsersSharedTestBase.users) -class TestUserSharedBase: +class UsersSharedTestBase: request_id = 789 - user_id = 101112 + user_ids = (101112, 101113) + users = (SharedUser(101112, "user1"), SharedUser(101113, "user2")) -class TestUserSharedWithoutRequest(TestUserSharedBase): - def test_slot_behaviour(self, user_shared): - for attr in user_shared.__slots__: - assert getattr(user_shared, attr, "err") != "err", f"got extra slot '{attr}'" - assert len(mro_slots(user_shared)) == len(set(mro_slots(user_shared))), "duplicate slot" +class TestUsersSharedWithoutRequest(UsersSharedTestBase): + def test_slot_behaviour(self, users_shared): + for attr in users_shared.__slots__: + assert getattr(users_shared, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(users_shared)) == len(set(mro_slots(users_shared))), "duplicate slot" - def test_to_dict(self, user_shared): - user_shared_dict = user_shared.to_dict() + def test_to_dict(self, users_shared): + users_shared_dict = users_shared.to_dict() - assert isinstance(user_shared_dict, dict) - assert user_shared_dict["request_id"] == self.request_id - assert user_shared_dict["user_id"] == self.user_id + assert isinstance(users_shared_dict, dict) + assert users_shared_dict["request_id"] == self.request_id + assert users_shared_dict["users"] == [user.to_dict() for user in self.users] - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "request_id": self.request_id, - "user_id": self.user_id, + "users": [user.to_dict() for user in self.users], + "user_ids": self.user_ids, } - user_shared = UserShared.de_json(json_dict, bot) - assert user_shared.api_kwargs == {} + users_shared = UsersShared.de_json(json_dict, offline_bot) + assert users_shared.api_kwargs == {"user_ids": self.user_ids} - assert user_shared.request_id == self.request_id - assert user_shared.user_id == self.user_id + assert users_shared.request_id == self.request_id + assert users_shared.users == self.users def test_equality(self): - a = UserShared(self.request_id, self.user_id) - b = UserShared(self.request_id, self.user_id) - c = UserShared(1, self.user_id) - d = UserShared(self.request_id, 1) + a = UsersShared(self.request_id, users=self.users) + b = UsersShared(self.request_id, users=self.users) + c = UsersShared(1, users=self.users) + d = UsersShared(self.request_id, users=(SharedUser(1, "user1"), SharedUser(1, "user2"))) + e = PhotoSize("file_id", "1", 1, 1) assert a == b assert hash(a) == hash(b) @@ -76,21 +76,24 @@ def test_equality(self): assert a != d assert hash(a) != hash(d) + assert a != e + assert hash(a) != hash(e) + @pytest.fixture(scope="class") def chat_shared(): return ChatShared( - TestChatSharedBase.request_id, - TestChatSharedBase.chat_id, + ChatSharedTestBase.request_id, + ChatSharedTestBase.chat_id, ) -class TestChatSharedBase: +class ChatSharedTestBase: request_id = 131415 chat_id = 161718 -class TestChatSharedWithoutRequest(TestChatSharedBase): +class TestChatSharedWithoutRequest(ChatSharedTestBase): def test_slot_behaviour(self, chat_shared): for attr in chat_shared.__slots__: assert getattr(chat_shared, attr, "err") != "err", f"got extra slot '{attr}'" @@ -103,22 +106,150 @@ def test_to_dict(self, chat_shared): assert chat_shared_dict["request_id"] == self.request_id assert chat_shared_dict["chat_id"] == self.chat_id - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "request_id": self.request_id, "chat_id": self.chat_id, } - chat_shared = ChatShared.de_json(json_dict, bot) + chat_shared = ChatShared.de_json(json_dict, offline_bot) assert chat_shared.api_kwargs == {} assert chat_shared.request_id == self.request_id assert chat_shared.chat_id == self.chat_id - def test_equality(self): + def test_link(self): + chat_shared = ChatShared(1, 123, username="username") + assert chat_shared.link == f"https://t.me/{chat_shared.username}" + chat_shared = ChatShared(1, 123) + assert chat_shared.link is None + + def test_equality(self, users_shared): a = ChatShared(self.request_id, self.chat_id) b = ChatShared(self.request_id, self.chat_id) c = ChatShared(1, self.chat_id) d = ChatShared(self.request_id, 1) + e = users_shared + + 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) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture(scope="class") +def shared_user(): + return SharedUser( + SharedUserTestBase.user_id, + SharedUserTestBase.first_name, + last_name=SharedUserTestBase.last_name, + username=SharedUserTestBase.username, + photo=SharedUserTestBase.photo, + ) + + +class SharedUserTestBase: + user_id = 101112 + first_name = "first" + last_name = "last" + username = "user" + photo = ( + PhotoSize(file_id="file_id", width=1, height=1, file_unique_id="1"), + PhotoSize(file_id="file_id", width=2, height=2, file_unique_id="2"), + ) + + +class TestSharedUserWithoutRequest(SharedUserTestBase): + def test_slot_behaviour(self, shared_user): + for attr in shared_user.__slots__: + assert getattr(shared_user, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(shared_user)) == len(set(mro_slots(shared_user))), "duplicate slot" + + def test_to_dict(self, shared_user): + shared_user_dict = shared_user.to_dict() + + assert isinstance(shared_user_dict, dict) + assert shared_user_dict["user_id"] == self.user_id + assert shared_user_dict["first_name"] == self.first_name + assert shared_user_dict["last_name"] == self.last_name + assert shared_user_dict["username"] == self.username + assert shared_user_dict["photo"] == [photo.to_dict() for photo in self.photo] + + def test_de_json_required(self, offline_bot): + json_dict = { + "user_id": self.user_id, + "first_name": self.first_name, + } + shared_user = SharedUser.de_json(json_dict, offline_bot) + assert shared_user.api_kwargs == {} + + assert shared_user.user_id == self.user_id + assert shared_user.first_name == self.first_name + assert shared_user.last_name is None + assert shared_user.username is None + assert shared_user.photo == () + + def test_de_json_all(self, offline_bot): + json_dict = { + "user_id": self.user_id, + "first_name": self.first_name, + "last_name": self.last_name, + "username": self.username, + "photo": [photo.to_dict() for photo in self.photo], + } + shared_user = SharedUser.de_json(json_dict, offline_bot) + assert shared_user.api_kwargs == {} + + assert shared_user.user_id == self.user_id + assert shared_user.first_name == self.first_name + assert shared_user.last_name == self.last_name + assert shared_user.username == self.username + assert shared_user.photo == self.photo + + def test_name(self): + shared_user = SharedUser(123, "first_name", "last_name", "username") + assert shared_user.name == f"@{shared_user.username}" + shared_user = SharedUser(123, "first_name", "last_name") + assert shared_user.name == f"{shared_user.first_name} {shared_user.last_name}" + shared_user = SharedUser(123, "first_name") + assert shared_user.name == f"{shared_user.first_name}" + shared_user = SharedUser(123, "first_name", username="username") + assert shared_user.name == f"@{shared_user.username}" + shared_user = SharedUser(123) + assert shared_user.name is None + + def test_full_name(self): + shared_user = SharedUser(123, "first_name", "last_name") + assert shared_user.full_name == f"{shared_user.first_name} {shared_user.last_name}" + shared_user = SharedUser(123, "first_name") + assert shared_user.full_name == f"{shared_user.first_name}" + shared_user = SharedUser(123) + assert shared_user.full_name is None + + def test_link(self): + shared_user = SharedUser(123, username="username") + assert shared_user.link == f"https://t.me/{shared_user.username}" + shared_user = SharedUser(123, "first_name", "last_name") + assert shared_user.link is None + + def test_equality(self, chat_shared): + a = SharedUser( + self.user_id, + self.first_name, + last_name=self.last_name, + username=self.username, + photo=self.photo, + ) + b = SharedUser(self.user_id, "other_firs_name") + c = SharedUser(self.user_id + 1, self.first_name) + d = chat_shared assert a == b assert hash(a) == hash(b) diff --git a/tests/test_slots.py b/tests/test_slots.py index fdd8619bcd6..dd23c660471 100644 --- a/tests/test_slots.py +++ b/tests/test_slots.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -37,7 +37,7 @@ def test_class_has_slots_and_no_dict(): for name, cls in inspect.getmembers(module, inspect.isclass): if cls.__module__ != module.__name__ or any( # exclude 'imported' modules - x in name for x in {"__class__", "__init__", "Queue", "Webhook"} + x in name for x in ("__class__", "__init__", "Queue", "Webhook") ): continue @@ -46,7 +46,7 @@ def test_class_has_slots_and_no_dict(): assert not isinstance(cls.__slots__, str), f"{name!r}s slots shouldn't be strings" # specify if a certain module/class/base class should have dict- - if any(i in included for i in {cls.__module__, name, cls.__base__.__name__}): + if any(i in included for i in (cls.__module__, name, cls.__base__.__name__)): assert "__dict__" in get_slots(cls), f"class {name!r} ({path}) has no __dict__" continue diff --git a/tests/test_story.py b/tests/test_story.py new file mode 100644 index 00000000000..a1472a60aef --- /dev/null +++ b/tests/test_story.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 Bot, Chat, Story +from tests.auxil.bot_method_checks import ( + check_defaults_handling, + check_shortcut_call, + check_shortcut_signature, +) +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def story(bot): + story = Story(StoryTestBase.chat, StoryTestBase.id) + story.set_bot(bot) + return story + + +class StoryTestBase: + chat = Chat(1, "") + id = 0 + + +class TestStoryWithoutRequest(StoryTestBase): + def test_slot_behaviour(self, story): + for attr in story.__slots__: + assert getattr(story, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(story)) == len(set(mro_slots(story))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = {"chat": self.chat.to_dict(), "id": self.id} + story = Story.de_json(json_dict, offline_bot) + assert story.api_kwargs == {} + assert story.chat == self.chat + assert story.id == self.id + assert isinstance(story, Story) + + def test_to_dict(self, story): + story_dict = story.to_dict() + assert story_dict["chat"] == self.chat.to_dict() + assert story_dict["id"] == self.id + + def test_equality(self): + a = Story(Chat(1, ""), 0) + b = Story(Chat(1, ""), 0) + c = Story(Chat(1, ""), 1) + d = Story(Chat(2, ""), 0) + e = Chat(1, "") + + 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) + + async def test_instance_method_repost(self, monkeypatch, story): + async def make_assertion(*_, **kwargs): + chat_id = kwargs["from_chat_id"] == story.chat.id + story_id = kwargs["from_story_id"] == story.id + return chat_id and story_id + + assert check_shortcut_signature( + Story.repost, + Bot.repost_story, + [ + "from_chat_id", + "from_story_id", + ], + additional_kwargs=[], + ) + assert await check_shortcut_call( + story.repost, + story.get_bot(), + "repost_story", + shortcut_kwargs=["from_chat_id", "from_story_id"], + ) + assert await check_defaults_handling(story.repost, story.get_bot()) + + monkeypatch.setattr(story.get_bot(), "repost_story", make_assertion) + assert await story.repost( + business_connection_id="bcid", + active_period=3600, + ) diff --git a/tests/test_storyarea.py b/tests/test_storyarea.py new file mode 100644 index 00000000000..fd78c14c318 --- /dev/null +++ b/tests/test_storyarea.py @@ -0,0 +1,508 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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._dice import Dice +from telegram._reaction import ReactionTypeEmoji +from telegram._storyarea import ( + LocationAddress, + StoryArea, + StoryAreaPosition, + StoryAreaType, + StoryAreaTypeLink, + StoryAreaTypeLocation, + StoryAreaTypeSuggestedReaction, + StoryAreaTypeUniqueGift, + StoryAreaTypeWeather, +) +from telegram.constants import StoryAreaTypeType +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def story_area_position(): + return StoryAreaPosition( + x_percentage=StoryAreaPositionTestBase.x_percentage, + y_percentage=StoryAreaPositionTestBase.y_percentage, + width_percentage=StoryAreaPositionTestBase.width_percentage, + height_percentage=StoryAreaPositionTestBase.height_percentage, + rotation_angle=StoryAreaPositionTestBase.rotation_angle, + corner_radius_percentage=StoryAreaPositionTestBase.corner_radius_percentage, + ) + + +class StoryAreaPositionTestBase: + x_percentage = 50.0 + y_percentage = 10.0 + width_percentage = 15 + height_percentage = 15 + rotation_angle = 0.0 + corner_radius_percentage = 8.0 + + +class TestStoryAreaPositionWithoutRequest(StoryAreaPositionTestBase): + def test_slot_behaviour(self, story_area_position): + inst = story_area_position + 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, story_area_position): + assert story_area_position.x_percentage == self.x_percentage + assert story_area_position.y_percentage == self.y_percentage + assert story_area_position.width_percentage == self.width_percentage + assert story_area_position.height_percentage == self.height_percentage + assert story_area_position.rotation_angle == self.rotation_angle + assert story_area_position.corner_radius_percentage == self.corner_radius_percentage + + def test_to_dict(self, story_area_position): + json_dict = story_area_position.to_dict() + assert json_dict["x_percentage"] == self.x_percentage + assert json_dict["y_percentage"] == self.y_percentage + assert json_dict["width_percentage"] == self.width_percentage + assert json_dict["height_percentage"] == self.height_percentage + assert json_dict["rotation_angle"] == self.rotation_angle + assert json_dict["corner_radius_percentage"] == self.corner_radius_percentage + + def test_equality(self, story_area_position): + a = story_area_position + b = StoryAreaPosition( + self.x_percentage, + self.y_percentage, + self.width_percentage, + self.height_percentage, + self.rotation_angle, + self.corner_radius_percentage, + ) + c = StoryAreaPosition( + self.x_percentage + 10.0, + self.y_percentage, + self.width_percentage, + self.height_percentage, + self.rotation_angle, + self.corner_radius_percentage, + ) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def location_address(): + return LocationAddress( + country_code=LocationAddressTestBase.country_code, + state=LocationAddressTestBase.state, + city=LocationAddressTestBase.city, + street=LocationAddressTestBase.street, + ) + + +class LocationAddressTestBase: + country_code = "CC" + state = "State" + city = "City" + street = "12 downtown" + + +class TestLocationAddressWithoutRequest(LocationAddressTestBase): + def test_slot_behaviour(self, location_address): + inst = location_address + 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, location_address): + assert location_address.country_code == self.country_code + assert location_address.state == self.state + assert location_address.city == self.city + assert location_address.street == self.street + + def test_to_dict(self, location_address): + json_dict = location_address.to_dict() + assert json_dict["country_code"] == self.country_code + assert json_dict["state"] == self.state + assert json_dict["city"] == self.city + assert json_dict["street"] == self.street + + def test_equality(self, location_address): + a = location_address + b = LocationAddress(self.country_code, self.state, self.city, self.street) + c = LocationAddress("some_other_code", self.state, self.city, self.street) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def story_area(): + return StoryArea( + position=StoryAreaTestBase.position, + type=StoryAreaTestBase.type, + ) + + +class StoryAreaTestBase: + position = StoryAreaPosition( + x_percentage=50.0, + y_percentage=10.0, + width_percentage=15, + height_percentage=15, + rotation_angle=0.0, + corner_radius_percentage=8.0, + ) + type = StoryAreaTypeLink(url="some_url") + + +class TestStoryAreaWithoutRequest(StoryAreaTestBase): + def test_slot_behaviour(self, story_area): + inst = story_area + 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, story_area): + assert story_area.position == self.position + assert story_area.type == self.type + + def test_to_dict(self, story_area): + json_dict = story_area.to_dict() + assert json_dict["position"] == self.position.to_dict() + assert json_dict["type"] == self.type.to_dict() + + def test_equality(self, story_area): + a = story_area + b = StoryArea(self.position, self.type) + c = StoryArea(self.position, StoryAreaTypeLink(url="some_other_url")) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def story_area_type(): + return StoryAreaType(type=StoryAreaTypeTestBase.type) + + +class StoryAreaTypeTestBase: + type = StoryAreaTypeType.LOCATION + latitude = 100.5 + longitude = 200.5 + address = LocationAddress( + country_code="cc", + state="State", + city="City", + street="12 downtown", + ) + reaction_type = ReactionTypeEmoji(emoji="emoji") + is_dark = False + is_flipped = False + url = "http_url" + temperature = 35.0 + emoji = "emoji" + background_color = 0xFF66CCFF + name = "unique_gift_name" + + +class TestStoryAreaTypeWithoutRequest(StoryAreaTypeTestBase): + def test_slot_behaviour(self, story_area_type): + inst = story_area_type + 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, story_area_type): + assert story_area_type.type == self.type + + def test_type_enum_conversion(self, story_area_type): + assert type(StoryAreaType("location").type) is StoryAreaTypeType + assert StoryAreaType("unknown").type == "unknown" + + def test_to_dict(self, story_area_type): + assert story_area_type.to_dict() == {"type": self.type} + + def test_equality(self, story_area_type): + a = story_area_type + b = StoryAreaType(self.type) + c = StoryAreaType("unknown") + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def story_area_type_location(): + return StoryAreaTypeLocation( + latitude=TestStoryAreaTypeLocationWithoutRequest.latitude, + longitude=TestStoryAreaTypeLocationWithoutRequest.longitude, + address=TestStoryAreaTypeLocationWithoutRequest.address, + ) + + +class TestStoryAreaTypeLocationWithoutRequest(StoryAreaTypeTestBase): + type = StoryAreaTypeType.LOCATION + + def test_slot_behaviour(self, story_area_type_location): + inst = story_area_type_location + 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, story_area_type_location): + assert story_area_type_location.type == self.type + assert story_area_type_location.latitude == self.latitude + assert story_area_type_location.longitude == self.longitude + assert story_area_type_location.address == self.address + + def test_to_dict(self, story_area_type_location): + json_dict = story_area_type_location.to_dict() + assert isinstance(json_dict, dict) + assert json_dict["type"] == self.type + assert json_dict["latitude"] == self.latitude + assert json_dict["longitude"] == self.longitude + assert json_dict["address"] == self.address.to_dict() + + def test_equality(self, story_area_type_location): + a = story_area_type_location + b = StoryAreaTypeLocation(self.latitude, self.longitude, self.address) + c = StoryAreaTypeLocation(self.latitude + 0.5, self.longitude, self.address) + d = Dice(5, "test") + + 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) + + +@pytest.fixture +def story_area_type_suggested_reaction(): + return StoryAreaTypeSuggestedReaction( + reaction_type=TestStoryAreaTypeSuggestedReactionWithoutRequest.reaction_type, + is_dark=TestStoryAreaTypeSuggestedReactionWithoutRequest.is_dark, + is_flipped=TestStoryAreaTypeSuggestedReactionWithoutRequest.is_flipped, + ) + + +class TestStoryAreaTypeSuggestedReactionWithoutRequest(StoryAreaTypeTestBase): + type = StoryAreaTypeType.SUGGESTED_REACTION + + def test_slot_behaviour(self, story_area_type_suggested_reaction): + inst = story_area_type_suggested_reaction + 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, story_area_type_suggested_reaction): + assert story_area_type_suggested_reaction.type == self.type + assert story_area_type_suggested_reaction.reaction_type == self.reaction_type + assert story_area_type_suggested_reaction.is_dark is self.is_dark + assert story_area_type_suggested_reaction.is_flipped is self.is_flipped + + def test_to_dict(self, story_area_type_suggested_reaction): + json_dict = story_area_type_suggested_reaction.to_dict() + assert isinstance(json_dict, dict) + assert json_dict["type"] == self.type + assert json_dict["reaction_type"] == self.reaction_type.to_dict() + assert json_dict["is_dark"] is self.is_dark + assert json_dict["is_flipped"] is self.is_flipped + + def test_equality(self, story_area_type_suggested_reaction): + a = story_area_type_suggested_reaction + b = StoryAreaTypeSuggestedReaction(self.reaction_type, self.is_dark, self.is_flipped) + c = StoryAreaTypeSuggestedReaction(self.reaction_type, not self.is_dark, self.is_flipped) + d = Dice(5, "test") + + 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) + + +@pytest.fixture +def story_area_type_link(): + return StoryAreaTypeLink( + url=TestStoryAreaTypeLinkWithoutRequest.url, + ) + + +class TestStoryAreaTypeLinkWithoutRequest(StoryAreaTypeTestBase): + type = StoryAreaTypeType.LINK + + def test_slot_behaviour(self, story_area_type_link): + inst = story_area_type_link + 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, story_area_type_link): + assert story_area_type_link.type == self.type + assert story_area_type_link.url == self.url + + def test_to_dict(self, story_area_type_link): + json_dict = story_area_type_link.to_dict() + assert isinstance(json_dict, dict) + assert json_dict["type"] == self.type + assert json_dict["url"] == self.url + + def test_equality(self, story_area_type_link): + a = story_area_type_link + b = StoryAreaTypeLink(self.url) + c = StoryAreaTypeLink("other_http_url") + d = Dice(5, "test") + + 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) + + +@pytest.fixture +def story_area_type_weather(): + return StoryAreaTypeWeather( + temperature=TestStoryAreaTypeWeatherWithoutRequest.temperature, + emoji=TestStoryAreaTypeWeatherWithoutRequest.emoji, + background_color=TestStoryAreaTypeWeatherWithoutRequest.background_color, + ) + + +class TestStoryAreaTypeWeatherWithoutRequest(StoryAreaTypeTestBase): + type = StoryAreaTypeType.WEATHER + + def test_slot_behaviour(self, story_area_type_weather): + inst = story_area_type_weather + 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, story_area_type_weather): + assert story_area_type_weather.type == self.type + assert story_area_type_weather.temperature == self.temperature + assert story_area_type_weather.emoji == self.emoji + assert story_area_type_weather.background_color == self.background_color + + def test_to_dict(self, story_area_type_weather): + json_dict = story_area_type_weather.to_dict() + assert isinstance(json_dict, dict) + assert json_dict["type"] == self.type + assert json_dict["temperature"] == self.temperature + assert json_dict["emoji"] == self.emoji + assert json_dict["background_color"] == self.background_color + + def test_equality(self, story_area_type_weather): + a = story_area_type_weather + b = StoryAreaTypeWeather(self.temperature, self.emoji, self.background_color) + c = StoryAreaTypeWeather(self.temperature - 5.0, self.emoji, self.background_color) + d = Dice(5, "test") + + 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) + + +@pytest.fixture +def story_area_type_unique_gift(): + return StoryAreaTypeUniqueGift( + name=TestStoryAreaTypeUniqueGiftWithoutRequest.name, + ) + + +class TestStoryAreaTypeUniqueGiftWithoutRequest(StoryAreaTypeTestBase): + type = StoryAreaTypeType.UNIQUE_GIFT + + def test_slot_behaviour(self, story_area_type_unique_gift): + inst = story_area_type_unique_gift + 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, story_area_type_unique_gift): + assert story_area_type_unique_gift.type == self.type + assert story_area_type_unique_gift.name == self.name + + def test_to_dict(self, story_area_type_unique_gift): + json_dict = story_area_type_unique_gift.to_dict() + assert isinstance(json_dict, dict) + assert json_dict["type"] == self.type + assert json_dict["name"] == self.name + + def test_equality(self, story_area_type_unique_gift): + a = story_area_type_unique_gift + b = StoryAreaTypeUniqueGift(self.name) + c = StoryAreaTypeUniqueGift("other_name") + d = Dice(5, "test") + + 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/test_suggestedpost.py b/tests/test_suggestedpost.py new file mode 100644 index 00000000000..b3bc89568ca --- /dev/null +++ b/tests/test_suggestedpost.py @@ -0,0 +1,600 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm + +import pytest + +from telegram import Dice +from telegram._chat import Chat +from telegram._message import Message +from telegram._payment.stars.staramount import StarAmount +from telegram._suggestedpost import ( + SuggestedPostApprovalFailed, + SuggestedPostApproved, + SuggestedPostDeclined, + SuggestedPostInfo, + SuggestedPostPaid, + SuggestedPostParameters, + SuggestedPostPrice, + SuggestedPostRefunded, +) +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import SuggestedPostInfoState +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def suggested_post_parameters(): + return SuggestedPostParameters( + price=SuggestedPostParametersTestBase.price, + send_date=SuggestedPostParametersTestBase.send_date, + ) + + +class SuggestedPostParametersTestBase: + price = SuggestedPostPrice(currency="XTR", amount=100) + send_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + + +class TestSuggestedPostParametersWithoutRequest(SuggestedPostParametersTestBase): + def test_slot_behaviour(self, suggested_post_parameters): + for attr in suggested_post_parameters.__slots__: + assert getattr(suggested_post_parameters, attr, "err") != "err", ( + f"got extra slot '{attr}'" + ) + assert len(mro_slots(suggested_post_parameters)) == len( + set(mro_slots(suggested_post_parameters)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "price": self.price.to_dict(), + "send_date": to_timestamp(self.send_date), + } + spp = SuggestedPostParameters.de_json(json_dict, offline_bot) + assert spp.price == self.price + assert spp.send_date == self.send_date + assert spp.api_kwargs == {} + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): + json_dict = { + "price": self.price.to_dict(), + "send_date": to_timestamp(self.send_date), + } + + spp_bot = SuggestedPostParameters.de_json(json_dict, offline_bot) + spp_bot_raw = SuggestedPostParameters.de_json(json_dict, raw_bot) + spp_bot_tz = SuggestedPostParameters.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing tzinfo objects is not reliable + send_date_offset = spp_bot_tz.send_date.utcoffset() + send_date_offset_tz = tz_bot.defaults.tzinfo.utcoffset( + spp_bot_tz.send_date.replace(tzinfo=None) + ) + + assert spp_bot.send_date.tzinfo == UTC + assert spp_bot_raw.send_date.tzinfo == UTC + assert send_date_offset_tz == send_date_offset + + def test_to_dict(self, suggested_post_parameters): + spp_dict = suggested_post_parameters.to_dict() + + assert isinstance(spp_dict, dict) + assert spp_dict["price"] == self.price.to_dict() + assert spp_dict["send_date"] == to_timestamp(self.send_date) + + def test_equality(self, suggested_post_parameters): + a = suggested_post_parameters + b = SuggestedPostParameters(price=self.price, send_date=self.send_date) + c = SuggestedPostParameters( + price=self.price, send_date=self.send_date + dtm.timedelta(seconds=1) + ) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture(scope="module") +def suggested_post_info(): + return SuggestedPostInfo( + state=SuggestedPostInfoTestBase.state, + price=SuggestedPostInfoTestBase.price, + send_date=SuggestedPostInfoTestBase.send_date, + ) + + +class SuggestedPostInfoTestBase: + state = SuggestedPostInfoState.PENDING + price = SuggestedPostPrice(currency="XTR", amount=100) + send_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + + +class TestSuggestedPostInfoWithoutRequest(SuggestedPostInfoTestBase): + def test_slot_behaviour(self, suggested_post_info): + for attr in suggested_post_info.__slots__: + assert getattr(suggested_post_info, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(suggested_post_info)) == len(set(mro_slots(suggested_post_info))), ( + "duplicate slot" + ) + + def test_type_enum_conversion(self): + assert type(SuggestedPostInfo("pending").state) is SuggestedPostInfoState + assert SuggestedPostInfo("unknown").state == "unknown" + + def test_de_json(self, offline_bot): + json_dict = { + "state": self.state, + "price": self.price.to_dict(), + "send_date": to_timestamp(self.send_date), + } + spi = SuggestedPostInfo.de_json(json_dict, offline_bot) + assert spi.state == self.state + assert spi.price == self.price + assert spi.send_date == self.send_date + assert spi.api_kwargs == {} + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): + json_dict = { + "state": self.state, + "price": self.price.to_dict(), + "send_date": to_timestamp(self.send_date), + } + + spi_bot = SuggestedPostInfo.de_json(json_dict, offline_bot) + spi_bot_raw = SuggestedPostInfo.de_json(json_dict, raw_bot) + spi_bot_tz = SuggestedPostInfo.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing tzinfo objects is not reliable + send_date_offset = spi_bot_tz.send_date.utcoffset() + send_date_offset_tz = tz_bot.defaults.tzinfo.utcoffset( + spi_bot_tz.send_date.replace(tzinfo=None) + ) + + assert spi_bot.send_date.tzinfo == UTC + assert spi_bot_raw.send_date.tzinfo == UTC + assert send_date_offset_tz == send_date_offset + + def test_to_dict(self, suggested_post_info): + spi_dict = suggested_post_info.to_dict() + + assert isinstance(spi_dict, dict) + assert spi_dict["state"] == self.state + assert spi_dict["price"] == self.price.to_dict() + assert spi_dict["send_date"] == to_timestamp(self.send_date) + + def test_equality(self, suggested_post_info): + a = suggested_post_info + b = SuggestedPostInfo(state=self.state, price=self.price) + c = SuggestedPostInfo(state=SuggestedPostInfoState.DECLINED, price=self.price) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture(scope="module") +def suggested_post_price(): + return SuggestedPostPrice( + currency=SuggestedPostPriceTestBase.currency, + amount=SuggestedPostPriceTestBase.amount, + ) + + +class SuggestedPostPriceTestBase: + currency = "XTR" + amount = 100 + + +class TestSuggestedPostPriceWithoutRequest(SuggestedPostPriceTestBase): + def test_slot_behaviour(self, suggested_post_price): + for attr in suggested_post_price.__slots__: + assert getattr(suggested_post_price, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(suggested_post_price)) == len(set(mro_slots(suggested_post_price))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "currency": self.currency, + "amount": self.amount, + } + spp = SuggestedPostPrice.de_json(json_dict, offline_bot) + assert spp.currency == self.currency + assert spp.amount == self.amount + assert spp.api_kwargs == {} + + def test_to_dict(self, suggested_post_price): + spp_dict = suggested_post_price.to_dict() + + assert isinstance(spp_dict, dict) + assert spp_dict["currency"] == self.currency + assert spp_dict["amount"] == self.amount + + def test_equality(self, suggested_post_price): + a = suggested_post_price + b = SuggestedPostPrice(currency=self.currency, amount=self.amount) + c = SuggestedPostPrice(currency="TON", amount=self.amount) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture(scope="module") +def suggested_post_declined(): + return SuggestedPostDeclined( + suggested_post_message=SuggestedPostDeclinedTestBase.suggested_post_message, + comment=SuggestedPostDeclinedTestBase.comment, + ) + + +class SuggestedPostDeclinedTestBase: + suggested_post_message = Message(1, dtm.datetime.now(), Chat(1, ""), text="post this pls.") + comment = "another time" + + +class TestSuggestedPostDeclinedWithoutRequest(SuggestedPostDeclinedTestBase): + def test_slot_behaviour(self, suggested_post_declined): + for attr in suggested_post_declined.__slots__: + assert getattr(suggested_post_declined, attr, "err") != "err", ( + f"got extra slot '{attr}'" + ) + assert len(mro_slots(suggested_post_declined)) == len( + set(mro_slots(suggested_post_declined)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "suggested_post_message": self.suggested_post_message.to_dict(), + "comment": self.comment, + } + spd = SuggestedPostDeclined.de_json(json_dict, offline_bot) + assert spd.suggested_post_message == self.suggested_post_message + assert spd.comment == self.comment + assert spd.api_kwargs == {} + + def test_to_dict(self, suggested_post_declined): + spd_dict = suggested_post_declined.to_dict() + + assert isinstance(spd_dict, dict) + assert spd_dict["suggested_post_message"] == self.suggested_post_message.to_dict() + assert spd_dict["comment"] == self.comment + + def test_equality(self, suggested_post_declined): + a = suggested_post_declined + b = SuggestedPostDeclined( + suggested_post_message=self.suggested_post_message, comment=self.comment + ) + c = SuggestedPostDeclined(suggested_post_message=self.suggested_post_message, comment="no") + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture(scope="module") +def suggested_post_paid(): + return SuggestedPostPaid( + currency=SuggestedPostPaidTestBase.currency, + suggested_post_message=SuggestedPostPaidTestBase.suggested_post_message, + amount=SuggestedPostPaidTestBase.amount, + star_amount=SuggestedPostPaidTestBase.star_amount, + ) + + +class SuggestedPostPaidTestBase: + suggested_post_message = Message(1, dtm.datetime.now(), Chat(1, ""), text="post this pls.") + currency = "XTR" + amount = 100 + star_amount = StarAmount(100) + + +class TestSuggestedPostPaidWithoutRequest(SuggestedPostPaidTestBase): + def test_slot_behaviour(self, suggested_post_paid): + for attr in suggested_post_paid.__slots__: + assert getattr(suggested_post_paid, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(suggested_post_paid)) == len(set(mro_slots(suggested_post_paid))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "suggested_post_message": self.suggested_post_message.to_dict(), + "currency": self.currency, + "amount": self.amount, + "star_amount": self.star_amount.to_dict(), + } + spp = SuggestedPostPaid.de_json(json_dict, offline_bot) + assert spp.suggested_post_message == self.suggested_post_message + assert spp.currency == self.currency + assert spp.amount == self.amount + assert spp.star_amount == self.star_amount + assert spp.api_kwargs == {} + + def test_to_dict(self, suggested_post_paid): + spp_dict = suggested_post_paid.to_dict() + + assert isinstance(spp_dict, dict) + assert spp_dict["suggested_post_message"] == self.suggested_post_message.to_dict() + assert spp_dict["currency"] == self.currency + assert spp_dict["amount"] == self.amount + assert spp_dict["star_amount"] == self.star_amount.to_dict() + + def test_equality(self, suggested_post_paid): + a = suggested_post_paid + b = SuggestedPostPaid( + suggested_post_message=self.suggested_post_message, + currency=self.currency, + amount=self.amount, + star_amount=self.star_amount, + ) + c = SuggestedPostPaid( + suggested_post_message=self.suggested_post_message, + currency=self.currency, + amount=self.amount - 1, + star_amount=StarAmount(self.amount - 1), + ) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture(scope="module") +def suggested_post_refunded(): + return SuggestedPostRefunded( + reason=SuggestedPostRefundedTestBase.reason, + suggested_post_message=SuggestedPostRefundedTestBase.suggested_post_message, + ) + + +class SuggestedPostRefundedTestBase: + reason = "post_deleted" + suggested_post_message = Message(1, dtm.datetime.now(), Chat(1, ""), text="post this pls.") + + +class TestSuggestedPostRefundedWithoutRequest(SuggestedPostRefundedTestBase): + def test_slot_behaviour(self, suggested_post_refunded): + for attr in suggested_post_refunded.__slots__: + assert getattr(suggested_post_refunded, attr, "err") != "err", ( + f"got extra slot '{attr}'" + ) + assert len(mro_slots(suggested_post_refunded)) == len( + set(mro_slots(suggested_post_refunded)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "suggested_post_message": self.suggested_post_message.to_dict(), + "reason": self.reason, + } + spr = SuggestedPostRefunded.de_json(json_dict, offline_bot) + assert spr.suggested_post_message == self.suggested_post_message + assert spr.reason == self.reason + assert spr.api_kwargs == {} + + def test_to_dict(self, suggested_post_refunded): + spr_dict = suggested_post_refunded.to_dict() + + assert isinstance(spr_dict, dict) + assert spr_dict["suggested_post_message"] == self.suggested_post_message.to_dict() + assert spr_dict["reason"] == self.reason + + def test_equality(self, suggested_post_refunded): + a = suggested_post_refunded + b = SuggestedPostRefunded( + suggested_post_message=self.suggested_post_message, reason=self.reason + ) + c = SuggestedPostRefunded( + suggested_post_message=self.suggested_post_message, reason="payment_refunded" + ) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture(scope="module") +def suggested_post_approved(): + return SuggestedPostApproved( + send_date=SuggestedPostApprovedTestBase.send_date, + suggested_post_message=SuggestedPostApprovedTestBase.suggested_post_message, + price=SuggestedPostApprovedTestBase.price, + ) + + +class SuggestedPostApprovedTestBase: + send_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + suggested_post_message = Message(1, dtm.datetime.now(), Chat(1, ""), text="post this pls.") + price = SuggestedPostPrice(currency="XTR", amount=100) + + +class TestSuggestedPostApprovedWithoutRequest(SuggestedPostApprovedTestBase): + def test_slot_behaviour(self, suggested_post_approved): + for attr in suggested_post_approved.__slots__: + assert getattr(suggested_post_approved, attr, "err") != "err", ( + f"got extra slot '{attr}'" + ) + assert len(mro_slots(suggested_post_approved)) == len( + set(mro_slots(suggested_post_approved)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "send_date": to_timestamp(self.send_date), + "suggested_post_message": self.suggested_post_message.to_dict(), + "price": self.price.to_dict(), + } + spa = SuggestedPostApproved.de_json(json_dict, offline_bot) + assert spa.send_date == self.send_date + assert spa.suggested_post_message == self.suggested_post_message + assert spa.price == self.price + assert spa.api_kwargs == {} + + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): + json_dict = { + "send_date": to_timestamp(self.send_date), + "suggested_post_message": self.suggested_post_message.to_dict(), + "price": self.price.to_dict(), + } + + spa_bot = SuggestedPostApproved.de_json(json_dict, offline_bot) + spa_bot_raw = SuggestedPostApproved.de_json(json_dict, raw_bot) + spi_bot_tz = SuggestedPostApproved.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing tzinfo objects is not reliable + send_date_offset = spi_bot_tz.send_date.utcoffset() + send_date_offset_tz = tz_bot.defaults.tzinfo.utcoffset( + spi_bot_tz.send_date.replace(tzinfo=None) + ) + + assert spa_bot.send_date.tzinfo == UTC + assert spa_bot_raw.send_date.tzinfo == UTC + assert send_date_offset_tz == send_date_offset + + def test_to_dict(self, suggested_post_approved): + spa_dict = suggested_post_approved.to_dict() + + assert isinstance(spa_dict, dict) + assert spa_dict["send_date"] == to_timestamp(self.send_date) + assert spa_dict["suggested_post_message"] == self.suggested_post_message.to_dict() + assert spa_dict["price"] == self.price.to_dict() + + def test_equality(self, suggested_post_approved): + a = suggested_post_approved + b = SuggestedPostApproved( + send_date=self.send_date, + suggested_post_message=self.suggested_post_message, + price=self.price, + ) + c = SuggestedPostApproved( + send_date=self.send_date, + suggested_post_message=self.suggested_post_message, + price=SuggestedPostPrice(currency="XTR", amount=self.price.amount - 1), + ) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + +@pytest.fixture(scope="module") +def suggested_post_approval_failed(): + return SuggestedPostApprovalFailed( + price=SuggestedPostApprovalFailedTestBase.price, + suggested_post_message=SuggestedPostApprovalFailedTestBase.suggested_post_message, + ) + + +class SuggestedPostApprovalFailedTestBase: + price = SuggestedPostPrice(currency="XTR", amount=100) + suggested_post_message = Message(1, dtm.datetime.now(), Chat(1, ""), text="post this pls.") + + +class TestSuggestedPostApprovalFailedWithoutRequest(SuggestedPostApprovalFailedTestBase): + def test_slot_behaviour(self, suggested_post_approval_failed): + for attr in suggested_post_approval_failed.__slots__: + assert getattr(suggested_post_approval_failed, attr, "err") != "err", ( + f"got extra slot '{attr}'" + ) + assert len(mro_slots(suggested_post_approval_failed)) == len( + set(mro_slots(suggested_post_approval_failed)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "price": self.price.to_dict(), + "suggested_post_message": self.suggested_post_message.to_dict(), + } + spaf = SuggestedPostApprovalFailed.de_json(json_dict, offline_bot) + assert spaf.price == self.price + assert spaf.suggested_post_message == self.suggested_post_message + assert spaf.api_kwargs == {} + + def test_to_dict(self, suggested_post_approval_failed): + spaf_dict = suggested_post_approval_failed.to_dict() + + assert isinstance(spaf_dict, dict) + assert spaf_dict["price"] == self.price.to_dict() + assert spaf_dict["suggested_post_message"] == self.suggested_post_message.to_dict() + + def test_equality(self, suggested_post_approval_failed): + a = suggested_post_approval_failed + b = SuggestedPostApprovalFailed( + price=self.price, + suggested_post_message=self.suggested_post_message, + ) + c = SuggestedPostApprovalFailed( + price=SuggestedPostPrice(currency="XTR", amount=self.price.amount - 1), + suggested_post_message=self.suggested_post_message, + ) + e = Dice(4, "emoji") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) diff --git a/tests/test_switchinlinequerychosenchat.py b/tests/test_switchinlinequerychosenchat.py index dc610e449dc..10c5de3e33b 100644 --- a/tests/test_switchinlinequerychosenchat.py +++ b/tests/test_switchinlinequerychosenchat.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -26,15 +26,15 @@ @pytest.fixture(scope="module") def switch_inline_query_chosen_chat(): return SwitchInlineQueryChosenChat( - query=TestSwitchInlineQueryChosenChatBase.query, - allow_user_chats=TestSwitchInlineQueryChosenChatBase.allow_user_chats, - allow_bot_chats=TestSwitchInlineQueryChosenChatBase.allow_bot_chats, - allow_channel_chats=TestSwitchInlineQueryChosenChatBase.allow_channel_chats, - allow_group_chats=TestSwitchInlineQueryChosenChatBase.allow_group_chats, + query=SwitchInlineQueryChosenChatTestBase.query, + allow_user_chats=SwitchInlineQueryChosenChatTestBase.allow_user_chats, + allow_bot_chats=SwitchInlineQueryChosenChatTestBase.allow_bot_chats, + allow_channel_chats=SwitchInlineQueryChosenChatTestBase.allow_channel_chats, + allow_group_chats=SwitchInlineQueryChosenChatTestBase.allow_group_chats, ) -class TestSwitchInlineQueryChosenChatBase: +class SwitchInlineQueryChosenChatTestBase: query = "query" allow_user_chats = True allow_bot_chats = True @@ -42,7 +42,7 @@ class TestSwitchInlineQueryChosenChatBase: allow_group_chats = True -class TestSwitchInlineQueryChosenChat(TestSwitchInlineQueryChosenChatBase): +class TestSwitchInlineQueryChosenChat(SwitchInlineQueryChosenChatTestBase): def test_slot_behaviour(self, switch_inline_query_chosen_chat): inst = switch_inline_query_chosen_chat for attr in inst.__slots__: diff --git a/tests/test_telegramobject.py b/tests/test_telegramobject.py index 756f69bcfcf..953bf392cec 100644 --- a/tests/test_telegramobject.py +++ b/tests/test_telegramobject.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,7 +16,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/]. -import datetime +import datetime as dtm import inspect import pickle import re @@ -27,6 +27,7 @@ import pytest from telegram import Bot, BotCommand, Chat, Message, PhotoSize, TelegramObject, User +from telegram._utils.defaultvalue import DEFAULT_FALSE, DEFAULT_NONE, DefaultValue from telegram.ext import PicklePersistence from telegram.warnings import PTBUserWarning from tests.auxil.files import data_file @@ -89,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): @@ -97,7 +103,7 @@ def __init__(self, arg: int, **kwargs): self._id_attrs = (self.arg,) - assert SubClass.de_list([{"arg": 1}, None, {"arg": 2}, None], bot) == ( + assert SubClass.de_list([{"arg": 1}, {"arg": 2}], bot) == ( SubClass(1), SubClass(2), ) @@ -140,9 +146,9 @@ def test_subclasses_have_api_kwargs(self, cls): if cls is TelegramObject: # TelegramObject doesn't have a super class return - assert "api_kwargs=api_kwargs" in inspect.getsource( - cls.__init__ - ), f"{cls.__name__} doesn't seem to pass `api_kwargs` to `super().__init__`" + assert "api_kwargs=api_kwargs" in inspect.getsource(cls.__init__), ( + f"{cls.__name__} doesn't seem to pass `api_kwargs` to `super().__init__`" + ) def test_de_json_arbitrary_exceptions(self, bot): class SubClass(TelegramObject): @@ -155,7 +161,7 @@ def __init__(self, **kwargs): def test_to_dict_private_attribute(self): class TelegramObjectSubclass(TelegramObject): - __slots__ = ("a", "_b") # Added slots so that the attrs are converted to dict + __slots__ = ("_b", "a") # Added slots so that the attrs are converted to dict def __init__(self): super().__init__() @@ -170,9 +176,7 @@ def test_to_dict_api_kwargs(self): assert to.to_dict() == {"foo": "bar"} def test_to_dict_missing_attribute(self): - message = Message( - 1, datetime.datetime.now(), Chat(1, "private"), from_user=User(1, "", False) - ) + message = Message(1, dtm.datetime.now(), Chat(1, "private"), from_user=User(1, "", False)) message._unfreeze() del message.chat @@ -206,6 +210,18 @@ def __init__(self): assert isinstance(to_dict_recurse["subclass"], dict) assert to_dict_recurse["subclass"]["recursive"] == "recursive" + def test_to_dict_default_value(self): + class SubClass(TelegramObject): + def __init__(self): + super().__init__() + self.default_none = DEFAULT_NONE + self.default_false = DEFAULT_FALSE + + to = SubClass() + to_dict = to.to_dict() + assert "default_none" not in to_dict + assert to_dict["default_false"] is False + def test_slot_behaviour(self): inst = TelegramObject() for attr in inst.__slots__: @@ -270,7 +286,7 @@ def test_subscription(self): def test_pickle(self, bot): chat = Chat(2, Chat.PRIVATE) user = User(3, "first_name", False) - date = datetime.datetime.now() + date = dtm.datetime.now() photo = PhotoSize("file_id", "unique", 21, 21) photo.set_bot(bot) msg = Message( @@ -280,6 +296,7 @@ def test_pickle(self, bot): from_user=user, text="foobar", photo=[photo], + animation=DEFAULT_NONE, api_kwargs={"api": "kwargs"}, ) msg.set_bot(bot) @@ -295,6 +312,8 @@ def test_pickle(self, bot): assert unpickled.from_user == user assert unpickled.date == date, f"{unpickled.date} != {date}" assert unpickled.photo[0] == photo + assert isinstance(unpickled.animation, DefaultValue) + assert unpickled.animation.value is None assert isinstance(unpickled.api_kwargs, MappingProxyType) assert unpickled.api_kwargs == {"api": "kwargs"} @@ -326,10 +345,14 @@ async def test_pickle_backwards_compatibility(self): chat = (await pp.get_chat_data())[1] assert chat.id == 1 assert chat.type == Chat.PRIVATE - assert chat.api_kwargs == { + api_kwargs_expected = { "all_members_are_administrators": True, "something": "Manually inserted", } + # There are older attrs in Chat's api_kwargs which are present but we don't care about them + for k, v in api_kwargs_expected.items(): + assert chat.api_kwargs[k] == v + with pytest.raises(AttributeError): # removed attribute should not be available as attribute, only though api_kwargs chat.all_members_are_administrators @@ -342,10 +365,72 @@ async def test_pickle_backwards_compatibility(self): chat.id = 7 assert chat.id == 7 + def test_pickle_handle_properties(self): + # Very hard to properly test, can't use a pickle file since newer versions of the library + # will stop having the property. + # The code below uses exec statements to simulate library changes. There is no other way + # to test this. + # Original class: + v1 = """ +class PicklePropertyTest(TelegramObject): + __slots__ = ("forward_from", "to_be_removed", "forward_date") + def __init__(self, forward_from=None, forward_date=None, api_kwargs=None): + super().__init__(api_kwargs=api_kwargs) + self.forward_from = forward_from + self.forward_date = forward_date + self.to_be_removed = "to_be_removed" +""" + exec(v1, globals(), None) + old = PicklePropertyTest("old_val", "date", api_kwargs={"new_attr": 1}) # noqa: F821 + pickled_v1 = pickle.dumps(old) + + # After some API changes: + v2 = """ +class PicklePropertyTest(TelegramObject): + __slots__ = ("_forward_from", "_date", "_new_attr") + def __init__(self, forward_from=None, f_date=None, new_attr=None, api_kwargs=None): + super().__init__(api_kwargs=api_kwargs) + self._forward_from = forward_from + self.f_date = f_date + self._new_attr = new_attr + @property + def forward_from(self): + return self._forward_from + @property + def forward_date(self): + return self.f_date + @property + def new_attr(self): + return self._new_attr + """ + exec(v2, globals(), None) + v2_unpickle = pickle.loads(pickled_v1) + assert v2_unpickle.forward_from == "old_val" == v2_unpickle._forward_from + with pytest.raises(AttributeError): + # New attribute should not be available either as is always the case for pickle + v2_unpickle.forward_date + assert v2_unpickle.new_attr == 1 == v2_unpickle._new_attr + assert not hasattr(v2_unpickle, "to_be_removed") + assert v2_unpickle.api_kwargs == {"to_be_removed": "to_be_removed"} + pickled_v2 = pickle.dumps(v2_unpickle) + + # After PTB removes the property and the attribute: + v3 = """ +class PicklePropertyTest(TelegramObject): + __slots__ = () + def __init__(self, api_kwargs=None): + super().__init__(api_kwargs=api_kwargs) +""" + exec(v3, globals(), None) + v3_unpickle = pickle.loads(pickled_v2) + assert v3_unpickle.api_kwargs == {"to_be_removed": "to_be_removed"} + assert not hasattr(v3_unpickle, "_forward_from") + assert not hasattr(v3_unpickle, "_new_attr") + def test_deepcopy_telegram_obj(self, bot): chat = Chat(2, Chat.PRIVATE) user = User(3, "first_name", False) - date = datetime.datetime.now() + date = dtm.datetime.now() photo = PhotoSize("file_id", "unique", 21, 21) photo.set_bot(bot) msg = Message( @@ -460,7 +545,7 @@ def test_subclasses_are_frozen(self, cls): "and can therefore not be frozen correctly" ) - source_lines, first_line = inspect.getsourcelines(cls.__init__) + source_lines, _ = inspect.getsourcelines(cls.__init__) # We use regex matching since a simple "if self._freeze() in source_lines[-1]" would also # allo commented lines. diff --git a/tests/test_uniquegift.py b/tests/test_uniquegift.py new file mode 100644 index 00000000000..a61a4da2183 --- /dev/null +++ b/tests/test_uniquegift.py @@ -0,0 +1,668 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 as dtm + +import pytest + +from telegram import ( + BotCommand, + Chat, + Sticker, + UniqueGift, + UniqueGiftBackdrop, + UniqueGiftBackdropColors, + UniqueGiftColors, + UniqueGiftInfo, + UniqueGiftModel, + UniqueGiftSymbol, +) +from telegram._utils.datetime import UTC, to_timestamp +from telegram.constants import UniqueGiftInfoOrigin +from telegram.warnings import PTBDeprecationWarning +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def unique_gift_colors(): + return UniqueGiftColors( + model_custom_emoji_id=UniqueGiftColorsTestBase.model_custom_emoji_id, + symbol_custom_emoji_id=UniqueGiftColorsTestBase.symbol_custom_emoji_id, + light_theme_main_color=UniqueGiftColorsTestBase.light_theme_main_color, + light_theme_other_colors=UniqueGiftColorsTestBase.light_theme_other_colors, + dark_theme_main_color=UniqueGiftColorsTestBase.dark_theme_main_color, + dark_theme_other_colors=UniqueGiftColorsTestBase.dark_theme_other_colors, + ) + + +class UniqueGiftColorsTestBase: + model_custom_emoji_id = "model_emoji_id" + symbol_custom_emoji_id = "symbol_emoji_id" + light_theme_main_color = 0xFFFFFF + light_theme_other_colors = [0xAAAAAA, 0xBBBBBB] + dark_theme_main_color = 0x000000 + dark_theme_other_colors = [0x111111, 0x222222] + + +class TestUniqueGiftColorsWithoutRequest(UniqueGiftColorsTestBase): + def test_slot_behaviour(self, unique_gift_colors): + for attr in unique_gift_colors.__slots__: + assert getattr(unique_gift_colors, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift_colors)) == len(set(mro_slots(unique_gift_colors))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "model_custom_emoji_id": self.model_custom_emoji_id, + "symbol_custom_emoji_id": self.symbol_custom_emoji_id, + "light_theme_main_color": self.light_theme_main_color, + "light_theme_other_colors": self.light_theme_other_colors, + "dark_theme_main_color": self.dark_theme_main_color, + "dark_theme_other_colors": self.dark_theme_other_colors, + } + unique_gift_colors = UniqueGiftColors.de_json(json_dict, offline_bot) + assert unique_gift_colors.api_kwargs == {} + assert unique_gift_colors.model_custom_emoji_id == self.model_custom_emoji_id + assert unique_gift_colors.symbol_custom_emoji_id == self.symbol_custom_emoji_id + assert unique_gift_colors.light_theme_main_color == self.light_theme_main_color + assert unique_gift_colors.light_theme_other_colors == tuple(self.light_theme_other_colors) + assert unique_gift_colors.dark_theme_main_color == self.dark_theme_main_color + assert unique_gift_colors.dark_theme_other_colors == tuple(self.dark_theme_other_colors) + + def test_to_dict(self, unique_gift_colors): + json_dict = unique_gift_colors.to_dict() + assert json_dict["model_custom_emoji_id"] == self.model_custom_emoji_id + assert json_dict["symbol_custom_emoji_id"] == self.symbol_custom_emoji_id + assert json_dict["light_theme_main_color"] == self.light_theme_main_color + assert json_dict["light_theme_other_colors"] == self.light_theme_other_colors + assert json_dict["dark_theme_main_color"] == self.dark_theme_main_color + assert json_dict["dark_theme_other_colors"] == self.dark_theme_other_colors + + def test_equality(self, unique_gift_colors): + a = unique_gift_colors + b = UniqueGiftColors( + self.model_custom_emoji_id, + self.symbol_custom_emoji_id, + self.light_theme_main_color, + self.light_theme_other_colors, + self.dark_theme_main_color, + self.dark_theme_other_colors, + ) + c = UniqueGiftColors( + "other_model_emoji_id", + self.symbol_custom_emoji_id, + self.light_theme_main_color, + self.light_theme_other_colors, + self.dark_theme_main_color, + self.dark_theme_other_colors, + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def unique_gift(): + return UniqueGift( + gift_id=UniqueGiftTestBase.gift_id, + base_name=UniqueGiftTestBase.base_name, + name=UniqueGiftTestBase.name, + number=UniqueGiftTestBase.number, + model=UniqueGiftTestBase.model, + symbol=UniqueGiftTestBase.symbol, + backdrop=UniqueGiftTestBase.backdrop, + publisher_chat=UniqueGiftTestBase.publisher_chat, + is_premium=UniqueGiftTestBase.is_premium, + is_from_blockchain=UniqueGiftTestBase.is_from_blockchain, + colors=UniqueGiftTestBase.colors, + ) + + +class UniqueGiftTestBase: + gift_id = "gift_id" + base_name = "human_readable" + name = "unique_name" + number = 10 + model = UniqueGiftModel( + name="model_name", + sticker=Sticker("file_id1", "file_unique_id1", 512, 512, False, False, "regular"), + rarity_per_mille=10, + ) + symbol = UniqueGiftSymbol( + name="symbol_name", + sticker=Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + rarity_per_mille=20, + ) + backdrop = UniqueGiftBackdrop( + name="backdrop_name", + colors=UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F), + rarity_per_mille=30, + ) + publisher_chat = Chat(1, Chat.PRIVATE) + is_premium = False + is_from_blockchain = True + colors = UniqueGiftColors( + model_custom_emoji_id="M", + symbol_custom_emoji_id="S", + light_theme_main_color=0xFFFFFF, + light_theme_other_colors=[0xAAAAAA], + dark_theme_main_color=0x000000, + dark_theme_other_colors=[0x111111], + ) + + +class TestUniqueGiftWithoutRequest(UniqueGiftTestBase): + def test_slot_behaviour(self, unique_gift): + for attr in unique_gift.__slots__: + assert getattr(unique_gift, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift)) == len(set(mro_slots(unique_gift))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "gift_id": self.gift_id, + "base_name": self.base_name, + "name": self.name, + "number": self.number, + "model": self.model.to_dict(), + "symbol": self.symbol.to_dict(), + "backdrop": self.backdrop.to_dict(), + "publisher_chat": self.publisher_chat.to_dict(), + "is_premium": self.is_premium, + "is_from_blockchain": self.is_from_blockchain, + "colors": self.colors.to_dict(), + } + unique_gift = UniqueGift.de_json(json_dict, offline_bot) + assert unique_gift.api_kwargs == {} + + assert unique_gift.base_name == self.base_name + assert unique_gift.name == self.name + assert unique_gift.number == self.number + assert unique_gift.model == self.model + assert unique_gift.symbol == self.symbol + assert unique_gift.backdrop == self.backdrop + assert unique_gift.publisher_chat == self.publisher_chat + assert unique_gift.is_premium == self.is_premium + assert unique_gift.is_from_blockchain == self.is_from_blockchain + assert unique_gift.colors == self.colors + + def test_to_dict(self, unique_gift): + gift_dict = unique_gift.to_dict() + + assert isinstance(gift_dict, dict) + assert gift_dict["gift_id"] == self.gift_id + assert gift_dict["base_name"] == self.base_name + assert gift_dict["name"] == self.name + assert gift_dict["number"] == self.number + assert gift_dict["model"] == self.model.to_dict() + assert gift_dict["symbol"] == self.symbol.to_dict() + assert gift_dict["backdrop"] == self.backdrop.to_dict() + assert gift_dict["publisher_chat"] == self.publisher_chat.to_dict() + assert gift_dict["is_premium"] == self.is_premium + assert gift_dict["is_from_blockchain"] == self.is_from_blockchain + assert gift_dict["colors"] == self.colors.to_dict() + + def test_equality(self, unique_gift): + a = unique_gift + b = UniqueGift( + gift_id=self.gift_id, + base_name=self.base_name, + name=self.name, + number=self.number, + model=self.model, + symbol=self.symbol, + backdrop=self.backdrop, + publisher_chat=self.publisher_chat, + ) + c = UniqueGift( + gift_id=self.gift_id, + base_name="other_base_name", + name=self.name, + number=self.number, + model=self.model, + symbol=self.symbol, + backdrop=self.backdrop, + publisher_chat=self.publisher_chat, + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + def test_gift_id_required_workaround(self): + # tags: deprecated 22.6, bot api 9.3 + with pytest.raises(TypeError, match="`gift_id` is a required"): + UniqueGift( + base_name=self.base_name, + name=self.name, + number=self.number, + model=self.model, + symbol=self.symbol, + backdrop=self.backdrop, + publisher_chat=self.publisher_chat, + ) + + +@pytest.fixture +def unique_gift_model(): + return UniqueGiftModel( + name=UniqueGiftModelTestBase.name, + sticker=UniqueGiftModelTestBase.sticker, + rarity_per_mille=UniqueGiftModelTestBase.rarity_per_mille, + ) + + +class UniqueGiftModelTestBase: + name = "model_name" + sticker = Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular") + rarity_per_mille = 10 + + +class TestUniqueGiftModelWithoutRequest(UniqueGiftModelTestBase): + def test_slot_behaviour(self, unique_gift_model): + for attr in unique_gift_model.__slots__: + assert getattr(unique_gift_model, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift_model)) == len(set(mro_slots(unique_gift_model))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "name": self.name, + "sticker": self.sticker.to_dict(), + "rarity_per_mille": self.rarity_per_mille, + } + unique_gift_model = UniqueGiftModel.de_json(json_dict, offline_bot) + assert unique_gift_model.api_kwargs == {} + assert unique_gift_model.name == self.name + assert unique_gift_model.sticker == self.sticker + assert unique_gift_model.rarity_per_mille == self.rarity_per_mille + + def test_to_dict(self, unique_gift_model): + json_dict = unique_gift_model.to_dict() + assert json_dict["name"] == self.name + assert json_dict["sticker"] == self.sticker.to_dict() + assert json_dict["rarity_per_mille"] == self.rarity_per_mille + + def test_equality(self, unique_gift_model): + a = unique_gift_model + b = UniqueGiftModel(self.name, self.sticker, self.rarity_per_mille) + c = UniqueGiftModel("other_name", self.sticker, self.rarity_per_mille) + d = UniqueGiftSymbol(self.name, self.sticker, self.rarity_per_mille) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def unique_gift_symbol(): + return UniqueGiftSymbol( + name=UniqueGiftSymbolTestBase.name, + sticker=UniqueGiftSymbolTestBase.sticker, + rarity_per_mille=UniqueGiftSymbolTestBase.rarity_per_mille, + ) + + +class UniqueGiftSymbolTestBase: + name = "symbol_name" + sticker = Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular") + rarity_per_mille = 20 + + +class TestUniqueGiftSymbolWithoutRequest(UniqueGiftSymbolTestBase): + def test_slot_behaviour(self, unique_gift_symbol): + for attr in unique_gift_symbol.__slots__: + assert getattr(unique_gift_symbol, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift_symbol)) == len(set(mro_slots(unique_gift_symbol))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "name": self.name, + "sticker": self.sticker.to_dict(), + "rarity_per_mille": self.rarity_per_mille, + } + unique_gift_symbol = UniqueGiftSymbol.de_json(json_dict, offline_bot) + assert unique_gift_symbol.api_kwargs == {} + assert unique_gift_symbol.name == self.name + assert unique_gift_symbol.sticker == self.sticker + assert unique_gift_symbol.rarity_per_mille == self.rarity_per_mille + + def test_to_dict(self, unique_gift_symbol): + json_dict = unique_gift_symbol.to_dict() + assert json_dict["name"] == self.name + assert json_dict["sticker"] == self.sticker.to_dict() + assert json_dict["rarity_per_mille"] == self.rarity_per_mille + + def test_equality(self, unique_gift_symbol): + a = unique_gift_symbol + b = UniqueGiftSymbol(self.name, self.sticker, self.rarity_per_mille) + c = UniqueGiftSymbol("other_name", self.sticker, self.rarity_per_mille) + d = UniqueGiftModel(self.name, self.sticker, self.rarity_per_mille) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def unique_gift_backdrop(): + return UniqueGiftBackdrop( + name=UniqueGiftBackdropTestBase.name, + colors=UniqueGiftBackdropTestBase.colors, + rarity_per_mille=UniqueGiftBackdropTestBase.rarity_per_mille, + ) + + +class UniqueGiftBackdropTestBase: + name = "backdrop_name" + colors = UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F) + rarity_per_mille = 30 + + +class TestUniqueGiftBackdropWithoutRequest(UniqueGiftBackdropTestBase): + def test_slot_behaviour(self, unique_gift_backdrop): + for attr in unique_gift_backdrop.__slots__: + assert getattr(unique_gift_backdrop, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift_backdrop)) == len(set(mro_slots(unique_gift_backdrop))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "name": self.name, + "colors": self.colors.to_dict(), + "rarity_per_mille": self.rarity_per_mille, + } + unique_gift_backdrop = UniqueGiftBackdrop.de_json(json_dict, offline_bot) + assert unique_gift_backdrop.api_kwargs == {} + assert unique_gift_backdrop.name == self.name + assert unique_gift_backdrop.colors == self.colors + assert unique_gift_backdrop.rarity_per_mille == self.rarity_per_mille + + def test_to_dict(self, unique_gift_backdrop): + json_dict = unique_gift_backdrop.to_dict() + assert json_dict["name"] == self.name + assert json_dict["colors"] == self.colors.to_dict() + assert json_dict["rarity_per_mille"] == self.rarity_per_mille + + def test_equality(self, unique_gift_backdrop): + a = unique_gift_backdrop + b = UniqueGiftBackdrop(self.name, self.colors, self.rarity_per_mille) + c = UniqueGiftBackdrop("other_name", self.colors, self.rarity_per_mille) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def unique_gift_backdrop_colors(): + return UniqueGiftBackdropColors( + center_color=UniqueGiftBackdropColorsTestBase.center_color, + edge_color=UniqueGiftBackdropColorsTestBase.edge_color, + symbol_color=UniqueGiftBackdropColorsTestBase.symbol_color, + text_color=UniqueGiftBackdropColorsTestBase.text_color, + ) + + +class UniqueGiftBackdropColorsTestBase: + center_color = 0x00FF00 + edge_color = 0xEE00FF + symbol_color = 0xAA22BB + text_color = 0x20FE8F + + +class TestUniqueGiftBackdropColorsWithoutRequest(UniqueGiftBackdropColorsTestBase): + def test_slot_behaviour(self, unique_gift_backdrop_colors): + for attr in unique_gift_backdrop_colors.__slots__: + assert getattr(unique_gift_backdrop_colors, attr, "err") != "err", ( + f"got extra slot '{attr}'" + ) + assert len(mro_slots(unique_gift_backdrop_colors)) == len( + set(mro_slots(unique_gift_backdrop_colors)) + ), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "center_color": self.center_color, + "edge_color": self.edge_color, + "symbol_color": self.symbol_color, + "text_color": self.text_color, + } + unique_gift_backdrop_colors = UniqueGiftBackdropColors.de_json(json_dict, offline_bot) + assert unique_gift_backdrop_colors.api_kwargs == {} + assert unique_gift_backdrop_colors.center_color == self.center_color + assert unique_gift_backdrop_colors.edge_color == self.edge_color + assert unique_gift_backdrop_colors.symbol_color == self.symbol_color + assert unique_gift_backdrop_colors.text_color == self.text_color + + def test_to_dict(self, unique_gift_backdrop_colors): + json_dict = unique_gift_backdrop_colors.to_dict() + assert json_dict["center_color"] == self.center_color + assert json_dict["edge_color"] == self.edge_color + assert json_dict["symbol_color"] == self.symbol_color + assert json_dict["text_color"] == self.text_color + + def test_equality(self, unique_gift_backdrop_colors): + a = unique_gift_backdrop_colors + b = UniqueGiftBackdropColors( + center_color=self.center_color, + edge_color=self.edge_color, + symbol_color=self.symbol_color, + text_color=self.text_color, + ) + c = UniqueGiftBackdropColors( + center_color=0x000000, + edge_color=self.edge_color, + symbol_color=self.symbol_color, + text_color=self.text_color, + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture +def unique_gift_info(): + return UniqueGiftInfo( + gift=UniqueGiftInfoTestBase.gift, + origin=UniqueGiftInfoTestBase.origin, + owned_gift_id=UniqueGiftInfoTestBase.owned_gift_id, + transfer_star_count=UniqueGiftInfoTestBase.transfer_star_count, + last_resale_star_count=UniqueGiftInfoTestBase.last_resale_star_count, + last_resale_currency=UniqueGiftInfoTestBase.last_resale_currency, + last_resale_amount=UniqueGiftInfoTestBase.last_resale_amount, + next_transfer_date=UniqueGiftInfoTestBase.next_transfer_date, + ) + + +class UniqueGiftInfoTestBase: + gift = UniqueGift( + gift_id="gift_id", + base_name="human_readable_name", + name="unique_name", + number=10, + model=UniqueGiftModel( + name="model_name", + sticker=Sticker("file_id1", "file_unique_id1", 512, 512, False, False, "regular"), + rarity_per_mille=10, + ), + symbol=UniqueGiftSymbol( + name="symbol_name", + sticker=Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + rarity_per_mille=20, + ), + backdrop=UniqueGiftBackdrop( + name="backdrop_name", + colors=UniqueGiftBackdropColors(0x00FF00, 0xEE00FF, 0xAA22BB, 0x20FE8F), + rarity_per_mille=2, + ), + ) + origin = UniqueGiftInfo.UPGRADE + owned_gift_id = "some_id" + transfer_star_count = 10 + last_resale_star_count = 5 + last_resale_currency = "XTR" + last_resale_amount = 1234 + next_transfer_date = dtm.datetime.now(tz=UTC).replace(microsecond=0) + + +class TestUniqueGiftInfoWithoutRequest(UniqueGiftInfoTestBase): + def test_slot_behaviour(self, unique_gift_info): + for attr in unique_gift_info.__slots__: + assert getattr(unique_gift_info, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(unique_gift_info)) == len(set(mro_slots(unique_gift_info))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "gift": self.gift.to_dict(), + "origin": self.origin, + "owned_gift_id": self.owned_gift_id, + "transfer_star_count": self.transfer_star_count, + "last_resale_star_count": self.last_resale_star_count, + "last_resale_currency": self.last_resale_currency, + "last_resale_amount": self.last_resale_amount, + "next_transfer_date": to_timestamp(self.next_transfer_date), + } + unique_gift_info = UniqueGiftInfo.de_json(json_dict, offline_bot) + assert unique_gift_info.api_kwargs == {} + assert unique_gift_info.gift == self.gift + assert unique_gift_info.origin == self.origin + assert unique_gift_info.owned_gift_id == self.owned_gift_id + assert unique_gift_info.transfer_star_count == self.transfer_star_count + assert unique_gift_info.last_resale_star_count == self.last_resale_star_count + assert unique_gift_info.last_resale_currency == self.last_resale_currency + assert unique_gift_info.last_resale_amount == self.last_resale_amount + assert unique_gift_info.next_transfer_date == self.next_transfer_date + + def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): + json_dict = { + "gift": self.gift.to_dict(), + "origin": self.origin, + "owned_gift_id": self.owned_gift_id, + "transfer_star_count": self.transfer_star_count, + "last_resale_star_count": self.last_resale_star_count, + "last_resale_currency": self.last_resale_currency, + "last_resale_amount": self.last_resale_amount, + "next_transfer_date": to_timestamp(self.next_transfer_date), + } + + unique_gift_info_raw = UniqueGiftInfo.de_json(json_dict, raw_bot) + unique_gift_info_offline = UniqueGiftInfo.de_json(json_dict, offline_bot) + unique_gift_info_tz = UniqueGiftInfo.de_json(json_dict, tz_bot) + + # comparing utcoffsets because comparing timezones is unpredicatable + unique_gift_info_tz_offset = unique_gift_info_tz.next_transfer_date.utcoffset() + tz_bot_offset = tz_bot.defaults.tzinfo.utcoffset( + unique_gift_info_tz.next_transfer_date.replace(tzinfo=None) + ) + + assert unique_gift_info_raw.next_transfer_date.tzinfo == UTC + assert unique_gift_info_offline.next_transfer_date.tzinfo == UTC + assert unique_gift_info_tz_offset == tz_bot_offset + + def test_to_dict(self, unique_gift_info): + json_dict = unique_gift_info.to_dict() + assert json_dict["gift"] == self.gift.to_dict() + assert json_dict["origin"] == self.origin + assert json_dict["owned_gift_id"] == self.owned_gift_id + assert json_dict["transfer_star_count"] == self.transfer_star_count + assert json_dict["last_resale_currency"] == self.last_resale_currency + assert json_dict["last_resale_amount"] == self.last_resale_amount + assert json_dict["next_transfer_date"] == to_timestamp(self.next_transfer_date) + + def test_enum_type_conversion(self, unique_gift_info): + assert type(unique_gift_info.origin) is UniqueGiftInfoOrigin + assert unique_gift_info.origin == UniqueGiftInfoOrigin.UPGRADE + + def test_equality(self, unique_gift_info): + a = unique_gift_info + b = UniqueGiftInfo(self.gift, self.origin, self.owned_gift_id, self.transfer_star_count) + c = UniqueGiftInfo( + self.gift, UniqueGiftInfo.TRANSFER, self.owned_gift_id, self.transfer_star_count + ) + d = BotCommand("start", "description") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + def test_last_resale_star_count_argument_deprecation(self): + with pytest.warns(PTBDeprecationWarning, match=r"9\.3.*last_resale_star_count") as record: + UniqueGiftInfo( + gift=self.gift, + origin=UniqueGiftInfo.TRANSFER, + last_resale_star_count=self.last_resale_star_count, + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + + def test_last_resale_star_count_attribute_deprecation(self, unique_gift_info): + with pytest.warns(PTBDeprecationWarning, match=r"9\.3.*last_resale_star_count") as record: + assert unique_gift_info.last_resale_star_count == self.last_resale_star_count + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" diff --git a/tests/test_update.py b/tests/test_update.py index 3e5ed3b6f95..b74234b9435 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,32 +16,62 @@ # # 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 as dtm import time -from datetime import datetime +from copy import deepcopy import pytest from telegram import ( + BusinessBotRights, + BusinessConnection, + BusinessMessagesDeleted, CallbackQuery, Chat, + ChatBoost, + ChatBoostRemoved, + ChatBoostSourcePremium, + ChatBoostUpdated, ChatJoinRequest, ChatMemberOwner, ChatMemberUpdated, ChosenInlineResult, + InaccessibleMessage, InlineQuery, Message, + MessageReactionCountUpdated, + MessageReactionUpdated, + PaidMediaPurchased, Poll, PollAnswer, PollOption, PreCheckoutQuery, + ReactionCount, + ReactionTypeEmoji, + ShippingAddress, ShippingQuery, Update, User, ) from telegram._utils.datetime import from_timestamp +from telegram.warnings import PTBUserWarning from tests.auxil.slots import mro_slots -message = Message(1, datetime.utcnow(), Chat(1, ""), from_user=User(1, "", False), text="Text") +message = Message( + 1, + dtm.datetime.utcnow(), + Chat(1, ""), + from_user=User(1, "", False), + text="Text", + sender_chat=Chat(1, ""), +) +channel_post = Message( + 1, + dtm.datetime.utcnow(), + Chat(1, ""), + text="Text", + sender_chat=Chat(1, ""), +) chat_member_updated = ChatMemberUpdated( Chat(1, "chat"), User(1, "", False), @@ -59,21 +89,108 @@ bio="bio", ) +chat_boost = ChatBoostUpdated( + chat=Chat(1, "priv"), + boost=ChatBoost( + "1", + from_timestamp(int(time.time())), + from_timestamp(int(time.time())), + ChatBoostSourcePremium(User(1, "", False)), + ), +) + +removed_chat_boost = ChatBoostRemoved( + Chat(1, "private"), + "2", + from_timestamp(int(time.time())), + ChatBoostSourcePremium(User(1, "name", False)), +) + +message_reaction = MessageReactionUpdated( + chat=Chat(1, "chat"), + message_id=1, + date=from_timestamp(int(time.time())), + old_reaction=(ReactionTypeEmoji("👍"),), + new_reaction=(ReactionTypeEmoji("👍"),), + user=User(1, "name", False), + actor_chat=Chat(1, ""), +) + + +message_reaction_count = MessageReactionCountUpdated( + chat=Chat(1, "chat"), + message_id=1, + date=from_timestamp(int(time.time())), + reactions=(ReactionCount(ReactionTypeEmoji("👍"), 1),), +) + +business_connection = BusinessConnection( + "1", + User(1, "name", False), + 1, + from_timestamp(int(time.time())), + True, + rights=BusinessBotRights(can_reply=True), +) + +deleted_business_messages = BusinessMessagesDeleted( + "1", + Chat(1, ""), + (1, 2), +) + +business_message = Message( + 1, + dtm.datetime.utcnow(), + Chat(1, ""), + User(1, "", False), +) + +purchased_paid_media = PaidMediaPurchased( + from_user=User(1, "", False), + paid_media_payload="payload", +) + + params = [ {"message": message}, {"edited_message": message}, {"callback_query": CallbackQuery(1, User(1, "", False), "chat", message=message)}, - {"channel_post": message}, - {"edited_channel_post": message}, + {"channel_post": channel_post}, + {"edited_channel_post": channel_post}, {"inline_query": InlineQuery(1, User(1, "", False), "", "")}, {"chosen_inline_result": ChosenInlineResult("id", User(1, "", False), "")}, - {"shipping_query": ShippingQuery("id", User(1, "", False), "", None)}, + { + "shipping_query": ShippingQuery( + "id", User(1, "", False), "", ShippingAddress("", "", "", "", "", "") + ) + }, {"pre_checkout_query": PreCheckoutQuery("id", User(1, "", False), "", 0, "")}, {"poll": Poll("id", "?", [PollOption(".", 1)], False, False, False, Poll.REGULAR, True)}, - {"poll_answer": PollAnswer("id", User(1, "", False), [1])}, + { + "poll_answer": PollAnswer( + "id", + [1], + User( + 1, + "", + False, + ), + Chat(1, ""), + ) + }, {"my_chat_member": chat_member_updated}, {"chat_member": chat_member_updated}, {"chat_join_request": chat_join_request}, + {"chat_boost": chat_boost}, + {"removed_chat_boost": removed_chat_boost}, + {"message_reaction": message_reaction}, + {"message_reaction_count": message_reaction_count}, + {"business_connection": business_connection}, + {"deleted_business_messages": deleted_business_messages}, + {"business_message": business_message}, + {"edited_business_message": business_message}, + {"purchased_paid_media": purchased_paid_media}, # Must be last to conform with `ids` below! {"callback_query": CallbackQuery(1, User(1, "", False), "chat")}, ] @@ -93,6 +210,15 @@ "my_chat_member", "chat_member", "chat_join_request", + "chat_boost", + "removed_chat_boost", + "message_reaction", + "message_reaction_count", + "business_connection", + "deleted_business_messages", + "business_message", + "edited_business_message", + "purchased_paid_media", ) ids = (*all_types, "callback_query_without_message") @@ -100,14 +226,14 @@ @pytest.fixture(scope="module", params=params, ids=ids) def update(request): - return Update(update_id=TestUpdateBase.update_id, **request.param) + return Update(update_id=UpdateTestBase.update_id, **request.param) -class TestUpdateBase: +class UpdateTestBase: update_id = 868573637 -class TestUpdateWithoutRequest(TestUpdateBase): +class TestUpdateWithoutRequest(UpdateTestBase): def test_slot_behaviour(self): update = Update(self.update_id) for attr in update.__slots__: @@ -115,11 +241,11 @@ def test_slot_behaviour(self): assert len(mro_slots(update)) == len(set(mro_slots(update))), "duplicate slot" @pytest.mark.parametrize("paramdict", argvalues=params, ids=ids) - def test_de_json(self, bot, paramdict): + def test_de_json(self, offline_bot, paramdict): json_dict = {"update_id": self.update_id} # Convert the single update 'item' to a dict of that item and apply it to the json_dict json_dict.update({k: v.to_dict() for k, v in paramdict.items()}) - update = Update.de_json(json_dict, bot) + update = Update.de_json(json_dict, offline_bot) assert update.api_kwargs == {} assert update.update_id == self.update_id @@ -132,11 +258,6 @@ def test_de_json(self, bot, paramdict): assert getattr(update, _type) == paramdict[_type] assert i == 1 - def test_update_de_json_empty(self, bot): - update = Update.de_json(None, bot) - - assert update is None - def test_to_dict(self, update): update_dict = update.to_dict() @@ -177,6 +298,8 @@ def test_effective_chat(self, update): or update.pre_checkout_query is not None or update.poll is not None or update.poll_answer is not None + or update.business_connection is not None + or update.purchased_paid_media is not None ): assert chat.id == 1 else: @@ -189,11 +312,87 @@ def test_effective_user(self, update): update.channel_post is not None or update.edited_channel_post is not None or update.poll is not None + or update.chat_boost is not None + or update.removed_chat_boost is not None + or update.message_reaction_count is not None + or update.deleted_business_messages is not None ): assert user.id == 1 else: assert user is None + def test_effective_sender_non_anonymous(self, update): + update = deepcopy(update) + # Simulate 'Remain anonymous' being turned off + if message := (update.message or update.edited_message): + message._unfreeze() + message.sender_chat = None + elif reaction := (update.message_reaction): + reaction._unfreeze() + reaction.actor_chat = None + elif answer := (update.poll_answer): + answer._unfreeze() + answer.voter_chat = None + + # Test that it's sometimes None per docstring + sender = update.effective_sender + if not ( + update.poll is not None + or update.chat_boost is not None + or update.removed_chat_boost is not None + or update.message_reaction_count is not None + or update.deleted_business_messages is not None + ): + if update.channel_post or update.edited_channel_post: + assert isinstance(sender, Chat) + else: + assert isinstance(sender, User) + + else: + assert sender is None + + cached = update.effective_sender + assert cached is sender + + def test_effective_sender_anonymous(self, update): + update = deepcopy(update) + # Simulate 'Remain anonymous' being turned on + if message := (update.message or update.edited_message): + message._unfreeze() + message.from_user = None + elif reaction := (update.message_reaction): + reaction._unfreeze() + reaction.user = None + elif answer := (update.poll_answer): + answer._unfreeze() + answer.user = None + + # Test that it's sometimes None per docstring + sender = update.effective_sender + if not ( + update.poll is not None + or update.chat_boost is not None + or update.removed_chat_boost is not None + or update.message_reaction_count is not None + or update.deleted_business_messages is not None + ): + if ( + update.message + or update.edited_message + or update.channel_post + or update.edited_channel_post + or update.message_reaction + or update.poll_answer + ): + assert isinstance(sender, Chat) + else: + assert isinstance(sender, User) + else: + assert sender is None + + cached = update.effective_sender + assert cached is sender + def test_effective_message(self, update): # Test that it's sometimes None per docstring eff_message = update.effective_message @@ -208,7 +407,32 @@ def test_effective_message(self, update): or update.my_chat_member is not None or update.chat_member is not None or update.chat_join_request is not None + or update.chat_boost is not None + or update.removed_chat_boost is not None + or update.message_reaction is not None + or update.message_reaction_count is not None + or update.deleted_business_messages is not None + or update.business_connection is not None + or update.purchased_paid_media is not None ): assert eff_message.message_id == message.message_id else: assert eff_message is None + + def test_effective_message_inaccessible(self): + update = Update( + update_id=1, + callback_query=CallbackQuery( + "id", + User(1, "", False), + "chat", + message=InaccessibleMessage(message_id=1, chat=Chat(1, "")), + ), + ) + with pytest.warns( + PTBUserWarning, + match="update.callback_query` is not `None`, but of type `InaccessibleMessage`", + ) as record: + assert update.effective_message is None + + assert record[0].filename == __file__ diff --git a/tests/test_user.py b/tests/test_user.py index e21b5443d31..4d85d616a65 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -31,41 +31,47 @@ @pytest.fixture(scope="module") def json_dict(): return { - "id": TestUserBase.id_, - "is_bot": TestUserBase.is_bot, - "first_name": TestUserBase.first_name, - "last_name": TestUserBase.last_name, - "username": TestUserBase.username, - "language_code": TestUserBase.language_code, - "can_join_groups": TestUserBase.can_join_groups, - "can_read_all_group_messages": TestUserBase.can_read_all_group_messages, - "supports_inline_queries": TestUserBase.supports_inline_queries, - "is_premium": TestUserBase.is_premium, - "added_to_attachment_menu": TestUserBase.added_to_attachment_menu, + "id": UserTestBase.id_, + "is_bot": UserTestBase.is_bot, + "first_name": UserTestBase.first_name, + "last_name": UserTestBase.last_name, + "username": UserTestBase.username, + "language_code": UserTestBase.language_code, + "can_join_groups": UserTestBase.can_join_groups, + "can_read_all_group_messages": UserTestBase.can_read_all_group_messages, + "supports_inline_queries": UserTestBase.supports_inline_queries, + "is_premium": UserTestBase.is_premium, + "added_to_attachment_menu": UserTestBase.added_to_attachment_menu, + "can_connect_to_business": UserTestBase.can_connect_to_business, + "has_main_web_app": UserTestBase.has_main_web_app, + "has_topics_enabled": UserTestBase.has_topics_enabled, } -@pytest.fixture() +@pytest.fixture def user(bot): user = User( - id=TestUserBase.id_, - first_name=TestUserBase.first_name, - is_bot=TestUserBase.is_bot, - last_name=TestUserBase.last_name, - username=TestUserBase.username, - language_code=TestUserBase.language_code, - can_join_groups=TestUserBase.can_join_groups, - can_read_all_group_messages=TestUserBase.can_read_all_group_messages, - supports_inline_queries=TestUserBase.supports_inline_queries, - is_premium=TestUserBase.is_premium, - added_to_attachment_menu=TestUserBase.added_to_attachment_menu, + id=UserTestBase.id_, + first_name=UserTestBase.first_name, + is_bot=UserTestBase.is_bot, + last_name=UserTestBase.last_name, + username=UserTestBase.username, + language_code=UserTestBase.language_code, + can_join_groups=UserTestBase.can_join_groups, + can_read_all_group_messages=UserTestBase.can_read_all_group_messages, + supports_inline_queries=UserTestBase.supports_inline_queries, + is_premium=UserTestBase.is_premium, + added_to_attachment_menu=UserTestBase.added_to_attachment_menu, + can_connect_to_business=UserTestBase.can_connect_to_business, + has_main_web_app=UserTestBase.has_main_web_app, + has_topics_enabled=UserTestBase.has_topics_enabled, ) user.set_bot(bot) user._unfreeze() return user -class TestUserBase: +class UserTestBase: id_ = 1 is_bot = True first_name = "first\u2022name" @@ -77,16 +83,19 @@ class TestUserBase: supports_inline_queries = False is_premium = True added_to_attachment_menu = False + can_connect_to_business = True + has_main_web_app = False + has_topics_enabled = False -class TestUserWithoutRequest(TestUserBase): +class TestUserWithoutRequest(UserTestBase): def test_slot_behaviour(self, user): for attr in user.__slots__: assert getattr(user, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(user)) == len(set(mro_slots(user))), "duplicate slot" - def test_de_json(self, json_dict, bot): - user = User.de_json(json_dict, bot) + def test_de_json(self, json_dict, offline_bot): + user = User.de_json(json_dict, offline_bot) assert user.api_kwargs == {} assert user.id == self.id_ @@ -100,6 +109,9 @@ def test_de_json(self, json_dict, bot): assert user.supports_inline_queries == self.supports_inline_queries assert user.is_premium == self.is_premium assert user.added_to_attachment_menu == self.added_to_attachment_menu + assert user.can_connect_to_business == self.can_connect_to_business + assert user.has_main_web_app == self.has_main_web_app + assert user.has_topics_enabled == self.has_topics_enabled def test_to_dict(self, user): user_dict = user.to_dict() @@ -116,6 +128,9 @@ def test_to_dict(self, user): assert user_dict["supports_inline_queries"] == user.supports_inline_queries assert user_dict["is_premium"] == user.is_premium assert user_dict["added_to_attachment_menu"] == user.added_to_attachment_menu + assert user_dict["can_connect_to_business"] == user.can_connect_to_business + assert user_dict["has_main_web_app"] == user.has_main_web_app + assert user_dict["has_topics_enabled"] == user.has_topics_enabled def test_equality(self): a = User(self.id_, self.first_name, self.is_bot, self.last_name) @@ -221,6 +236,25 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(user.get_bot(), "send_message", make_assertion) assert await user.send_message("test") + async def test_instance_method_send_message_draft(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == user.id + and kwargs["draft_id"] == 123 + and kwargs["text"] == "test" + ) + + assert check_shortcut_signature( + User.send_message_draft, Bot.send_message_draft, ["chat_id"], [] + ) + assert await check_shortcut_call( + user.send_message_draft, user.get_bot(), "send_message_draft" + ) + assert await check_defaults_handling(user.send_message_draft, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "send_message_draft", make_assertion) + assert await user.send_message_draft(123, "test") + async def test_instance_method_send_photo(self, monkeypatch, user): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == user.id and kwargs["photo"] == "test_photo" @@ -333,9 +367,9 @@ async def make_assertion(*_, **kwargs): "title", "description", "payload", - "provider_token", "currency", "prices", + "provider_token", ) async def test_instance_method_send_location(self, monkeypatch, user): @@ -434,8 +468,8 @@ async def make_assertion(*_, **kwargs): return from_chat_id and message_id and user_id assert check_shortcut_signature(User.send_copy, Bot.copy_message, ["chat_id"], []) - assert await check_shortcut_call(user.copy_message, user.get_bot(), "copy_message") - assert await check_defaults_handling(user.copy_message, user.get_bot()) + assert await check_shortcut_call(user.send_copy, user.get_bot(), "copy_message") + assert await check_defaults_handling(user.send_copy, user.get_bot()) monkeypatch.setattr(user.get_bot(), "copy_message", make_assertion) assert await user.send_copy(from_chat_id="from_chat_id", message_id="message_id") @@ -454,6 +488,23 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(user.get_bot(), "copy_message", make_assertion) assert await user.copy_message(chat_id="chat_id", message_id="message_id") + async def test_instance_method_get_user_chat_boosts(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + chat_id = kwargs["chat_id"] == "chat_id" + user_id = kwargs["user_id"] == user.id + return chat_id and user_id + + assert check_shortcut_signature( + User.get_chat_boosts, Bot.get_user_chat_boosts, ["user_id"], [] + ) + assert await check_shortcut_call( + user.get_chat_boosts, user.get_bot(), "get_user_chat_boosts" + ) + assert await check_defaults_handling(user.get_chat_boosts, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "get_user_chat_boosts", make_assertion) + assert await user.get_chat_boosts(chat_id="chat_id") + async def test_instance_method_get_menu_button(self, monkeypatch, user): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == user.id @@ -562,3 +613,257 @@ async def test_mention_markdown_v2(self, user): "the\\{name\\>\u2022", user.id ) assert user.mention_markdown_v2(user.username) == expected.format(user.username, user.id) + + async def test_delete_message(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == user.id and kwargs["message_id"] == 42 + + assert check_shortcut_signature(user.delete_message, Bot.delete_message, ["chat_id"], []) + assert await check_shortcut_call(user.delete_message, user.get_bot(), "delete_message") + assert await check_defaults_handling(user.delete_message, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "delete_message", make_assertion) + assert await user.delete_message(message_id=42) + + async def test_delete_messages(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return kwargs["chat_id"] == user.id and kwargs["message_ids"] == (42, 43) + + assert check_shortcut_signature(user.delete_messages, Bot.delete_messages, ["chat_id"], []) + assert await check_shortcut_call(user.delete_messages, user.get_bot(), "delete_messages") + assert await check_defaults_handling(user.delete_messages, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "delete_messages", make_assertion) + assert await user.delete_messages(message_ids=(42, 43)) + + async def test_instance_method_send_copies(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + from_chat_id = kwargs["from_chat_id"] == "test_copies" + message_ids = kwargs["message_ids"] == (42, 43) + user_id = kwargs["chat_id"] == user.id + return from_chat_id and message_ids and user_id + + assert check_shortcut_signature(user.send_copies, Bot.copy_messages, ["chat_id"], []) + assert await check_shortcut_call(user.send_copies, user.get_bot(), "copy_messages") + assert await check_defaults_handling(user.send_copies, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "copy_messages", make_assertion) + assert await user.send_copies(from_chat_id="test_copies", message_ids=(42, 43)) + + async def test_instance_method_copy_messages(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + from_chat_id = kwargs["from_chat_id"] == user.id + message_ids = kwargs["message_ids"] == (42, 43) + user_id = kwargs["chat_id"] == "test_copies" + return from_chat_id and message_ids and user_id + + assert check_shortcut_signature( + user.copy_messages, Bot.copy_messages, ["from_chat_id"], [] + ) + assert await check_shortcut_call(user.copy_messages, user.get_bot(), "copy_messages") + assert await check_defaults_handling(user.copy_messages, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "copy_messages", make_assertion) + assert await user.copy_messages(chat_id="test_copies", message_ids=(42, 43)) + + async def test_instance_method_forward_from(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + user_id = kwargs["chat_id"] == user.id + message_id = kwargs["message_id"] == 42 + from_chat_id = kwargs["from_chat_id"] == "test_forward" + return from_chat_id and message_id and user_id + + assert check_shortcut_signature(user.forward_from, Bot.forward_message, ["chat_id"], []) + assert await check_shortcut_call(user.forward_from, user.get_bot(), "forward_message") + assert await check_defaults_handling(user.forward_from, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "forward_message", make_assertion) + assert await user.forward_from(from_chat_id="test_forward", message_id=42) + + async def test_instance_method_forward_to(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + from_chat_id = kwargs["from_chat_id"] == user.id + message_id = kwargs["message_id"] == 42 + user_id = kwargs["chat_id"] == "test_forward" + return from_chat_id and message_id and user_id + + assert check_shortcut_signature(user.forward_to, Bot.forward_message, ["from_chat_id"], []) + assert await check_shortcut_call(user.forward_to, user.get_bot(), "forward_message") + assert await check_defaults_handling(user.forward_to, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "forward_message", make_assertion) + assert await user.forward_to(chat_id="test_forward", message_id=42) + + async def test_instance_method_forward_messages_from(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + user_id = kwargs["chat_id"] == user.id + message_ids = kwargs["message_ids"] == (42, 43) + from_chat_id = kwargs["from_chat_id"] == "test_forwards" + return from_chat_id and message_ids and user_id + + assert check_shortcut_signature( + user.forward_messages_from, Bot.forward_messages, ["chat_id"], [] + ) + assert await check_shortcut_call( + user.forward_messages_from, user.get_bot(), "forward_messages" + ) + assert await check_defaults_handling(user.forward_messages_from, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "forward_messages", make_assertion) + assert await user.forward_messages_from(from_chat_id="test_forwards", message_ids=(42, 43)) + + async def test_instance_method_forward_messages_to(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + from_chat_id = kwargs["from_chat_id"] == user.id + message_ids = kwargs["message_ids"] == (42, 43) + user_id = kwargs["chat_id"] == "test_forwards" + return from_chat_id and message_ids and user_id + + assert check_shortcut_signature( + user.forward_messages_to, Bot.forward_messages, ["from_chat_id"], [] + ) + assert await check_shortcut_call( + user.forward_messages_to, user.get_bot(), "forward_messages" + ) + assert await check_defaults_handling(user.forward_messages_to, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "forward_messages", make_assertion) + assert await user.forward_messages_to(chat_id="test_forwards", message_ids=(42, 43)) + + async def test_instance_method_refund_star_payment(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return kwargs["user_id"] == user.id and kwargs["telegram_payment_charge_id"] == 42 + + assert check_shortcut_signature( + user.refund_star_payment, Bot.refund_star_payment, ["user_id"], [] + ) + assert await check_shortcut_call( + user.refund_star_payment, user.get_bot(), "refund_star_payment" + ) + assert await check_defaults_handling(user.refund_star_payment, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "refund_star_payment", make_assertion) + assert await user.refund_star_payment(telegram_payment_charge_id=42) + + async def test_instance_method_send_gift(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["user_id"] == user.id + and kwargs["gift_id"] == "gift_id" + and kwargs["text"] == "text" + and kwargs["text_parse_mode"] == "text_parse_mode" + and kwargs["text_entities"] == "text_entities" + ) + + assert check_shortcut_signature(user.send_gift, Bot.send_gift, ["user_id", "chat_id"], []) + assert await check_shortcut_call( + user.send_gift, user.get_bot(), "send_gift", ["chat_id", "user_id"] + ) + assert await check_defaults_handling(user.send_gift, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "send_gift", make_assertion) + assert await user.send_gift( + gift_id="gift_id", + text="text", + text_parse_mode="text_parse_mode", + text_entities="text_entities", + ) + + async def test_instance_method_gift_premium_subscription(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["user_id"] == user.id + and kwargs["month_count"] == 3 + and kwargs["star_count"] == 1000 + and kwargs["text"] == "text" + and kwargs["text_parse_mode"] == "text_parse_mode" + and kwargs["text_entities"] == "text_entities" + ) + + assert check_shortcut_signature( + user.gift_premium_subscription, Bot.gift_premium_subscription, ["user_id"], [] + ) + assert await check_shortcut_call( + user.gift_premium_subscription, + user.get_bot(), + "gift_premium_subscription", + ) + assert await check_defaults_handling(user.gift_premium_subscription, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "gift_premium_subscription", make_assertion) + assert await user.gift_premium_subscription( + month_count=3, + star_count=1000, + text="text", + text_parse_mode="text_parse_mode", + text_entities="text_entities", + ) + + async def test_instance_method_verify_user(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["user_id"] == user.id + and kwargs["custom_description"] == "This is a custom description" + ) + + assert check_shortcut_signature(user.verify, Bot.verify_user, ["user_id"], []) + assert await check_shortcut_call(user.verify, user.get_bot(), "verify_user") + assert await check_defaults_handling(user.verify, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "verify_user", make_assertion) + assert await user.verify( + custom_description="This is a custom description", + ) + + async def test_instance_method_remove_user_verification(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return kwargs["user_id"] == user.id + + assert check_shortcut_signature( + user.remove_verification, Bot.remove_user_verification, ["user_id"], [] + ) + assert await check_shortcut_call( + user.remove_verification, user.get_bot(), "remove_user_verification" + ) + assert await check_defaults_handling(user.remove_verification, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "remove_user_verification", make_assertion) + assert await user.remove_verification() + + async def test_instance_method_repost_story(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return kwargs["from_chat_id"] == user.id + + assert check_shortcut_signature( + User.repost_story, + Bot.repost_story, + [ + "from_chat_id", + ], + additional_kwargs=[], + ) + assert await check_shortcut_call( + user.repost_story, + user.get_bot(), + "repost_story", + shortcut_kwargs=["from_chat_id"], + ) + assert await check_defaults_handling(user.repost_story, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "repost_story", make_assertion) + assert await user.repost_story( + business_connection_id="bcid", + from_story_id=123, + active_period=3600, + ) + + async def test_instance_method_get_gifts(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return kwargs["user_id"] == user.id + + assert check_shortcut_signature(user.get_gifts, Bot.get_user_gifts, ["user_id"], []) + assert await check_shortcut_call(user.get_gifts, user.get_bot(), "get_user_gifts") + assert await check_defaults_handling(user.get_gifts, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "get_user_gifts", make_assertion) + assert await user.get_gifts() diff --git a/tests/test_userprofilephotos.py b/tests/test_userprofilephotos.py index 25c9dd8e8df..973d5158841 100644 --- a/tests/test_userprofilephotos.py +++ b/tests/test_userprofilephotos.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -20,7 +20,7 @@ from tests.auxil.slots import mro_slots -class TestUserProfilePhotosBase: +class UserProfilePhotosTestBase: total_count = 2 photos = [ [ @@ -34,16 +34,16 @@ class TestUserProfilePhotosBase: ] -class TestUserProfilePhotosWithoutRequest(TestUserProfilePhotosBase): +class TestUserProfilePhotosWithoutRequest(UserProfilePhotosTestBase): def test_slot_behaviour(self): inst = UserProfilePhotos(self.total_count, self.photos) 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): + def test_de_json(self, offline_bot): json_dict = {"total_count": 2, "photos": [[y.to_dict() for y in x] for x in self.photos]} - user_profile_photos = UserProfilePhotos.de_json(json_dict, bot) + user_profile_photos = UserProfilePhotos.de_json(json_dict, offline_bot) assert user_profile_photos.api_kwargs == {} assert user_profile_photos.total_count == self.total_count assert user_profile_photos.photos == tuple(tuple(p) for p in self.photos) diff --git a/tests/test_userrating.py b/tests/test_userrating.py new file mode 100644 index 00000000000..effcdebc68b --- /dev/null +++ b/tests/test_userrating.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# 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 BotCommand, UserRating +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def user_rating(): + return UserRating( + level=UserRatingTestBase.level, + rating=UserRatingTestBase.rating, + current_level_rating=UserRatingTestBase.current_level_rating, + next_level_rating=UserRatingTestBase.next_level_rating, + ) + + +class UserRatingTestBase: + level = 2 + rating = 120 + current_level_rating = 100 + next_level_rating = 180 + + +class TestUserRatingWithoutRequest(UserRatingTestBase): + def test_slot_behaviour(self, user_rating): + for attr in user_rating.__slots__: + assert getattr(user_rating, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(user_rating)) == len(set(mro_slots(user_rating))), "duplicate slot" + + def test_de_json_with_next(self, offline_bot): + json_dict = { + "level": self.level, + "rating": self.rating, + "current_level_rating": self.current_level_rating, + "next_level_rating": self.next_level_rating, + } + ur = UserRating.de_json(json_dict, offline_bot) + assert ur.api_kwargs == {} + + assert ur.level == self.level + assert ur.rating == self.rating + assert ur.current_level_rating == self.current_level_rating + assert ur.next_level_rating == self.next_level_rating + + def test_de_json_no_optional(self, offline_bot): + json_dict = { + "level": self.level, + "rating": self.rating, + "current_level_rating": self.current_level_rating, + } + ur = UserRating.de_json(json_dict, offline_bot) + assert ur.api_kwargs == {} + + assert ur.level == self.level + assert ur.rating == self.rating + assert ur.current_level_rating == self.current_level_rating + assert ur.next_level_rating is None + + def test_to_dict(self, user_rating): + ur_dict = user_rating.to_dict() + + assert isinstance(ur_dict, dict) + assert ur_dict["level"] == user_rating.level + assert ur_dict["rating"] == user_rating.rating + assert ur_dict["current_level_rating"] == user_rating.current_level_rating + assert ur_dict["next_level_rating"] == user_rating.next_level_rating + + def test_equality(self): + a = UserRating(3, 200, 150, 300) + b = UserRating(3, 200, 100, None) + c = UserRating(3, 201, 150, 300) + d = UserRating(4, 200, 150, 300) + e = BotCommand("start", "description") + + 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) diff --git a/tests/test_version.py b/tests/test_version.py index f521bdf38b9..ab57f9325e8 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify diff --git a/tests/test_videochat.py b/tests/test_videochat.py index a3272c7b03b..325c8ac8e5e 100644 --- a/tests/test_videochat.py +++ b/tests/test_videochat.py @@ -1,6 +1,6 @@ #!/usr/bin/env python # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -28,6 +28,7 @@ VideoChatStarted, ) from telegram._utils.datetime import UTC, to_timestamp +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -60,7 +61,7 @@ def test_to_dict(self): class TestVideoChatEndedWithoutRequest: - duration = 100 + duration = dtm.timedelta(seconds=100) def test_slot_behaviour(self): action = VideoChatEnded(8) @@ -69,27 +70,50 @@ def test_slot_behaviour(self): assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" def test_de_json(self): - json_dict = {"duration": self.duration} + json_dict = {"duration": int(self.duration.total_seconds())} video_chat_ended = VideoChatEnded.de_json(json_dict, None) assert video_chat_ended.api_kwargs == {} - assert video_chat_ended.duration == self.duration + assert video_chat_ended._duration == self.duration def test_to_dict(self): video_chat_ended = VideoChatEnded(self.duration) video_chat_dict = video_chat_ended.to_dict() assert isinstance(video_chat_dict, dict) - assert video_chat_dict["duration"] == self.duration + assert video_chat_dict["duration"] == int(self.duration.total_seconds()) + + def test_time_period_properties(self, PTB_TIMEDELTA): + duration = VideoChatEnded(duration=self.duration).duration + + if PTB_TIMEDELTA: + assert duration == self.duration + assert isinstance(duration, dtm.timedelta) + else: + assert duration == int(self.duration.total_seconds()) + assert isinstance(duration, int) + + def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA): + VideoChatEnded(self.duration).duration + + if PTB_TIMEDELTA: + assert len(recwarn) == 0 + else: + assert len(recwarn) == 1 + assert "`duration` will be of type `datetime.timedelta`" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning def test_equality(self): a = VideoChatEnded(100) b = VideoChatEnded(100) + x = VideoChatEnded(dtm.timedelta(seconds=100)) c = VideoChatEnded(50) d = VideoChatStarted() assert a == b assert hash(a) == hash(b) + assert b == x + assert hash(b) == hash(x) assert a != c assert hash(a) != hash(c) @@ -105,9 +129,9 @@ def test_slot_behaviour(self, user1): assert getattr(action, attr, "err") != "err", f"got extra slot '{attr}'" assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" - def test_de_json(self, user1, user2, bot): + def test_de_json(self, user1, user2, offline_bot): json_data = {"users": [user1.to_dict(), user2.to_dict()]} - video_chat_participants = VideoChatParticipantsInvited.de_json(json_data, bot) + video_chat_participants = VideoChatParticipantsInvited.de_json(json_data, offline_bot) assert video_chat_participants.api_kwargs == {} assert isinstance(video_chat_participants.users, tuple) @@ -161,20 +185,18 @@ def test_slot_behaviour(self): def test_expected_values(self): assert VideoChatScheduled(self.start_date).start_date == self.start_date - def test_de_json(self, bot): - assert VideoChatScheduled.de_json({}, bot=bot) is None - + def test_de_json(self, offline_bot): json_dict = {"start_date": to_timestamp(self.start_date)} - video_chat_scheduled = VideoChatScheduled.de_json(json_dict, bot) + video_chat_scheduled = VideoChatScheduled.de_json(json_dict, offline_bot) assert video_chat_scheduled.api_kwargs == {} assert abs(video_chat_scheduled.start_date - self.start_date) < dtm.timedelta(seconds=1) - def test_de_json_localization(self, tz_bot, bot, raw_bot): + def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): json_dict = {"start_date": to_timestamp(self.start_date)} videochat_raw = VideoChatScheduled.de_json(json_dict, raw_bot) - videochat_bot = VideoChatScheduled.de_json(json_dict, bot) + videochat_bot = VideoChatScheduled.de_json(json_dict, offline_bot) videochat_tz = VideoChatScheduled.de_json(json_dict, tz_bot) # comparing utcoffsets because comparing timezones is unpredicatable diff --git a/tests/test_warnings.py b/tests/test_warnings.py index 172ee343893..211e3d511e1 100644 --- a/tests/test_warnings.py +++ b/tests/test_warnings.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -23,7 +23,7 @@ from telegram._utils.warnings import warn from telegram.warnings import PTBDeprecationWarning, PTBRuntimeWarning, PTBUserWarning -from tests.auxil.files import PROJECT_ROOT_PATH +from tests.auxil.files import SOURCE_ROOT_PATH from tests.auxil.slots import mro_slots @@ -33,7 +33,7 @@ class TestWarnings: [ (PTBUserWarning("test message")), (PTBRuntimeWarning("test message")), - (PTBDeprecationWarning()), + (PTBDeprecationWarning("20.6", "test message")), ], ) def test_slots_behavior(self, inst): @@ -66,7 +66,7 @@ def make_assertion(cls): make_assertion(PTBUserWarning) def test_warn(self, recwarn): - expected_file = PROJECT_ROOT_PATH / "telegram" / "_utils" / "warnings.py" + expected_file = SOURCE_ROOT_PATH / "_utils" / "warnings.py" warn("test message") assert len(recwarn) == 1 @@ -80,9 +80,8 @@ def test_warn(self, recwarn): assert str(recwarn[1].message) == "test message 2" assert Path(recwarn[1].filename) == expected_file, "incorrect stacklevel!" - warn("test message 3", stacklevel=1, category=PTBDeprecationWarning) - expected_file = Path(__file__) + warn(PTBDeprecationWarning("20.6", "test message 3"), stacklevel=1) assert len(recwarn) == 3 assert recwarn[2].category is PTBDeprecationWarning - assert str(recwarn[2].message) == "test message 3" - assert Path(recwarn[2].filename) == expected_file, "incorrect stacklevel!" + assert str(recwarn[2].message) == "Deprecated since version 20.6: test message 3" + assert Path(recwarn[2].filename) == Path(__file__), "incorrect stacklevel!" diff --git a/tests/test_webappdata.py b/tests/test_webappdata.py index 3e53e0d276b..a52e49275a9 100644 --- a/tests/test_webappdata.py +++ b/tests/test_webappdata.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,15 +25,15 @@ @pytest.fixture(scope="module") def web_app_data(): - return WebAppData(data=TestWebAppDataBase.data, button_text=TestWebAppDataBase.button_text) + return WebAppData(data=WebAppDataTestBase.data, button_text=WebAppDataTestBase.button_text) -class TestWebAppDataBase: +class WebAppDataTestBase: data = "data" button_text = "button_text" -class TestWebAppDataWithoutRequest(TestWebAppDataBase): +class TestWebAppDataWithoutRequest(WebAppDataTestBase): def test_slot_behaviour(self, web_app_data): for attr in web_app_data.__slots__: assert getattr(web_app_data, attr, "err") != "err", f"got extra slot '{attr}'" @@ -46,9 +46,9 @@ def test_to_dict(self, web_app_data): assert web_app_data_dict["data"] == self.data assert web_app_data_dict["button_text"] == self.button_text - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = {"data": self.data, "button_text": self.button_text} - web_app_data = WebAppData.de_json(json_dict, bot) + web_app_data = WebAppData.de_json(json_dict, offline_bot) assert web_app_data.api_kwargs == {} assert web_app_data.data == self.data diff --git a/tests/test_webappinfo.py b/tests/test_webappinfo.py index d40e1531864..d831945696e 100644 --- a/tests/test_webappinfo.py +++ b/tests/test_webappinfo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -25,14 +25,14 @@ @pytest.fixture(scope="module") def web_app_info(): - return WebAppInfo(url=TestWebAppInfoBase.url) + return WebAppInfo(url=WebAppInfoTestBase.url) -class TestWebAppInfoBase: +class WebAppInfoTestBase: url = "https://www.example.com" -class TestWebAppInfoWithoutRequest(TestWebAppInfoBase): +class TestWebAppInfoWithoutRequest(WebAppInfoTestBase): def test_slot_behaviour(self, web_app_info): for attr in web_app_info.__slots__: assert getattr(web_app_info, attr, "err") != "err", f"got extra slot '{attr}'" @@ -44,9 +44,9 @@ def test_to_dict(self, web_app_info): assert isinstance(web_app_info_dict, dict) assert web_app_info_dict["url"] == self.url - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = {"url": self.url} - web_app_info = WebAppInfo.de_json(json_dict, bot) + web_app_info = WebAppInfo.de_json(json_dict, offline_bot) assert web_app_info.api_kwargs == {} assert web_app_info.url == self.url diff --git a/tests/test_webhookinfo.py b/tests/test_webhookinfo.py index acba601ff8a..857fc942704 100644 --- a/tests/test_webhookinfo.py +++ b/tests/test_webhookinfo.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -16,8 +16,8 @@ # # 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 as dtm import time -from datetime import datetime import pytest @@ -29,18 +29,18 @@ @pytest.fixture(scope="module") def webhook_info(): return WebhookInfo( - url=TestWebhookInfoBase.url, - has_custom_certificate=TestWebhookInfoBase.has_custom_certificate, - pending_update_count=TestWebhookInfoBase.pending_update_count, - ip_address=TestWebhookInfoBase.ip_address, - last_error_date=TestWebhookInfoBase.last_error_date, - max_connections=TestWebhookInfoBase.max_connections, - allowed_updates=TestWebhookInfoBase.allowed_updates, - last_synchronization_error_date=TestWebhookInfoBase.last_synchronization_error_date, + url=WebhookInfoTestBase.url, + has_custom_certificate=WebhookInfoTestBase.has_custom_certificate, + pending_update_count=WebhookInfoTestBase.pending_update_count, + ip_address=WebhookInfoTestBase.ip_address, + last_error_date=WebhookInfoTestBase.last_error_date, + max_connections=WebhookInfoTestBase.max_connections, + allowed_updates=WebhookInfoTestBase.allowed_updates, + last_synchronization_error_date=WebhookInfoTestBase.last_synchronization_error_date, ) -class TestWebhookInfoBase: +class WebhookInfoTestBase: url = "http://www.google.com" has_custom_certificate = False pending_update_count = 5 @@ -51,7 +51,7 @@ class TestWebhookInfoBase: last_synchronization_error_date = time.time() -class TestWebhookInfoWithoutRequest(TestWebhookInfoBase): +class TestWebhookInfoWithoutRequest(WebhookInfoTestBase): def test_slot_behaviour(self, webhook_info): for attr in webhook_info.__slots__: assert getattr(webhook_info, attr, "err") != "err", f"got extra slot '{attr}'" @@ -72,7 +72,7 @@ def test_to_dict(self, webhook_info): == self.last_synchronization_error_date ) - def test_de_json(self, bot): + def test_de_json(self, offline_bot): json_dict = { "url": self.url, "has_custom_certificate": self.has_custom_certificate, @@ -83,26 +83,23 @@ def test_de_json(self, bot): "ip_address": self.ip_address, "last_synchronization_error_date": self.last_synchronization_error_date, } - webhook_info = WebhookInfo.de_json(json_dict, bot) + webhook_info = WebhookInfo.de_json(json_dict, offline_bot) assert webhook_info.api_kwargs == {} assert webhook_info.url == self.url assert webhook_info.has_custom_certificate == self.has_custom_certificate assert webhook_info.pending_update_count == self.pending_update_count - assert isinstance(webhook_info.last_error_date, datetime) + assert isinstance(webhook_info.last_error_date, dtm.datetime) assert webhook_info.last_error_date == from_timestamp(self.last_error_date) assert webhook_info.max_connections == self.max_connections assert webhook_info.allowed_updates == tuple(self.allowed_updates) assert webhook_info.ip_address == self.ip_address - assert isinstance(webhook_info.last_synchronization_error_date, datetime) + assert isinstance(webhook_info.last_synchronization_error_date, dtm.datetime) assert webhook_info.last_synchronization_error_date == from_timestamp( self.last_synchronization_error_date ) - none = WebhookInfo.de_json(None, bot) - assert none is None - - def test_de_json_localization(self, bot, raw_bot, tz_bot): + def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): json_dict = { "url": self.url, "has_custom_certificate": self.has_custom_certificate, @@ -113,7 +110,7 @@ def test_de_json_localization(self, bot, raw_bot, tz_bot): "ip_address": self.ip_address, "last_synchronization_error_date": self.last_synchronization_error_date, } - webhook_info_bot = WebhookInfo.de_json(json_dict, bot) + webhook_info_bot = WebhookInfo.de_json(json_dict, offline_bot) webhook_info_raw = WebhookInfo.de_json(json_dict, raw_bot) webhook_info_tz = WebhookInfo.de_json(json_dict, tz_bot) diff --git a/tests/test_writeaccessallowed.py b/tests/test_writeaccessallowed.py index d330ccbee40..598db3ca83e 100644 --- a/tests/test_writeaccessallowed.py +++ b/tests/test_writeaccessallowed.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # # A library that provides a Python interface to the Telegram Bot API -# Copyright (C) 2015-2023 +# Copyright (C) 2015-2026 # Leandro Toledo de Souza # # This program is free software: you can redistribute it and/or modify @@ -36,3 +36,22 @@ def test_to_dict(self): action = WriteAccessAllowed() action_dict = action.to_dict() assert action_dict == {} + + def test_equality(self): + a = WriteAccessAllowed() + b = WriteAccessAllowed() + c = WriteAccessAllowed(web_app_name="foo") + d = WriteAccessAllowed(web_app_name="foo") + e = WriteAccessAllowed(web_app_name="bar") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert c == d + assert hash(c) == hash(d) + + assert c != e + assert hash(c) != hash(e) diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000000..c10a41ef946 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1975 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "accessible-pygments" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c1/bbac6a50d02774f91572938964c582fff4270eee73ab822a4aeea4d8b11b/accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872", size = 1377899, upload-time = "2024-05-10T11:23:10.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7", size = 1395903, upload-time = "2024-05-10T11:23:08.421Z" }, +] + +[[package]] +name = "aiolimiter" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/23/b52debf471f7a1e42e362d959a3982bdcb4fe13a5d46e63d28868807a79c/aiolimiter-1.2.1.tar.gz", hash = "sha256:e02a37ea1a855d9e832252a105420ad4d15011505512a1a1d814647451b5cca9", size = 7185, upload-time = "2024-12-08T15:31:51.496Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/ba/df6e8e1045aebc4778d19b8a3a9bc1808adb1619ba94ca354d9ba17d86c3/aiolimiter-1.2.1-py3-none-any.whl", hash = "sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7", size = 6711, upload-time = "2024-12-08T15:31:49.874Z" }, +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "apscheduler" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/81/192db4f8471de5bc1f0d098783decffb1e6e69c4f8b4bc6711094691950b/apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221", size = 108044, upload-time = "2025-10-31T18:55:42.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/9f/d3c76f76c73fcc959d28e9def45b8b1cc3d7722660c5003b19c1022fd7f4/apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2", size = 64278, upload-time = "2025-10-31T18:55:41.186Z" }, +] + +[[package]] +name = "astroid" +version = "4.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/22/97df040e15d964e592d3a180598ace67e91b7c559d8298bdb3c949dc6e42/astroid-4.0.2.tar.gz", hash = "sha256:ac8fb7ca1c08eb9afec91ccc23edbd8ac73bb22cbdd7da1d488d9fb8d6579070", size = 405714, upload-time = "2025-11-09T21:21:18.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/ac/a85b4bfb4cf53221513e27f33cc37ad158fce02ac291d18bee6b49ab477d/astroid-4.0.2-py3-none-any.whl", hash = "sha256:d7546c00a12efc32650b19a2bb66a153883185d3179ab0d4868086f807338b9b", size = 276354, upload-time = "2025-11-09T21:21:16.54Z" }, +] + +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, +] + +[[package]] +name = "build" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/1c/23e33405a7c9eac261dff640926b8b5adaed6a6eb3e1767d441ed611d0c0/build-1.3.0.tar.gz", hash = "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397", size = 48544, upload-time = "2025-08-01T21:27:09.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/8c/2b30c12155ad8de0cf641d76a8b396a16d2c36bc6d50b621a62b7c4567c1/build-1.3.0-py3-none-any.whl", hash = "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4", size = 23382, upload-time = "2025-08-01T21:27:07.844Z" }, +] + +[[package]] +name = "cachetools" +version = "6.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "chango" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic-settings", marker = "python_full_version >= '3.12'" }, + { name = "shortuuid", marker = "python_full_version >= '3.12'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "tomlkit", marker = "python_full_version >= '3.12'" }, + { name = "typer", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/d2/bc136bce9f3cd8db1610cd7bd6b7039a723c8ffc550ee095e7b33a777d34/chango-0.6.0.tar.gz", hash = "sha256:004e279f7fe6683de25e0f3fff4e3ae25042a4a2c9a1980315cb65a7c7a7b55f", size = 342670, upload-time = "2025-10-15T18:46:21.842Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/dc/f7b359fc2af5873a42d92c00ecc8768fbd4b81e8888a26d0c49d0ac8694b/chango-0.6.0-py3-none-any.whl", hash = "sha256:f735ffaf77f32ca2d3098b199f60020aea41769640b79a0039f96aef1b8b193a", size = 56881, upload-time = "2025-10-15T18:46:20.261Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/95/c49df0aceb5507a80b9fe5172d3d39bf23f05be40c23c8d77d556df96cec/coverage-7.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb53f1e8adeeb2e78962bade0c08bfdc461853c7969706ed901821e009b35e31", size = 215800, upload-time = "2025-10-15T15:12:19.824Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c6/7bb46ce01ed634fff1d7bb53a54049f539971862cc388b304ff3c51b4f66/coverage-7.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9a03ec6cb9f40a5c360f138b88266fd8f58408d71e89f536b4f91d85721d075", size = 216198, upload-time = "2025-10-15T15:12:22.549Z" }, + { url = "https://files.pythonhosted.org/packages/94/b2/75d9d8fbf2900268aca5de29cd0a0fe671b0f69ef88be16767cc3c828b85/coverage-7.11.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d7f0616c557cbc3d1c2090334eddcbb70e1ae3a40b07222d62b3aa47f608fab", size = 242953, upload-time = "2025-10-15T15:12:24.139Z" }, + { url = "https://files.pythonhosted.org/packages/65/ac/acaa984c18f440170525a8743eb4b6c960ace2dbad80dc22056a437fc3c6/coverage-7.11.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e44a86a47bbdf83b0a3ea4d7df5410d6b1a0de984fbd805fa5101f3624b9abe0", size = 244766, upload-time = "2025-10-15T15:12:25.974Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0d/938d0bff76dfa4a6b228c3fc4b3e1c0e2ad4aa6200c141fcda2bd1170227/coverage-7.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:596763d2f9a0ee7eec6e643e29660def2eef297e1de0d334c78c08706f1cb785", size = 246625, upload-time = "2025-10-15T15:12:27.387Z" }, + { url = "https://files.pythonhosted.org/packages/38/54/8f5f5e84bfa268df98f46b2cb396b1009734cfb1e5d6adb663d284893b32/coverage-7.11.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef55537ff511b5e0a43edb4c50a7bf7ba1c3eea20b4f49b1490f1e8e0e42c591", size = 243568, upload-time = "2025-10-15T15:12:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/68/30/8ba337c2877fe3f2e1af0ed7ff4be0c0c4aca44d6f4007040f3ca2255e99/coverage-7.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cbabd8f4d0d3dc571d77ae5bdbfa6afe5061e679a9d74b6797c48d143307088", size = 244665, upload-time = "2025-10-15T15:12:30.297Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fb/c6f1d6d9a665536b7dde2333346f0cc41dc6a60bd1ffc10cd5c33e7eb000/coverage-7.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e24045453384e0ae2a587d562df2a04d852672eb63051d16096d3f08aa4c7c2f", size = 242681, upload-time = "2025-10-15T15:12:32.326Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/1b532319af5f991fa153c20373291dc65c2bf532af7dbcffdeef745c8f79/coverage-7.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7161edd3426c8d19bdccde7d49e6f27f748f3c31cc350c5de7c633fea445d866", size = 242912, upload-time = "2025-10-15T15:12:34.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/3d/f39331c60ef6050d2a861dc1b514fa78f85f792820b68e8c04196ad733d6/coverage-7.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d4ed4de17e692ba6415b0587bc7f12bc80915031fc9db46a23ce70fc88c9841", size = 243559, upload-time = "2025-10-15T15:12:35.809Z" }, + { url = "https://files.pythonhosted.org/packages/4b/55/cb7c9df9d0495036ce582a8a2958d50c23cd73f84a23284bc23bd4711a6f/coverage-7.11.0-cp310-cp310-win32.whl", hash = "sha256:765c0bc8fe46f48e341ef737c91c715bd2a53a12792592296a095f0c237e09cf", size = 218266, upload-time = "2025-10-15T15:12:37.429Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/b79cb275fa7bd0208767f89d57a1b5f6ba830813875738599741b97c2e04/coverage-7.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:24d6f3128f1b2d20d84b24f4074475457faedc3d4613a7e66b5e769939c7d969", size = 219169, upload-time = "2025-10-15T15:12:39.25Z" }, + { url = "https://files.pythonhosted.org/packages/49/3a/ee1074c15c408ddddddb1db7dd904f6b81bc524e01f5a1c5920e13dbde23/coverage-7.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d58ecaa865c5b9fa56e35efc51d1014d4c0d22838815b9fce57a27dd9576847", size = 215912, upload-time = "2025-10-15T15:12:40.665Z" }, + { url = "https://files.pythonhosted.org/packages/70/c4/9f44bebe5cb15f31608597b037d78799cc5f450044465bcd1ae8cb222fe1/coverage-7.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b679e171f1c104a5668550ada700e3c4937110dbdd153b7ef9055c4f1a1ee3cc", size = 216310, upload-time = "2025-10-15T15:12:42.461Z" }, + { url = "https://files.pythonhosted.org/packages/42/01/5e06077cfef92d8af926bdd86b84fb28bf9bc6ad27343d68be9b501d89f2/coverage-7.11.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca61691ba8c5b6797deb221a0d09d7470364733ea9c69425a640f1f01b7c5bf0", size = 246706, upload-time = "2025-10-15T15:12:44.001Z" }, + { url = "https://files.pythonhosted.org/packages/40/b8/7a3f1f33b35cc4a6c37e759137533119560d06c0cc14753d1a803be0cd4a/coverage-7.11.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aef1747ede4bd8ca9cfc04cc3011516500c6891f1b33a94add3253f6f876b7b7", size = 248634, upload-time = "2025-10-15T15:12:45.768Z" }, + { url = "https://files.pythonhosted.org/packages/7a/41/7f987eb33de386bc4c665ab0bf98d15fcf203369d6aacae74f5dd8ec489a/coverage-7.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1839d08406e4cba2953dcc0ffb312252f14d7c4c96919f70167611f4dee2623", size = 250741, upload-time = "2025-10-15T15:12:47.222Z" }, + { url = "https://files.pythonhosted.org/packages/23/c1/a4e0ca6a4e83069fb8216b49b30a7352061ca0cb38654bd2dc96b7b3b7da/coverage-7.11.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0eb0a2dcc62478eb5b4cbb80b97bdee852d7e280b90e81f11b407d0b81c4287", size = 246837, upload-time = "2025-10-15T15:12:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/5d/03/ced062a17f7c38b4728ff76c3acb40d8465634b20b4833cdb3cc3a74e115/coverage-7.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc1fbea96343b53f65d5351d8fd3b34fd415a2670d7c300b06d3e14a5af4f552", size = 248429, upload-time = "2025-10-15T15:12:50.73Z" }, + { url = "https://files.pythonhosted.org/packages/97/af/a7c6f194bb8c5a2705ae019036b8fe7f49ea818d638eedb15fdb7bed227c/coverage-7.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:214b622259dd0cf435f10241f1333d32caa64dbc27f8790ab693428a141723de", size = 246490, upload-time = "2025-10-15T15:12:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c3/aab4df02b04a8fde79068c3c41ad7a622b0ef2b12e1ed154da986a727c3f/coverage-7.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:258d9967520cca899695d4eb7ea38be03f06951d6ca2f21fb48b1235f791e601", size = 246208, upload-time = "2025-10-15T15:12:54.586Z" }, + { url = "https://files.pythonhosted.org/packages/30/d8/e282ec19cd658238d60ed404f99ef2e45eed52e81b866ab1518c0d4163cf/coverage-7.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cf9e6ff4ca908ca15c157c409d608da77a56a09877b97c889b98fb2c32b6465e", size = 247126, upload-time = "2025-10-15T15:12:56.485Z" }, + { url = "https://files.pythonhosted.org/packages/d1/17/a635fa07fac23adb1a5451ec756216768c2767efaed2e4331710342a3399/coverage-7.11.0-cp311-cp311-win32.whl", hash = "sha256:fcc15fc462707b0680cff6242c48625da7f9a16a28a41bb8fd7a4280920e676c", size = 218314, upload-time = "2025-10-15T15:12:58.365Z" }, + { url = "https://files.pythonhosted.org/packages/2a/29/2ac1dfcdd4ab9a70026edc8d715ece9b4be9a1653075c658ee6f271f394d/coverage-7.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:865965bf955d92790f1facd64fe7ff73551bd2c1e7e6b26443934e9701ba30b9", size = 219203, upload-time = "2025-10-15T15:12:59.902Z" }, + { url = "https://files.pythonhosted.org/packages/03/21/5ce8b3a0133179115af4c041abf2ee652395837cb896614beb8ce8ddcfd9/coverage-7.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:5693e57a065760dcbeb292d60cc4d0231a6d4b6b6f6a3191561e1d5e8820b745", size = 217879, upload-time = "2025-10-15T15:13:01.35Z" }, + { url = "https://files.pythonhosted.org/packages/c4/db/86f6906a7c7edc1a52b2c6682d6dd9be775d73c0dfe2b84f8923dfea5784/coverage-7.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9c49e77811cf9d024b95faf86c3f059b11c0c9be0b0d61bc598f453703bd6fd1", size = 216098, upload-time = "2025-10-15T15:13:02.916Z" }, + { url = "https://files.pythonhosted.org/packages/21/54/e7b26157048c7ba555596aad8569ff903d6cd67867d41b75287323678ede/coverage-7.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a61e37a403a778e2cda2a6a39abcc895f1d984071942a41074b5c7ee31642007", size = 216331, upload-time = "2025-10-15T15:13:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/b9/19/1ce6bf444f858b83a733171306134a0544eaddf1ca8851ede6540a55b2ad/coverage-7.11.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c79cae102bb3b1801e2ef1511fb50e91ec83a1ce466b2c7c25010d884336de46", size = 247825, upload-time = "2025-10-15T15:13:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/d3bcbbc259fcced5fb67c5d78f6e7ee965f49760c14afd931e9e663a83b2/coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893", size = 250573, upload-time = "2025-10-15T15:13:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/58/8d/b0ff3641a320abb047258d36ed1c21d16be33beed4152628331a1baf3365/coverage-7.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80027673e9d0bd6aef86134b0771845e2da85755cf686e7c7c59566cf5a89115", size = 251706, upload-time = "2025-10-15T15:13:09.4Z" }, + { url = "https://files.pythonhosted.org/packages/59/c8/5a586fe8c7b0458053d9c687f5cff515a74b66c85931f7fe17a1c958b4ac/coverage-7.11.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d3ffa07a08657306cd2215b0da53761c4d73cb54d9143b9303a6481ec0cd415", size = 248221, upload-time = "2025-10-15T15:13:10.964Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ff/3a25e3132804ba44cfa9a778cdf2b73dbbe63ef4b0945e39602fc896ba52/coverage-7.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3b6a5f8b2524fd6c1066bc85bfd97e78709bb5e37b5b94911a6506b65f47186", size = 249624, upload-time = "2025-10-15T15:13:12.5Z" }, + { url = "https://files.pythonhosted.org/packages/c5/12/ff10c8ce3895e1b17a73485ea79ebc1896a9e466a9d0f4aef63e0d17b718/coverage-7.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fcc0a4aa589de34bc56e1a80a740ee0f8c47611bdfb28cd1849de60660f3799d", size = 247744, upload-time = "2025-10-15T15:13:14.554Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/d500b91f5471b2975947e0629b8980e5e90786fe316b6d7299852c1d793d/coverage-7.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dba82204769d78c3fd31b35c3d5f46e06511936c5019c39f98320e05b08f794d", size = 247325, upload-time = "2025-10-15T15:13:16.438Z" }, + { url = "https://files.pythonhosted.org/packages/77/11/dee0284fbbd9cd64cfce806b827452c6df3f100d9e66188e82dfe771d4af/coverage-7.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:81b335f03ba67309a95210caf3eb43bd6fe75a4e22ba653ef97b4696c56c7ec2", size = 249180, upload-time = "2025-10-15T15:13:17.959Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/cdf1def928f0a150a057cab03286774e73e29c2395f0d30ce3d9e9f8e697/coverage-7.11.0-cp312-cp312-win32.whl", hash = "sha256:037b2d064c2f8cc8716fe4d39cb705779af3fbf1ba318dc96a1af858888c7bb5", size = 218479, upload-time = "2025-10-15T15:13:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/ff/55/e5884d55e031da9c15b94b90a23beccc9d6beee65e9835cd6da0a79e4f3a/coverage-7.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:d66c0104aec3b75e5fd897e7940188ea1892ca1d0235316bf89286d6a22568c0", size = 219290, upload-time = "2025-10-15T15:13:21.593Z" }, + { url = "https://files.pythonhosted.org/packages/23/a8/faa930cfc71c1d16bc78f9a19bb73700464f9c331d9e547bfbc1dbd3a108/coverage-7.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:d91ebeac603812a09cf6a886ba6e464f3bbb367411904ae3790dfe28311b15ad", size = 217924, upload-time = "2025-10-15T15:13:23.39Z" }, + { url = "https://files.pythonhosted.org/packages/60/7f/85e4dfe65e400645464b25c036a26ac226cf3a69d4a50c3934c532491cdd/coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1", size = 216129, upload-time = "2025-10-15T15:13:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be", size = 216380, upload-time = "2025-10-15T15:13:26.976Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f5/3da9cc9596708273385189289c0e4d8197d37a386bdf17619013554b3447/coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d", size = 247375, upload-time = "2025-10-15T15:13:28.923Z" }, + { url = "https://files.pythonhosted.org/packages/65/6c/f7f59c342359a235559d2bc76b0c73cfc4bac7d61bb0df210965cb1ecffd/coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82", size = 249978, upload-time = "2025-10-15T15:13:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8c/042dede2e23525e863bf1ccd2b92689692a148d8b5fd37c37899ba882645/coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52", size = 251253, upload-time = "2025-10-15T15:13:32.174Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/3c58df67bfa809a7bddd786356d9c5283e45d693edb5f3f55d0986dd905a/coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b", size = 247591, upload-time = "2025-10-15T15:13:34.147Z" }, + { url = "https://files.pythonhosted.org/packages/26/5b/c7f32efd862ee0477a18c41e4761305de6ddd2d49cdeda0c1116227570fd/coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4", size = 249411, upload-time = "2025-10-15T15:13:38.425Z" }, + { url = "https://files.pythonhosted.org/packages/76/b5/78cb4f1e86c1611431c990423ec0768122905b03837e1b4c6a6f388a858b/coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd", size = 247303, upload-time = "2025-10-15T15:13:40.464Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/23c753a8641a330f45f221286e707c427e46d0ffd1719b080cedc984ec40/coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc", size = 247157, upload-time = "2025-10-15T15:13:42.087Z" }, + { url = "https://files.pythonhosted.org/packages/c5/42/6e0cc71dc8a464486e944a4fa0d85bdec031cc2969e98ed41532a98336b9/coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48", size = 248921, upload-time = "2025-10-15T15:13:43.715Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1c/743c2ef665e6858cccb0f84377dfe3a4c25add51e8c7ef19249be92465b6/coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040", size = 218526, upload-time = "2025-10-15T15:13:45.336Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d5/226daadfd1bf8ddbccefbd3aa3547d7b960fb48e1bdac124e2dd13a2b71a/coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05", size = 219317, upload-time = "2025-10-15T15:13:47.401Z" }, + { url = "https://files.pythonhosted.org/packages/97/54/47db81dcbe571a48a298f206183ba8a7ba79200a37cd0d9f4788fcd2af4a/coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a", size = 217948, upload-time = "2025-10-15T15:13:49.096Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8b/cb68425420154e7e2a82fd779a8cc01549b6fa83c2ad3679cd6c088ebd07/coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b", size = 216837, upload-time = "2025-10-15T15:13:51.09Z" }, + { url = "https://files.pythonhosted.org/packages/33/55/9d61b5765a025685e14659c8d07037247de6383c0385757544ffe4606475/coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37", size = 217061, upload-time = "2025-10-15T15:13:52.747Z" }, + { url = "https://files.pythonhosted.org/packages/52/85/292459c9186d70dcec6538f06ea251bc968046922497377bf4a1dc9a71de/coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de", size = 258398, upload-time = "2025-10-15T15:13:54.45Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e2/46edd73fb8bf51446c41148d81944c54ed224854812b6ca549be25113ee0/coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f", size = 260574, upload-time = "2025-10-15T15:13:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/07/5e/1df469a19007ff82e2ca8fe509822820a31e251f80ee7344c34f6cd2ec43/coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c", size = 262797, upload-time = "2025-10-15T15:13:58.635Z" }, + { url = "https://files.pythonhosted.org/packages/f9/50/de216b31a1434b94d9b34a964c09943c6be45069ec704bfc379d8d89a649/coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa", size = 257361, upload-time = "2025-10-15T15:14:00.409Z" }, + { url = "https://files.pythonhosted.org/packages/82/1e/3f9f8344a48111e152e0fd495b6fff13cc743e771a6050abf1627a7ba918/coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740", size = 260349, upload-time = "2025-10-15T15:14:02.188Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/3f52741f9e7d82124272f3070bbe316006a7de1bad1093f88d59bfc6c548/coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef", size = 258114, upload-time = "2025-10-15T15:14:03.907Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8b/918f0e15f0365d50d3986bbd3338ca01178717ac5678301f3f547b6619e6/coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0", size = 256723, upload-time = "2025-10-15T15:14:06.324Z" }, + { url = "https://files.pythonhosted.org/packages/44/9e/7776829f82d3cf630878a7965a7d70cc6ca94f22c7d20ec4944f7148cb46/coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca", size = 259238, upload-time = "2025-10-15T15:14:08.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b8/49cf253e1e7a3bedb85199b201862dd7ca4859f75b6cf25ffa7298aa0760/coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2", size = 219180, upload-time = "2025-10-15T15:14:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e1/1a541703826be7ae2125a0fb7f821af5729d56bb71e946e7b933cc7a89a4/coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268", size = 220241, upload-time = "2025-10-15T15:14:11.471Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/5ee0e0a08621140fd418ec4020f595b4d52d7eb429ae6a0c6542b4ba6f14/coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836", size = 218510, upload-time = "2025-10-15T15:14:13.46Z" }, + { url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" }, + { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" }, + { url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" }, + { url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" }, + { url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" }, + { url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", size = 3689163, upload-time = "2025-10-15T23:18:13.821Z" }, + { url = "https://files.pythonhosted.org/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c", size = 3429474, upload-time = "2025-10-15T23:18:15.477Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, + { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, +] + +[[package]] +name = "dill" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "docutils" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444, upload-time = "2024-04-23T18:57:18.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408, upload-time = "2024-04-23T18:57:14.835Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/ff/b4c0dc78fbe20c3e59c0c7334de0c27eb4001a2b2017999af398bf730817/execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3", size = 166524, upload-time = "2024-04-08T09:04:19.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[package]] +name = "flaky" +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/c5/ef69119a01427204ff2db5fc8f98001087bcce719bbb94749dcd7b191365/flaky-3.8.1.tar.gz", hash = "sha256:47204a81ec905f3d5acfbd61daeabcada8f9d4031616d9bcb0618461729699f5", size = 25248, upload-time = "2024-03-12T22:17:59.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/b8/b830fc43663246c3f3dd1ae7dca4847b96ed992537e85311e27fa41ac40e/flaky-3.8.1-py2.py3-none-any.whl", hash = "sha256:194ccf4f0d3a22b2de7130f4b62e45e977ac1b5ccad74d4d48f3005dcc38815e", size = 19139, upload-time = "2024-03-12T22:17:51.59Z" }, +] + +[[package]] +name = "furo" +version = "2025.9.25" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accessible-pygments" }, + { name = "beautifulsoup4" }, + { name = "pygments" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-basic-ng" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/29/ff3b83a1ffce74676043ab3e7540d398e0b1ce7660917a00d7c4958b93da/furo-2025.9.25.tar.gz", hash = "sha256:3eac05582768fdbbc2bdfa1cdbcdd5d33cfc8b4bd2051729ff4e026a1d7e0a98", size = 1662007, upload-time = "2025-09-25T21:37:19.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/69/964b55f389c289e16ba2a5dfe587c3c462aac09e24123f09ddf703889584/furo-2025.9.25-py3-none-any.whl", hash = "sha256:2937f68e823b8e37b410c972c371bc2b1d88026709534927158e0cb3fac95afe", size = 340409, upload-time = "2025-09-25T21:37:17.244Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] +socks = [ + { name = "socksio" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753, upload-time = "2025-09-19T00:10:49.161Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, + { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types", marker = "python_full_version >= '3.12'" }, + { name = "pydantic-core", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "typing-inspection", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/3d/9b8ca77b0f76fcdbf8bc6b72474e264283f461284ca84ac3fde570c6c49a/pydantic_core-2.41.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2442d9a4d38f3411f22eb9dd0912b7cbf4b7d5b6c92c4173b75d3e1ccd84e36e", size = 2111197, upload-time = "2025-10-14T10:19:43.303Z" }, + { url = "https://files.pythonhosted.org/packages/59/92/b7b0fe6ed4781642232755cb7e56a86e2041e1292f16d9ae410a0ccee5ac/pydantic_core-2.41.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:30a9876226dda131a741afeab2702e2d127209bde3c65a2b8133f428bc5d006b", size = 1917909, upload-time = "2025-10-14T10:19:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/52/8c/3eb872009274ffa4fb6a9585114e161aa1a0915af2896e2d441642929fe4/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d55bbac04711e2980645af68b97d445cdbcce70e5216de444a6c4b6943ebcccd", size = 1969905, upload-time = "2025-10-14T10:19:46.567Z" }, + { url = "https://files.pythonhosted.org/packages/f4/21/35adf4a753bcfaea22d925214a0c5b880792e3244731b3f3e6fec0d124f7/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1d778fb7849a42d0ee5927ab0f7453bf9f85eef8887a546ec87db5ddb178945", size = 2051938, upload-time = "2025-10-14T10:19:48.237Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d0/cdf7d126825e36d6e3f1eccf257da8954452934ede275a8f390eac775e89/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b65077a4693a98b90ec5ad8f203ad65802a1b9b6d4a7e48066925a7e1606706", size = 2250710, upload-time = "2025-10-14T10:19:49.619Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1c/af1e6fd5ea596327308f9c8d1654e1285cc3d8de0d584a3c9d7705bf8a7c/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62637c769dee16eddb7686bf421be48dfc2fae93832c25e25bc7242e698361ba", size = 2367445, upload-time = "2025-10-14T10:19:51.269Z" }, + { url = "https://files.pythonhosted.org/packages/d3/81/8cece29a6ef1b3a92f956ea6da6250d5b2d2e7e4d513dd3b4f0c7a83dfea/pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dfe3aa529c8f501babf6e502936b9e8d4698502b2cfab41e17a028d91b1ac7b", size = 2072875, upload-time = "2025-10-14T10:19:52.671Z" }, + { url = "https://files.pythonhosted.org/packages/e3/37/a6a579f5fc2cd4d5521284a0ab6a426cc6463a7b3897aeb95b12f1ba607b/pydantic_core-2.41.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca2322da745bf2eeb581fc9ea3bbb31147702163ccbcbf12a3bb630e4bf05e1d", size = 2191329, upload-time = "2025-10-14T10:19:54.214Z" }, + { url = "https://files.pythonhosted.org/packages/ae/03/505020dc5c54ec75ecba9f41119fd1e48f9e41e4629942494c4a8734ded1/pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e8cd3577c796be7231dcf80badcf2e0835a46665eaafd8ace124d886bab4d700", size = 2151658, upload-time = "2025-10-14T10:19:55.843Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5d/2c0d09fb53aa03bbd2a214d89ebfa6304be7df9ed86ee3dc7770257f41ee/pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1cae8851e174c83633f0833e90636832857297900133705ee158cf79d40f03e6", size = 2316777, upload-time = "2025-10-14T10:19:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/ea/4b/c2c9c8f5e1f9c864b57d08539d9d3db160e00491c9f5ee90e1bfd905e644/pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a26d950449aae348afe1ac8be5525a00ae4235309b729ad4d3399623125b43c9", size = 2320705, upload-time = "2025-10-14T10:19:59.016Z" }, + { url = "https://files.pythonhosted.org/packages/28/c3/a74c1c37f49c0a02c89c7340fafc0ba816b29bd495d1a31ce1bdeacc6085/pydantic_core-2.41.4-cp310-cp310-win32.whl", hash = "sha256:0cf2a1f599efe57fa0051312774280ee0f650e11152325e41dfd3018ef2c1b57", size = 1975464, upload-time = "2025-10-14T10:20:00.581Z" }, + { url = "https://files.pythonhosted.org/packages/d6/23/5dd5c1324ba80303368f7569e2e2e1a721c7d9eb16acb7eb7b7f85cb1be2/pydantic_core-2.41.4-cp310-cp310-win_amd64.whl", hash = "sha256:a8c2e340d7e454dc3340d3d2e8f23558ebe78c98aa8f68851b04dcb7bc37abdc", size = 2024497, upload-time = "2025-10-14T10:20:03.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/4c/f6cbfa1e8efacd00b846764e8484fe173d25b8dab881e277a619177f3384/pydantic_core-2.41.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:28ff11666443a1a8cf2a044d6a545ebffa8382b5f7973f22c36109205e65dc80", size = 2109062, upload-time = "2025-10-14T10:20:04.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/f8/40b72d3868896bfcd410e1bd7e516e762d326201c48e5b4a06446f6cf9e8/pydantic_core-2.41.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61760c3925d4633290292bad462e0f737b840508b4f722247d8729684f6539ae", size = 1916301, upload-time = "2025-10-14T10:20:06.857Z" }, + { url = "https://files.pythonhosted.org/packages/94/4d/d203dce8bee7faeca791671c88519969d98d3b4e8f225da5b96dad226fc8/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eae547b7315d055b0de2ec3965643b0ab82ad0106a7ffd29615ee9f266a02827", size = 1968728, upload-time = "2025-10-14T10:20:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/6a66187775df87c24d526985b3a5d78d861580ca466fbd9d4d0e792fcf6c/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef9ee5471edd58d1fcce1c80ffc8783a650e3e3a193fe90d52e43bb4d87bff1f", size = 2050238, upload-time = "2025-10-14T10:20:09.766Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/78336345de97298cf53236b2f271912ce11f32c1e59de25a374ce12f9cce/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15dd504af121caaf2c95cb90c0ebf71603c53de98305621b94da0f967e572def", size = 2249424, upload-time = "2025-10-14T10:20:11.732Z" }, + { url = "https://files.pythonhosted.org/packages/99/bb/a4584888b70ee594c3d374a71af5075a68654d6c780369df269118af7402/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a926768ea49a8af4d36abd6a8968b8790f7f76dd7cbd5a4c180db2b4ac9a3a2", size = 2366047, upload-time = "2025-10-14T10:20:13.647Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8d/17fc5de9d6418e4d2ae8c675f905cdafdc59d3bf3bf9c946b7ab796a992a/pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916b9b7d134bff5440098a4deb80e4cb623e68974a87883299de9124126c2a8", size = 2071163, upload-time = "2025-10-14T10:20:15.307Z" }, + { url = "https://files.pythonhosted.org/packages/54/e7/03d2c5c0b8ed37a4617430db68ec5e7dbba66358b629cd69e11b4d564367/pydantic_core-2.41.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5cf90535979089df02e6f17ffd076f07237efa55b7343d98760bde8743c4b265", size = 2190585, upload-time = "2025-10-14T10:20:17.3Z" }, + { url = "https://files.pythonhosted.org/packages/be/fc/15d1c9fe5ad9266a5897d9b932b7f53d7e5cfc800573917a2c5d6eea56ec/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7533c76fa647fade2d7ec75ac5cc079ab3f34879626dae5689b27790a6cf5a5c", size = 2150109, upload-time = "2025-10-14T10:20:19.143Z" }, + { url = "https://files.pythonhosted.org/packages/26/ef/e735dd008808226c83ba56972566138665b71477ad580fa5a21f0851df48/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:37e516bca9264cbf29612539801ca3cd5d1be465f940417b002905e6ed79d38a", size = 2315078, upload-time = "2025-10-14T10:20:20.742Z" }, + { url = "https://files.pythonhosted.org/packages/90/00/806efdcf35ff2ac0f938362350cd9827b8afb116cc814b6b75cf23738c7c/pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0c19cb355224037c83642429b8ce261ae108e1c5fbf5c028bac63c77b0f8646e", size = 2318737, upload-time = "2025-10-14T10:20:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/41/7e/6ac90673fe6cb36621a2283552897838c020db343fa86e513d3f563b196f/pydantic_core-2.41.4-cp311-cp311-win32.whl", hash = "sha256:09c2a60e55b357284b5f31f5ab275ba9f7f70b7525e18a132ec1f9160b4f1f03", size = 1974160, upload-time = "2025-10-14T10:20:23.817Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9d/7c5e24ee585c1f8b6356e1d11d40ab807ffde44d2db3b7dfd6d20b09720e/pydantic_core-2.41.4-cp311-cp311-win_amd64.whl", hash = "sha256:711156b6afb5cb1cb7c14a2cc2c4a8b4c717b69046f13c6b332d8a0a8f41ca3e", size = 2021883, upload-time = "2025-10-14T10:20:25.48Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/5c172357460fc28b2871eb4a0fb3843b136b429c6fa827e4b588877bf115/pydantic_core-2.41.4-cp311-cp311-win_arm64.whl", hash = "sha256:6cb9cf7e761f4f8a8589a45e49ed3c0d92d1d696a45a6feaee8c904b26efc2db", size = 1968026, upload-time = "2025-10-14T10:20:27.039Z" }, + { url = "https://files.pythonhosted.org/packages/e9/81/d3b3e95929c4369d30b2a66a91db63c8ed0a98381ae55a45da2cd1cc1288/pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887", size = 2099043, upload-time = "2025-10-14T10:20:28.561Z" }, + { url = "https://files.pythonhosted.org/packages/58/da/46fdac49e6717e3a94fc9201403e08d9d61aa7a770fab6190b8740749047/pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2", size = 1910699, upload-time = "2025-10-14T10:20:30.217Z" }, + { url = "https://files.pythonhosted.org/packages/1e/63/4d948f1b9dd8e991a5a98b77dd66c74641f5f2e5225fee37994b2e07d391/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999", size = 1952121, upload-time = "2025-10-14T10:20:32.246Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a7/e5fc60a6f781fc634ecaa9ecc3c20171d238794cef69ae0af79ac11b89d7/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4", size = 2041590, upload-time = "2025-10-14T10:20:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/70/69/dce747b1d21d59e85af433428978a1893c6f8a7068fa2bb4a927fba7a5ff/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f", size = 2219869, upload-time = "2025-10-14T10:20:35.965Z" }, + { url = "https://files.pythonhosted.org/packages/83/6a/c070e30e295403bf29c4df1cb781317b6a9bac7cd07b8d3acc94d501a63c/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b", size = 2345169, upload-time = "2025-10-14T10:20:37.627Z" }, + { url = "https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47", size = 2070165, upload-time = "2025-10-14T10:20:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/14/0a/e567c2883588dd12bcbc110232d892cf385356f7c8a9910311ac997ab715/pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970", size = 2189067, upload-time = "2025-10-14T10:20:41.015Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1d/3d9fca34273ba03c9b1c5289f7618bc4bd09c3ad2289b5420481aa051a99/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed", size = 2132997, upload-time = "2025-10-14T10:20:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/52/70/d702ef7a6cd41a8afc61f3554922b3ed8d19dd54c3bd4bdbfe332e610827/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8", size = 2307187, upload-time = "2025-10-14T10:20:44.849Z" }, + { url = "https://files.pythonhosted.org/packages/68/4c/c06be6e27545d08b802127914156f38d10ca287a9e8489342793de8aae3c/pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431", size = 2305204, upload-time = "2025-10-14T10:20:46.781Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e5/35ae4919bcd9f18603419e23c5eaf32750224a89d41a8df1a3704b69f77e/pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd", size = 1972536, upload-time = "2025-10-14T10:20:48.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/49c5bb6d2a49eb2ee3647a93e3dae7080c6409a8a7558b075027644e879c/pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff", size = 2031132, upload-time = "2025-10-14T10:20:50.421Z" }, + { url = "https://files.pythonhosted.org/packages/06/23/936343dbcba6eec93f73e95eb346810fc732f71ba27967b287b66f7b7097/pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8", size = 1969483, upload-time = "2025-10-14T10:20:52.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" }, + { url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" }, + { url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" }, + { url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" }, + { url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" }, + { url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" }, + { url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" }, + { url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" }, + { url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" }, + { url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" }, + { url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" }, + { url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/d3325da57d413b9819365546eb9a6e8b7cbd9373d9380efd5f74326143e6/pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1", size = 2102022, upload-time = "2025-10-14T10:21:32.809Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/b58a1bc0d834bf1acc4361e61233ee217169a42efbdc15a60296e13ce438/pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac", size = 1905495, upload-time = "2025-10-14T10:21:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a4/71f759cc41b7043e8ecdaab81b985a9b6cad7cec077e0b92cff8b71ecf6b/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554", size = 1956131, upload-time = "2025-10-14T10:21:36.924Z" }, + { url = "https://files.pythonhosted.org/packages/b0/64/1e79ac7aa51f1eec7c4cda8cbe456d5d09f05fdd68b32776d72168d54275/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e", size = 2052236, upload-time = "2025-10-14T10:21:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e3/a3ffc363bd4287b80f1d43dc1c28ba64831f8dfc237d6fec8f2661138d48/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616", size = 2223573, upload-time = "2025-10-14T10:21:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/28/27/78814089b4d2e684a9088ede3790763c64693c3d1408ddc0a248bc789126/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af", size = 2342467, upload-time = "2025-10-14T10:21:44.018Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/4de0e2a1159cb85ad737e03306717637842c88c7fd6d97973172fb183149/pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12", size = 2063754, upload-time = "2025-10-14T10:21:46.466Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/8cb90ce4b9efcf7ae78130afeb99fd1c86125ccdf9906ef64b9d42f37c25/pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d", size = 2196754, upload-time = "2025-10-14T10:21:48.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/ccdc77af9cd5082723574a1cc1bcae7a6acacc829d7c0a06201f7886a109/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad", size = 2137115, upload-time = "2025-10-14T10:21:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ba/e7c7a02651a8f7c52dc2cff2b64a30c313e3b57c7d93703cecea76c09b71/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a", size = 2317400, upload-time = "2025-10-14T10:21:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ba/6c533a4ee8aec6b812c643c49bb3bd88d3f01e3cebe451bb85512d37f00f/pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025", size = 2312070, upload-time = "2025-10-14T10:21:55.419Z" }, + { url = "https://files.pythonhosted.org/packages/22/ae/f10524fcc0ab8d7f96cf9a74c880243576fd3e72bd8ce4f81e43d22bcab7/pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e", size = 1982277, upload-time = "2025-10-14T10:21:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/e5aa27aea1ad4638f0c3fb41132f7eb583bd7420ee63204e2d4333a3bbf9/pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894", size = 2024608, upload-time = "2025-10-14T10:21:59.557Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/51d89cc2612bd147198e120a13f150afbf0bcb4615cddb049ab10b81b79e/pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d", size = 1967614, upload-time = "2025-10-14T10:22:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c2/472f2e31b95eff099961fa050c376ab7156a81da194f9edb9f710f68787b/pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da", size = 1876904, upload-time = "2025-10-14T10:22:04.062Z" }, + { url = "https://files.pythonhosted.org/packages/4a/07/ea8eeb91173807ecdae4f4a5f4b150a520085b35454350fc219ba79e66a3/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e", size = 1882538, upload-time = "2025-10-14T10:22:06.39Z" }, + { url = "https://files.pythonhosted.org/packages/1e/29/b53a9ca6cd366bfc928823679c6a76c7a4c69f8201c0ba7903ad18ebae2f/pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa", size = 2041183, upload-time = "2025-10-14T10:22:08.812Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3d/f8c1a371ceebcaf94d6dd2d77c6cf4b1c078e13a5837aee83f760b4f7cfd/pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d", size = 1993542, upload-time = "2025-10-14T10:22:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, + { url = "https://files.pythonhosted.org/packages/b0/12/5ba58daa7f453454464f92b3ca7b9d7c657d8641c48e370c3ebc9a82dd78/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a1b2cfec3879afb742a7b0bcfa53e4f22ba96571c9e54d6a3afe1052d17d843b", size = 2122139, upload-time = "2025-10-14T10:22:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/21/fb/6860126a77725c3108baecd10fd3d75fec25191d6381b6eb2ac660228eac/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:d175600d975b7c244af6eb9c9041f10059f20b8bbffec9e33fdd5ee3f67cdc42", size = 1936674, upload-time = "2025-10-14T10:22:49.555Z" }, + { url = "https://files.pythonhosted.org/packages/de/be/57dcaa3ed595d81f8757e2b44a38240ac5d37628bce25fb20d02c7018776/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f184d657fa4947ae5ec9c47bd7e917730fa1cbb78195037e32dcbab50aca5ee", size = 1956398, upload-time = "2025-10-14T10:22:52.19Z" }, + { url = "https://files.pythonhosted.org/packages/2f/1d/679a344fadb9695f1a6a294d739fbd21d71fa023286daeea8c0ed49e7c2b/pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed810568aeffed3edc78910af32af911c835cc39ebbfacd1f0ab5dd53028e5c", size = 2138674, upload-time = "2025-10-14T10:22:54.499Z" }, + { url = "https://files.pythonhosted.org/packages/c4/48/ae937e5a831b7c0dc646b2ef788c27cd003894882415300ed21927c21efa/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537", size = 2112087, upload-time = "2025-10-14T10:22:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/5e/db/6db8073e3d32dae017da7e0d16a9ecb897d0a4d92e00634916e486097961/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94", size = 1920387, upload-time = "2025-10-14T10:22:59.342Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c1/dd3542d072fcc336030d66834872f0328727e3b8de289c662faa04aa270e/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c", size = 1951495, upload-time = "2025-10-14T10:23:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c6/db8d13a1f8ab3f1eb08c88bd00fd62d44311e3456d1e85c0e59e0a0376e7/pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335", size = 2139008, upload-time = "2025-10-14T10:23:04.539Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d4/912e976a2dd0b49f31c98a060ca90b353f3b73ee3ea2fd0030412f6ac5ec/pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1e5ab4fc177dd41536b3c32b2ea11380dd3d4619a385860621478ac2d25ceb00", size = 2106739, upload-time = "2025-10-14T10:23:06.934Z" }, + { url = "https://files.pythonhosted.org/packages/71/f0/66ec5a626c81eba326072d6ee2b127f8c139543f1bf609b4842978d37833/pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3d88d0054d3fa11ce936184896bed3c1c5441d6fa483b498fac6a5d0dd6f64a9", size = 1932549, upload-time = "2025-10-14T10:23:09.24Z" }, + { url = "https://files.pythonhosted.org/packages/c4/af/625626278ca801ea0a658c2dcf290dc9f21bb383098e99e7c6a029fccfc0/pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b2a054a8725f05b4b6503357e0ac1c4e8234ad3b0c2ac130d6ffc66f0e170e2", size = 2135093, upload-time = "2025-10-14T10:23:11.626Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/2fba049f54e0f4975fef66be654c597a1d005320fa141863699180c7697d/pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0d9db5a161c99375a0c68c058e227bee1d89303300802601d76a3d01f74e258", size = 2187971, upload-time = "2025-10-14T10:23:14.437Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/65ab839a2dfcd3b949202f9d920c34f9de5a537c3646662bdf2f7d999680/pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:6273ea2c8ffdac7b7fda2653c49682db815aebf4a89243a6feccf5e36c18c347", size = 2147939, upload-time = "2025-10-14T10:23:16.831Z" }, + { url = "https://files.pythonhosted.org/packages/44/58/627565d3d182ce6dfda18b8e1c841eede3629d59c9d7cbc1e12a03aeb328/pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:4c973add636efc61de22530b2ef83a65f39b6d6f656df97f678720e20de26caa", size = 2311400, upload-time = "2025-10-14T10:23:19.234Z" }, + { url = "https://files.pythonhosted.org/packages/24/06/8a84711162ad5a5f19a88cead37cca81b4b1f294f46260ef7334ae4f24d3/pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b69d1973354758007f46cf2d44a4f3d0933f10b6dc9bf15cf1356e037f6f731a", size = 2316840, upload-time = "2025-10-14T10:23:21.738Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8b/b7bb512a4682a2f7fbfae152a755d37351743900226d29bd953aaf870eaa/pydantic_core-2.41.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3619320641fd212aaf5997b6ca505e97540b7e16418f4a241f44cdf108ffb50d", size = 2149135, upload-time = "2025-10-14T10:23:24.379Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7d/138e902ed6399b866f7cfe4435d22445e16fff888a1c00560d9dc79a780f/pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:491535d45cd7ad7e4a2af4a5169b0d07bebf1adfd164b0368da8aa41e19907a5", size = 2104721, upload-time = "2025-10-14T10:23:26.906Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/0525623cf94627f7b53b4c2034c81edc8491cbfc7c28d5447fa318791479/pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:54d86c0cada6aba4ec4c047d0e348cbad7063b87ae0f005d9f8c9ad04d4a92a2", size = 1931608, upload-time = "2025-10-14T10:23:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f9/744bc98137d6ef0a233f808bfc9b18cf94624bf30836a18d3b05d08bf418/pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca1124aced216b2500dc2609eade086d718e8249cb9696660ab447d50a758bd", size = 2132986, upload-time = "2025-10-14T10:23:32.057Z" }, + { url = "https://files.pythonhosted.org/packages/17/c8/629e88920171173f6049386cc71f893dff03209a9ef32b4d2f7e7c264bcf/pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c9024169becccf0cb470ada03ee578d7348c119a0d42af3dcf9eda96e3a247c", size = 2187516, upload-time = "2025-10-14T10:23:34.871Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/4f2734688d98488782218ca61bcc118329bf5de05bb7fe3adc7dd79b0b86/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:26895a4268ae5a2849269f4991cdc97236e4b9c010e51137becf25182daac405", size = 2146146, upload-time = "2025-10-14T10:23:37.342Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f2/ab385dbd94a052c62224b99cf99002eee99dbec40e10006c78575aead256/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:ca4df25762cf71308c446e33c9b1fdca2923a3f13de616e2a949f38bf21ff5a8", size = 2311296, upload-time = "2025-10-14T10:23:40.145Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8e/e4f12afe1beeb9823bba5375f8f258df0cc61b056b0195fb1cf9f62a1a58/pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5a28fcedd762349519276c36634e71853b4541079cab4acaaac60c4421827308", size = 2315386, upload-time = "2025-10-14T10:23:42.624Z" }, + { url = "https://files.pythonhosted.org/packages/48/f7/925f65d930802e3ea2eb4d5afa4cb8730c8dc0d2cb89a59dc4ed2fcb2d74/pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f", size = 2147775, upload-time = "2025-10-14T10:23:45.406Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic", marker = "python_full_version >= '3.12'" }, + { name = "python-dotenv", marker = "python_full_version >= '3.12'" }, + { name = "typing-inspection", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pylint" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d2/b081da1a8930d00e3fc06352a1d449aaf815d4982319fab5d8cdb2e9ab35/pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2", size = 1571735, upload-time = "2025-11-30T13:29:04.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/92/d40f5d937517cc489ad848fc4414ecccc7592e4686b9071e09e64f5e378e/pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0", size = 536425, upload-time = "2025-11-30T13:29:02.53Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/53/57663d99acaac2fcdafdc697e52a9b1b7d6fcf36616281ff9768a44e7ff3/pytest_asyncio-0.21.2.tar.gz", hash = "sha256:d67738fc232b94b326b9d060750beb16e0074210b98dd8b58a5239fa2a154f45", size = 30656, upload-time = "2024-04-29T13:23:24.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/ce/1e4b53c213dce25d6e8b163697fbce2d43799d76fa08eea6ad270451c370/pytest_asyncio-0.21.2-py3-none-any.whl", hash = "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b", size = 13368, upload-time = "2024-04-29T13:23:23.126Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-telegram-bot" +source = { editable = "." } +dependencies = [ + { name = "httpcore", marker = "python_full_version >= '3.14'" }, + { name = "httpx" }, +] + +[package.optional-dependencies] +all = [ + { name = "aiolimiter" }, + { name = "apscheduler" }, + { name = "cachetools" }, + { name = "cffi", marker = "python_full_version >= '3.13'" }, + { name = "cryptography" }, + { name = "httpx", extra = ["http2", "socks"] }, + { name = "tornado" }, +] +callback-data = [ + { name = "cachetools" }, +] +ext = [ + { name = "aiolimiter" }, + { name = "apscheduler" }, + { name = "cachetools" }, + { name = "tornado" }, +] +http2 = [ + { name = "httpx", extra = ["http2"] }, +] +job-queue = [ + { name = "apscheduler" }, +] +passport = [ + { name = "cffi", marker = "python_full_version >= '3.13'" }, + { name = "cryptography" }, +] +rate-limiter = [ + { name = "aiolimiter" }, +] +socks = [ + { name = "httpx", extra = ["socks"] }, +] +webhooks = [ + { name = "tornado" }, +] + +[package.dev-dependencies] +all = [ + { name = "beautifulsoup4" }, + { name = "build" }, + { name = "chango", marker = "python_full_version >= '3.12'" }, + { name = "flaky" }, + { name = "furo" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pydantic", marker = "python_full_version >= '3.14'" }, + { name = "pylint" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-xdist" }, + { name = "pytz" }, + { name = "ruff" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-build-compatibility" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-inline-tabs" }, + { name = "sphinx-paramlinks" }, + { name = "sphinxcontrib-mermaid" }, + { name = "tzdata" }, +] +docs = [ + { name = "chango", marker = "python_full_version >= '3.12'" }, + { name = "furo" }, + { name = "pydantic", marker = "python_full_version >= '3.14'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "sphinx-build-compatibility" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-inline-tabs" }, + { name = "sphinx-paramlinks" }, + { name = "sphinxcontrib-mermaid" }, +] +linting = [ + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pylint" }, + { name = "ruff" }, +] +tests = [ + { name = "beautifulsoup4" }, + { name = "build" }, + { name = "flaky" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-xdist" }, + { name = "pytz" }, + { name = "tzdata" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiolimiter", marker = "extra == 'all'", specifier = ">=1.1,<1.3" }, + { name = "aiolimiter", marker = "extra == 'ext'", specifier = ">=1.1,<1.3" }, + { name = "aiolimiter", marker = "extra == 'rate-limiter'", specifier = ">=1.1,<1.3" }, + { name = "apscheduler", marker = "extra == 'all'", specifier = ">=3.10.4,<3.12.0" }, + { name = "apscheduler", marker = "extra == 'ext'", specifier = ">=3.10.4,<3.12.0" }, + { name = "apscheduler", marker = "extra == 'job-queue'", specifier = ">=3.10.4,<3.12.0" }, + { name = "cachetools", marker = "extra == 'all'", specifier = ">=5.3.3,<6.3.0" }, + { name = "cachetools", marker = "extra == 'callback-data'", specifier = ">=5.3.3,<6.3.0" }, + { name = "cachetools", marker = "extra == 'ext'", specifier = ">=5.3.3,<6.3.0" }, + { name = "cffi", marker = "python_full_version >= '3.13' and extra == 'all'", specifier = ">=1.17.0rc1" }, + { name = "cffi", marker = "python_full_version >= '3.13' and extra == 'passport'", specifier = ">=1.17.0rc1" }, + { name = "cryptography", marker = "extra == 'all'", specifier = "!=3.4,!=3.4.1,!=3.4.2,!=3.4.3,>=39.0.1" }, + { name = "cryptography", marker = "extra == 'passport'", specifier = "!=3.4,!=3.4.1,!=3.4.2,!=3.4.3,>=39.0.1" }, + { name = "httpcore", marker = "python_full_version >= '3.14'", specifier = ">=1.0.9" }, + { name = "httpx", specifier = ">=0.27,<0.29" }, + { name = "httpx", extras = ["http2"], marker = "extra == 'all'" }, + { name = "httpx", extras = ["http2"], marker = "extra == 'http2'" }, + { name = "httpx", extras = ["socks"], marker = "extra == 'all'" }, + { name = "httpx", extras = ["socks"], marker = "extra == 'socks'" }, + { name = "tornado", marker = "extra == 'all'", specifier = "~=6.5" }, + { name = "tornado", marker = "extra == 'ext'", specifier = "~=6.5" }, + { name = "tornado", marker = "extra == 'webhooks'", specifier = "~=6.5" }, +] +provides-extras = ["all", "callback-data", "ext", "http2", "job-queue", "passport", "rate-limiter", "socks", "webhooks"] + +[package.metadata.requires-dev] +all = [ + { name = "beautifulsoup4" }, + { name = "build" }, + { name = "chango", marker = "python_full_version >= '3.12'", specifier = "~=0.6.0" }, + { name = "flaky", specifier = ">=3.8.1" }, + { name = "furo", specifier = "==2025.9.25" }, + { name = "mypy", specifier = "==1.18.2" }, + { name = "pre-commit" }, + { name = "pydantic", marker = "python_full_version >= '3.14'", specifier = ">=2.12.0a1" }, + { name = "pylint", specifier = "==4.0.4" }, + { name = "pytest", specifier = "==9.0.2" }, + { name = "pytest-asyncio", specifier = "==0.21.2" }, + { name = "pytest-cov" }, + { name = "pytest-xdist", specifier = "==3.8.0" }, + { name = "pytz" }, + { name = "ruff", specifier = "==0.14.14" }, + { name = "sphinx", marker = "python_full_version >= '3.11'", specifier = "==8.2.3" }, + { name = "sphinx-build-compatibility", git = "https://github.com/readthedocs/sphinx-build-compatibility.git?rev=58aabc5f207c6c2421f23d3578adc0b14af57047" }, + { name = "sphinx-copybutton", specifier = "==0.5.2" }, + { name = "sphinx-inline-tabs", specifier = "==2023.4.21" }, + { name = "sphinx-paramlinks", specifier = "==0.6.0" }, + { name = "sphinxcontrib-mermaid", specifier = "==1.0.0" }, + { name = "tzdata" }, +] +docs = [ + { name = "chango", marker = "python_full_version >= '3.12'", specifier = "~=0.6.0" }, + { name = "furo", specifier = "==2025.9.25" }, + { name = "pydantic", marker = "python_full_version >= '3.14'", specifier = ">=2.12.0a1" }, + { name = "sphinx", marker = "python_full_version >= '3.11'", specifier = "==8.2.3" }, + { name = "sphinx-build-compatibility", git = "https://github.com/readthedocs/sphinx-build-compatibility.git?rev=58aabc5f207c6c2421f23d3578adc0b14af57047" }, + { name = "sphinx-copybutton", specifier = "==0.5.2" }, + { name = "sphinx-inline-tabs", specifier = "==2023.4.21" }, + { name = "sphinx-paramlinks", specifier = "==0.6.0" }, + { name = "sphinxcontrib-mermaid", specifier = "==1.0.0" }, +] +linting = [ + { name = "mypy", specifier = "==1.18.2" }, + { name = "pre-commit" }, + { name = "pylint", specifier = "==4.0.4" }, + { name = "ruff", specifier = "==0.14.14" }, +] +tests = [ + { name = "beautifulsoup4" }, + { name = "build" }, + { name = "flaky", specifier = ">=3.8.1" }, + { name = "pytest", specifier = "==9.0.2" }, + { name = "pytest-asyncio", specifier = "==0.21.2" }, + { name = "pytest-cov" }, + { name = "pytest-xdist", specifier = "==3.8.0" }, + { name = "pytz" }, + { name = "tzdata" }, +] + +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "roman-numerals-py" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/76/48fd56d17c5bdbdf65609abbc67288728a98ed4c02919428d4f52d23b24b/roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d", size = 9017, upload-time = "2025-02-22T07:34:54.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/97/d2cbbaa10c9b826af0e10fdf836e1bf344d9f0abb873ebc34d1f49642d3f/roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c", size = 7742, upload-time = "2025-02-22T07:34:52.422Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "shortuuid" +version = "1.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/e2/bcf761f3bff95856203f9559baf3741c416071dd200c0fc19fad7f078f86/shortuuid-1.0.13.tar.gz", hash = "sha256:3bb9cf07f606260584b1df46399c0b87dd84773e7b25912b7e391e30797c5e72", size = 9662, upload-time = "2024-03-11T20:11:06.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/44/21d6bf170bf40b41396480d8d49ad640bca3f2b02139cd52aa1e272830a5/shortuuid-1.0.13-py3-none-any.whl", hash = "sha256:a482a497300b49b4953e15108a7913244e1bb0d41f9d332f5e9925dba33a3c5a", size = 10529, upload-time = "2024-03-11T20:11:04.807Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "snowballstemmer" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" }, +] + +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, +] + +[[package]] +name = "sphinx" +version = "8.1.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/60/1ddff83a56d33aaf6f10ec8ce84b4c007d9368b21008876fceda7e7381ef/sphinx-8.1.3-py3-none-any.whl", hash = "sha256:09719015511837b76bf6e03e42eb7595ac8c2e41eeb9c29c5b755c6b677992a2", size = 3487125, upload-time = "2024-10-13T20:27:10.448Z" }, +] + +[[package]] +name = "sphinx" +version = "8.2.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.11'" }, + { name = "babel", marker = "python_full_version >= '3.11'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version >= '3.11'" }, + { name = "imagesize", marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "requests", marker = "python_full_version >= '3.11'" }, + { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3", size = 3589741, upload-time = "2025-03-02T22:31:56.836Z" }, +] + +[[package]] +name = "sphinx-basic-ng" +version = "1.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736, upload-time = "2023-07-08T18:40:54.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/dd/018ce05c532a22007ac58d4f45232514cd9d6dd0ee1dc374e309db830983/sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b", size = 22496, upload-time = "2023-07-08T18:40:52.659Z" }, +] + +[[package]] +name = "sphinx-build-compatibility" +version = "0.0.1" +source = { git = "https://github.com/readthedocs/sphinx-build-compatibility.git?rev=58aabc5f207c6c2421f23d3578adc0b14af57047#58aabc5f207c6c2421f23d3578adc0b14af57047" } +dependencies = [ + { name = "requests" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[[package]] +name = "sphinx-copybutton" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" }, +] + +[[package]] +name = "sphinx-inline-tabs" +version = "2023.4.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/f5/f8a2be63ed7be9f91a4c2bea0e25bcb56aa4c5cc37ec4d8ead8065f926b1/sphinx_inline_tabs-2023.4.21.tar.gz", hash = "sha256:5df2f13f602c158f3f5f6c509e008aeada199a8c76d97ba3aa2822206683bebc", size = 42664, upload-time = "2023-04-21T20:25:30.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/60/1e4c9017d722b9c7731abc11f39ac8b083b479fbcefe12015b57e457a296/sphinx_inline_tabs-2023.4.21-py3-none-any.whl", hash = "sha256:06809ac613f7c48ddd6e2fa588413e3fe92cff2397b56e2ccf0b0218f9ef6a78", size = 6850, upload-time = "2023-04-21T20:25:28.778Z" }, +] + +[[package]] +name = "sphinx-paramlinks" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/21/62d3a58ff7bd02bbb9245a63d1f0d2e0455522a11a78951d16088569fca8/sphinx-paramlinks-0.6.0.tar.gz", hash = "sha256:746a0816860aa3fff5d8d746efcbec4deead421f152687411db1d613d29f915e", size = 12363, upload-time = "2023-08-11T16:09:28.604Z" } + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-mermaid" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/69/bf039237ad260073e8c02f820b3e00dc34f3a2de20aff7861e6b19d2f8c5/sphinxcontrib_mermaid-1.0.0.tar.gz", hash = "sha256:2e8ab67d3e1e2816663f9347d026a8dee4a858acdd4ad32dd1c808893db88146", size = 15153, upload-time = "2024-10-12T16:33:03.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/c8/784b9ac6ea08aa594c1a4becbd0dbe77186785362e31fd633b8c6ae0197a/sphinxcontrib_mermaid-1.0.0-py3-none-any.whl", hash = "sha256:60b72710ea02087f212028feb09711225fbc2e343a10d34822fe787510e1caa3", size = 9597, upload-time = "2024-10-12T16:33:02.303Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" }, + { url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" }, + { url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" }, + { url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" }, +] + +[[package]] +name = "typer" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", marker = "python_full_version >= '3.12'" }, + { name = "rich", marker = "python_full_version >= '3.12'" }, + { name = "shellingham", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]