diff --git a/.github/CONTRIBUTING.rst b/.github/CONTRIBUTING.rst index b64a368ac43..c4708371ff8 100644 --- a/.github/CONTRIBUTING.rst +++ b/.github/CONTRIBUTING.rst @@ -22,12 +22,14 @@ 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. .. code-block:: bash - $ pip install -r requirements-dev-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: @@ -157,45 +159,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``, ``.. 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 `__ -- 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 `_ -- Created a PR to remove functionality deprecated in the previous Bot API release (`see here `_) + - [ ] 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`` (including the badge) and ``telegram.constants.BOT_API_VERSION_INFO`` - - Added logic for arbitrary callback data in :class:`telegram.ext.ExtBot` for new methods that either accept a ``reply_markup`` in some form or have a return type that is/contains :class:`~telegram.Message` + - [ ] 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 =========== 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..8010ccec486 --- /dev/null +++ b/.github/workflows/chango.yml @@ -0,0 +1,66 @@ +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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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@9d6bd9d7612eca5fab2c5161687011be59baaf19 # v0.4.0 + with: + 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@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.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@b863ae1933cb653a53c021fe36dbb774e1fb9403 # v5.2.0 + with: + commit_message: "Do chango Release" + repository: ./target-repo diff --git a/.github/workflows/docs-admonitions.yml b/.github/workflows/docs-admonitions.yml index 90ab6f8e97c..c408cad2662 100644 --- a/.github/workflows/docs-admonitions.yml +++ b/.github/workflows/docs-admonitions.yml @@ -2,7 +2,7 @@ name: Test Admonitions Generation on: pull_request: paths: - - telegram/** + - src/telegram/** - docs/** - .github/workflows/docs-admonitions.yml push: @@ -20,7 +20,7 @@ jobs: actions: write strategy: matrix: - python-version: ['3.10'] + python-version: ['3.12'] os: [ubuntu-latest] fail-fast: False steps: @@ -28,14 +28,14 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0 with: python-version: ${{ matrix.python-version }} cache: 'pip' - cache-dependency-path: '**/requirements*.txt' + cache-dependency-path: 'pyproject.toml' - name: Install dependencies run: | python -W ignore -m pip install --upgrade pip - python -W ignore -m pip install -r requirements-dev-all.txt + 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 d4b28122840..f87424bd74b 100644 --- a/.github/workflows/docs-linkcheck.yml +++ b/.github/workflows/docs-linkcheck.yml @@ -15,7 +15,7 @@ jobs: runs-on: ${{matrix.os}} strategy: matrix: - python-version: ['3.10'] + python-version: ['3.12'] os: [ubuntu-latest] fail-fast: False steps: @@ -23,19 +23,19 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.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-dev-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@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: linkcheck-output path: docs/build/html/output.* diff --git a/.github/workflows/gha_security.yml b/.github/workflows/gha_security.yml index bd7a8fafe1b..df0d0f10bb5 100644 --- a/.github/workflows/gha_security.yml +++ b/.github/workflows/gha_security.yml @@ -21,13 +21,13 @@ jobs: with: persist-credentials: false - name: Install the latest version of uv - uses: astral-sh/setup-uv@4db96194c378173c656ce18a155ffc14a9fc4355 # v5.2.2 + uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.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@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 + uses: github/codeql-action/upload-sarif@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16 with: sarif_file: results.sarif category: zizmor \ No newline at end of file diff --git a/.github/workflows/labelling.yml b/.github/workflows/labelling.yml index 24b01a71dfc..21a4d6733ba 100644 --- a/.github/workflows/labelling.yml +++ b/.github/workflows/labelling.yml @@ -13,7 +13,7 @@ jobs: pull-requests: write # for srvaroa/labeler to add labels in PR runs-on: ubuntu-latest steps: - - uses: srvaroa/labeler@fe4b1c73bb8abf2f14a44a6912a8b4fee835d631 # v1.12.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/release_pypi.yml b/.github/workflows/release_pypi.yml index 73ae5d8c7eb..ab4ea06a4fe 100644 --- a/.github/workflows/release_pypi.yml +++ b/.github/workflows/release_pypi.yml @@ -21,7 +21,7 @@ jobs: with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0 with: python-version: "3.x" - name: Install pypa/build @@ -30,7 +30,7 @@ jobs: - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: python-package-distributions path: dist/ @@ -55,12 +55,12 @@ jobs: steps: - name: Download all the dists - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: python-package-distributions path: dist/ - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@67339c736fd9354cd4f8cb0b744f2b82a74b5c70 # v1.12.3 + uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 compute-signatures: name: Compute SHA1 Sums and Sign with Sigstore @@ -74,7 +74,7 @@ jobs: steps: - name: Download all the dists - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: python-package-distributions path: dist/ @@ -92,7 +92,7 @@ jobs: ./dist/*.tar.gz ./dist/*.whl - name: Store the distribution packages and signatures - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: python-package-distributions-and-signatures path: dist/ @@ -110,8 +110,11 @@ jobs: actions: read # for downloading artifacts steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Download all the dists - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: python-package-distributions-and-signatures path: dist/ @@ -119,13 +122,14 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} TAG: ${{ needs.build.outputs.TAG }} - # Create a tag and a GitHub Release. The description can be changed later, as for now - # we don't define it through this workflow. + # 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 }}' - --generate-notes + --notes-file - - name: Upload artifact signatures to GitHub Release env: GITHUB_TOKEN: ${{ github.token }} @@ -137,3 +141,32 @@ jobs: 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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 index b50ee80a4b1..a59baec5e67 100644 --- a/.github/workflows/release_test_pypi.yml +++ b/.github/workflows/release_test_pypi.yml @@ -21,7 +21,7 @@ jobs: with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0 with: python-version: "3.x" - name: Install pypa/build @@ -30,7 +30,7 @@ jobs: - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: python-package-distributions path: dist/ @@ -55,12 +55,12 @@ jobs: steps: - name: Download all the dists - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: python-package-distributions path: dist/ - name: Publish to Test PyPI - uses: pypa/gh-action-pypi-publish@67339c736fd9354cd4f8cb0b744f2b82a74b5c70 # v1.12.3 + uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 with: repository-url: https://test.pypi.org/legacy/ @@ -76,7 +76,7 @@ jobs: steps: - name: Download all the dists - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: python-package-distributions path: dist/ @@ -94,7 +94,7 @@ jobs: ./dist/*.tar.gz ./dist/*.whl - name: Store the distribution packages and signatures - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: python-package-distributions-and-signatures path: dist/ @@ -112,8 +112,11 @@ jobs: actions: read # for downloading artifacts steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Download all the dists - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: python-package-distributions-and-signatures path: dist/ @@ -121,14 +124,15 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} TAG: ${{ needs.build.outputs.TAG }} - # Create a GitHub Release *draft*. The description can be changed later, as for now - # we don't define it through this workflow. + # 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 }}' - --generate-notes --draft + --notes-file - - name: Upload artifact signatures to GitHub Release env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/test_official.yml b/.github/workflows/test_official.yml index 6eae5e4bcf6..692bacd8ab7 100644 --- a/.github/workflows/test_official.yml +++ b/.github/workflows/test_official.yml @@ -2,7 +2,7 @@ name: Bot API Tests on: pull_request: paths: - - telegram/** + - src/telegram/** - tests/** push: branches: @@ -27,14 +27,13 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.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] - python -W ignore -m pip install -r requirements-unit-tests.txt + 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 diff --git a/.github/workflows/type_completeness.yml b/.github/workflows/type_completeness.yml index 3b3f30e4873..56b57f5e539 100644 --- a/.github/workflows/type_completeness.yml +++ b/.github/workflows/type_completeness.yml @@ -2,7 +2,7 @@ name: Check Type Completeness on: pull_request: paths: - - telegram/** + - src/telegram/** - pyproject.toml - .github/workflows/type_completeness.yml push: diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index fd914bf91b4..012468d1126 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -2,11 +2,10 @@ name: Unit Tests on: pull_request: paths: - - telegram/** + - src/telegram/** - tests/** - .github/workflows/unit_tests.yml - pyproject.toml - - requirements-unit-tests.txt push: branches: - master @@ -30,18 +29,15 @@ jobs: with: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 + uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.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 . - python -W ignore -m pip install -r requirements-unit-tests.txt - python -W ignore -m pip install pytest-xdist + python -W ignore -m pip install . --group tests - name: Test with pytest # We run 4 different suites here @@ -92,14 +88,14 @@ jobs: .test_report_optionals_junit.xml - name: Submit coverage - uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2 + uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2 with: env_vars: OS,PYTHON name: ${{ matrix.os }}-${{ matrix.python-version }} fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} - name: Upload test results to Codecov - uses: codecov/test-results-action@4e79e65778be1cecd5df25e14af1eafb6df80ea9 # v1.0.2 + uses: codecov/test-results-action@f2dba722c67b86c6caa034178c6e4d35335f6706 # v1.1.0 if: ${{ !cancelled() }} with: files: .test_report_no_optionals_junit.xml,.test_report_optionals_junit.xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6b56f457ed3..a6002c846bf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.8.6' + rev: 'v0.11.9' hooks: - id: ruff name: ruff @@ -18,18 +18,18 @@ repos: - cachetools>=5.3.3,<5.5.0 - aiolimiter~=1.1,<1.3 - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.10.0 + rev: 25.1.0 hooks: - id: black args: - --diff - --check - repo: https://github.com/PyCQA/flake8 - rev: 7.1.1 + rev: 7.2.0 hooks: - id: flake8 - repo: https://github.com/PyCQA/pylint - rev: v3.3.3 + rev: v3.3.6 hooks: - id: pylint files: ^(?!(tests|docs)).*\.py$ @@ -41,7 +41,7 @@ repos: - aiolimiter~=1.1,<1.3 - . # this basically does `pip install -e .` - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.14.1 + rev: v1.15.0 hooks: - id: mypy name: mypy-ptb @@ -74,7 +74,7 @@ repos: args: - --py39-plus - repo: https://github.com/pycqa/isort - rev: 5.13.2 + rev: 6.0.1 hooks: - id: isort name: isort diff --git a/.readthedocs.yml b/.readthedocs.yml index 11075b0fe2b..6d89b823dba 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -18,13 +18,16 @@ python: install: - method: pip path: . - - requirements: requirements-dev-all.txt build: os: ubuntu-22.04 tools: python: "3" # latest stable cpython version jobs: + install: + - pip install -U pip + - pip install .[all] --group 'all' # install all the 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 diff --git a/AUTHORS.rst b/AUTHORS.rst index 98e5b686de1..61535397919 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,7 +19,7 @@ Contributors The following wonderful people contributed directly or indirectly to this project: -- `Abdelrahman `_ +- `Aditya `_ - `Abshar `_ - `Abubakar Alaya `_ - `Alateas `_ @@ -42,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 `_ @@ -122,6 +121,7 @@ The following wonderful people contributed directly or indirectly to this projec - `Sam Mosleh `_ - `Sascha `_ - `Shelomentsev D `_ +- `Shivam `_ - `Shivam Saini `_ - `Siloé Garcez `_ - `Simon Schürrle `_ diff --git a/README.rst b/README.rst index d19a93d4d3f..633dc383ad7 100644 --- a/README.rst +++ b/README.rst @@ -11,7 +11,7 @@ :target: https://pypi.org/project/python-telegram-bot/ :alt: Supported Python versions -.. image:: https://img.shields.io/badge/Bot%20API-8.3-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-9.0-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog :alt: Supported Bot API version @@ -19,7 +19,7 @@ :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 @@ -81,7 +81,7 @@ After installing_ the library, be sure to check out the section on `working with Telegram API support ~~~~~~~~~~~~~~~~~~~~ -All types and methods of the Telegram Bot API **8.3** are natively supported by this library. +All types and methods of the Telegram Bot API **9.0** are natively supported by this library. In addition, Bot API functionality not yet natively included can still be used as described `in our wiki `_. Notable Features 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.rst b/changes/LEGACY.rst similarity index 99% rename from CHANGES.rst rename to changes/LEGACY.rst index aea00e464b0..81c4205cc29 100644 --- a/CHANGES.rst +++ b/changes/LEGACY.rst @@ -1,9 +1,3 @@ -.. _ptb-changelog: - -========= -Changelog -========= - Version 21.11.1 =============== diff --git a/changes/config.py b/changes/config.py new file mode 100644 index 00000000000..1fd95fa9767 --- /dev/null +++ b/changes/config.py @@ -0,0 +1,105 @@ +# noqa: INP001 +# pylint: disable=import-error +"""Configuration for the chango changelog tool""" + +import re +from collections.abc import Collection +from pathlib import Path +from typing import Optional + +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: Optional[Collection[str]], + ) -> 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 / "telegram" + 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/4792.YsK6LmbEhZv6y3dvhHbXD7.toml b/changes/unreleased/4792.YsK6LmbEhZv6y3dvhHbXD7.toml new file mode 100644 index 00000000000..675c2904b4d --- /dev/null +++ b/changes/unreleased/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/unreleased/4793.mDR5p3mrSmPFQkvWFGWBmD.toml b/changes/unreleased/4793.mDR5p3mrSmPFQkvWFGWBmD.toml new file mode 100644 index 00000000000..7a6ca4c3e95 --- /dev/null +++ b/changes/unreleased/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/unreleased/4798.g7G3jRf2ns4ath9LRFEcit.toml b/changes/unreleased/4798.g7G3jRf2ns4ath9LRFEcit.toml new file mode 100644 index 00000000000..c238ebdfc67 --- /dev/null +++ b/changes/unreleased/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/unreleased/4800.2Z9Q8uvzdU2TqMJ9biBLam.toml b/changes/unreleased/4800.2Z9Q8uvzdU2TqMJ9biBLam.toml new file mode 100644 index 00000000000..4f670da8bd0 --- /dev/null +++ b/changes/unreleased/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/docs/auxil/kwargs_insertion.py b/docs/auxil/kwargs_insertion.py index 02fc39c040a..e24003f1d71 100644 --- a/docs/auxil/kwargs_insertion.py +++ b/docs/auxil/kwargs_insertion.py @@ -47,29 +47,29 @@ "", ] -media_write_timeout_deprecation_methods = [ - "send_photo", +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_animation", "send_voice", - "send_media_group", "set_chat_photo", "upload_sticker_file", - "add_sticker_to_set", - "create_new_sticker_set", ] -media_write_timeout_deprecation = [ +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." "", "", - " .. deprecated:: 20.7", - " In future versions, the default value will be changed to " + " .. versionchanged:: 22.0", + " The default value changed to " " :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`.", "", "", diff --git a/docs/auxil/sphinx_hooks.py b/docs/auxil/sphinx_hooks.py index cdd48e42d38..47fd9c9281c 100644 --- a/docs/auxil/sphinx_hooks.py +++ b/docs/auxil/sphinx_hooks.py @@ -15,12 +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 @@ -32,11 +32,15 @@ find_insert_pos_for_kwargs, get_updates_read_timeout_addition, keyword_args, - media_write_timeout_deprecation, - media_write_timeout_deprecation_methods, + 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 @@ -116,9 +120,9 @@ def autodoc_process_docstring( if ( "post.write_timeout`. Defaults to" in to_insert - and method_name in media_write_timeout_deprecation_methods + and method_name in media_write_timeout_change_methods ): - effective_insert: list[str] = media_write_timeout_deprecation + 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: @@ -128,7 +132,7 @@ def autodoc_process_docstring( 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, ) @@ -136,7 +140,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, ) diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt deleted file mode 100644 index ab62b887813..00000000000 --- a/docs/requirements-docs.txt +++ /dev/null @@ -1,9 +0,0 @@ -sphinx==8.1.3 -furo==2024.8.6 -furo-sphinx-search @ git+https://github.com/harshil21/furo-sphinx-search@v0.2.0.1 -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 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 b00ba68e204..a0352d2c509 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -8,6 +8,11 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. from sphinx.application import Sphinx +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 ------------------------------------------------ @@ -36,6 +41,7 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ + "chango.sphinx_ext", "sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx.ext.intersphinx", @@ -52,6 +58,9 @@ 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"), @@ -116,6 +125,8 @@ # 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 diff --git a/docs/source/examples.rst b/docs/source/examples.rst index 53815770685..ce87c73450e 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -61,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 diff --git a/docs/source/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index 240c258f68f..d1ff3c3ac13 100644 --- a/docs/source/inclusions/bot_methods.rst +++ b/docs/source/inclusions/bot_methods.rst @@ -161,8 +161,6 @@ - Used for unpinning a message * - :meth:`~telegram.Bot.unpin_all_chat_messages` - Used for unpinning all pinned chat messages - * - :meth:`~telegram.Bot.get_business_connection` - - Used for getting information about the business account. * - :meth:`~telegram.Bot.get_user_profile_photos` - Used for obtaining user's profile pictures * - :meth:`~telegram.Bot.get_chat` @@ -396,6 +394,60 @@ - 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.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. + .. raw:: html diff --git a/docs/source/stability_policy.rst b/docs/source/stability_policy.rst index 972892185fc..621b99b540e 100644 --- a/docs/source/stability_policy.rst +++ b/docs/source/stability_policy.rst @@ -1,3 +1,5 @@ +.. _stability-policy: + Stability Policy ================ 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.at-tree.rst b/docs/source/telegram.at-tree.rst index 22abbfb3867..63da86e76de 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -4,6 +4,7 @@ Available Types .. toctree:: :titlesonly: + telegram.acceptedgifttypes telegram.animation telegram.audio telegram.birthdate @@ -19,6 +20,7 @@ Available Types telegram.botdescription telegram.botname telegram.botshortdescription + telegram.businessbotrights telegram.businessconnection telegram.businessintro telegram.businesslocation @@ -75,6 +77,7 @@ Available Types telegram.forumtopicreopened telegram.generalforumtopichidden telegram.generalforumtopicunhidden + telegram.giftinfo telegram.giveaway telegram.giveawaycompleted telegram.giveawaycreated @@ -92,13 +95,20 @@ Available Types 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.keyboardbuttonrequestusers telegram.linkpreviewoptions telegram.location + telegram.locationaddress telegram.loginurl telegram.maybeinaccessiblemessage telegram.menubutton @@ -116,12 +126,17 @@ Available Types 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 @@ -138,9 +153,23 @@ Available Types telegram.sentwebappmessage telegram.shareduser telegram.story + telegram.storyarea + telegram.storyareaposition + telegram.storyareatype + telegram.storyareatypelink + telegram.storyareatypelocation + telegram.storyareatypesuggestedreaction + telegram.storyareatypeuniquegift + telegram.storyareatypeweather telegram.switchinlinequerychosenchat telegram.telegramobject telegram.textquote + telegram.uniquegift + telegram.uniquegiftbackdrop + telegram.uniquegiftbackdropcolors + telegram.uniquegiftinfo + telegram.uniquegiftmodel + telegram.uniquegiftsymbol telegram.update telegram.user telegram.userchatboosts 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.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.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.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.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.paidmeessagepricechanged.rst b/docs/source/telegram.paidmeessagepricechanged.rst new file mode 100644 index 00000000000..3d0e739c456 --- /dev/null +++ b/docs/source/telegram.paidmeessagepricechanged.rst @@ -0,0 +1,6 @@ +PaidMessagePriceChanged +======================= + +.. autoclass:: telegram.PaidMessagePriceChanged + :members: + :show-inheritance: diff --git a/docs/source/telegram.payments-tree.rst b/docs/source/telegram.payments-tree.rst index 3e6f42bdc97..94e4fec3c99 100644 --- a/docs/source/telegram.payments-tree.rst +++ b/docs/source/telegram.payments-tree.rst @@ -22,6 +22,7 @@ Your bot can accept payments from Telegram users. Please see the `introduction t telegram.shippingaddress telegram.shippingoption telegram.shippingquery + telegram.staramount telegram.startransaction telegram.startransactions telegram.successfulpayment 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.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.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.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/examples/arbitrarycallbackdatabot.py b/examples/arbitrarycallbackdatabot.py index e11620c1670..64971817bfb 100644 --- a/examples/arbitrarycallbackdatabot.py +++ b/examples/arbitrarycallbackdatabot.py @@ -69,7 +69,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/pyproject.toml b/pyproject.toml index ef6556ecef8..d1f35f0591d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,16 +94,56 @@ webhooks = [ "tornado~=6.4", ] +[dependency-groups] +tests = [ + # required for building the wheels for releases + "build", + # For the test suite + "pytest==8.3.5", + # needed because pytest doesn't come with native support for coroutines as tests + "pytest-asyncio==0.21.2", + # xdist runs tests in parallel + "pytest-xdist==3.6.1", + # Used for flaky tests (flaky decorator) + "flaky>=3.8.1", + # used in test_official for parsing tg docs + "beautifulsoup4", + # 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", +] +docs = [ + "chango~=0.4.0; python_version >= '3.12'", + "sphinx==8.2.3; python_version >= '3.11'", + "furo==2024.8.6", + "furo-sphinx-search @ git+https://github.com/harshil21/furo-sphinx-search@v0.2.0.1", + "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", +] +all = ["pre-commit", { include-group = "tests" }, { include-group = "docs" }] # HATCH [tool.hatch.version] # dynamically evaluates the `__version__` variable in that file source = "code" -path = "telegram/_version.py" -search-paths = ["telegram"] +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"] +# the wheel will only include the src/telegram package +[tool.hatch.build.targets.wheel] +packages = ["src/telegram"] -[tool.hatch.build] -packages = ["telegram"] +# CHANGO +[tool.chango] +sys_path = "changes" +chango_instance = { name= "chango_instance", module = "config" } # BLACK: [tool.black] @@ -120,8 +160,7 @@ line-length = 99 show-fixes = true [tool.ruff.lint] -preview = true -explicit-preview-rules = true # TODO: Drop this when RUF022 and RUF023 are out of preview +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", "ASYNC", "TCH", "SLOT", "PERF", "PYI", "FLY", "AIR", "RUF022", @@ -132,9 +171,9 @@ select = ["E", "F", "I", "PL", "UP", "RUF", "PTH", "C4", "B", "PIE", "SIM", "RET [tool.ruff.lint.per-file-ignores] "tests/*.py" = ["B018"] "tests/**.py" = ["RUF012", "ASYNC230", "DTZ", "ARG", "T201", "ASYNC109", "D", "S", "TRY"] -"telegram/**.py" = ["TRY003"] -"telegram/ext/_applicationbuilder.py" = ["TRY004"] -"telegram/ext/filters.py" = ["D102"] +"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"] @@ -162,6 +201,7 @@ exclude-protected = ["_unfrozen"] # PYTEST: [tool.pytest.ini_options] testpaths = ["tests"] +pythonpath = ["src"] addopts = "--no-success-flaky-report -rX" filterwarnings = [ "error", @@ -184,6 +224,7 @@ log_cli_format = "%(funcName)s - Line %(lineno)d - %(message)s" # MYPY: [tool.mypy] +mypy_path = "src" warn_unused_ignores = true warn_unused_configs = true disallow_untyped_defs = true @@ -226,12 +267,12 @@ ignore_missing_imports = true # COVERAGE: [tool.coverage.run] branch = true -source = ["telegram"] +source = ["src/telegram"] parallel = true concurrency = ["thread", "multiprocessing"] omit = [ "tests/", - "telegram/__main__.py" + "src/telegram/__main__.py" ] [tool.coverage.report] diff --git a/requirements-dev-all.txt b/requirements-dev-all.txt deleted file mode 100644 index 995e067c420..00000000000 --- a/requirements-dev-all.txt +++ /dev/null @@ -1,5 +0,0 @@ --e .[all] -# needed for pre-commit hooks in the git commit command -pre-commit --r requirements-unit-tests.txt --r docs/requirements-docs.txt diff --git a/requirements-unit-tests.txt b/requirements-unit-tests.txt deleted file mode 100644 index a9c4ba3c2c9..00000000000 --- a/requirements-unit-tests.txt +++ /dev/null @@ -1,23 +0,0 @@ --e . - -# required for building the wheels for releases -build - -# For the test suite -pytest==8.3.4 - -# needed because pytest doesn't come with native support for coroutines as tests -pytest-asyncio==0.21.2 - -# xdist runs tests in parallel -pytest-xdist==3.6.1 - -# Used for flaky tests (flaky decorator) -flaky>=3.8.1 - -# used in test_official for parsing tg docs -beautifulsoup4 - -# 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 \ No newline at end of file diff --git a/telegram/__init__.py b/src/telegram/__init__.py similarity index 91% rename from telegram/__init__.py rename to src/telegram/__init__.py index fe2fce247ea..0f20f0ba605 100644 --- a/telegram/__init__.py +++ b/src/telegram/__init__.py @@ -20,6 +20,7 @@ __author__ = "devs@python-telegram-bot.org" __all__ = ( + "AcceptedGiftTypes", "AffiliateInfo", "Animation", "Audio", @@ -46,6 +47,7 @@ "BotDescription", "BotName", "BotShortDescription", + "BusinessBotRights", "BusinessConnection", "BusinessIntro", "BusinessLocation", @@ -103,6 +105,7 @@ "GeneralForumTopicHidden", "GeneralForumTopicUnhidden", "Gift", + "GiftInfo", "Gifts", "Giveaway", "GiveawayCompleted", @@ -150,7 +153,13 @@ "InputPaidMediaPhoto", "InputPaidMediaVideo", "InputPollOption", + "InputProfilePhoto", + "InputProfilePhotoAnimated", + "InputProfilePhotoStatic", "InputSticker", + "InputStoryContent", + "InputStoryContentPhoto", + "InputStoryContentVideo", "InputTextMessageContent", "InputVenueMessageContent", "Invoice", @@ -161,6 +170,7 @@ "LabeledPrice", "LinkPreviewOptions", "Location", + "LocationAddress", "LoginUrl", "MaskPosition", "MaybeInaccessibleMessage", @@ -180,12 +190,17 @@ "MessageReactionCountUpdated", "MessageReactionUpdated", "OrderInfo", + "OwnedGift", + "OwnedGiftRegular", + "OwnedGiftUnique", + "OwnedGifts", "PaidMedia", "PaidMediaInfo", "PaidMediaPhoto", "PaidMediaPreview", "PaidMediaPurchased", "PaidMediaVideo", + "PaidMessagePriceChanged", "PassportData", "PassportElementError", "PassportElementErrorDataField", @@ -227,11 +242,20 @@ "ShippingAddress", "ShippingOption", "ShippingQuery", + "StarAmount", "StarTransaction", "StarTransactions", "Sticker", "StickerSet", "Story", + "StoryArea", + "StoryAreaPosition", + "StoryAreaType", + "StoryAreaTypeLink", + "StoryAreaTypeLocation", + "StoryAreaTypeSuggestedReaction", + "StoryAreaTypeUniqueGift", + "StoryAreaTypeWeather", "SuccessfulPayment", "SwitchInlineQueryChosenChat", "TelegramObject", @@ -244,6 +268,12 @@ "TransactionPartnerTelegramAds", "TransactionPartnerTelegramApi", "TransactionPartnerUser", + "UniqueGift", + "UniqueGiftBackdrop", + "UniqueGiftBackdropColors", + "UniqueGiftInfo", + "UniqueGiftModel", + "UniqueGiftSymbol", "Update", "User", "UserChatBoosts", @@ -272,6 +302,7 @@ "warnings", ) +from telegram._payment.stars.staramount import StarAmount from telegram._payment.stars.startransactions import StarTransaction, StarTransactions from telegram._payment.stars.transactionpartner import ( TransactionPartner, @@ -301,6 +332,7 @@ from ._botdescription import BotDescription, BotShortDescription from ._botname import BotName from ._business import ( + BusinessBotRights, BusinessConnection, BusinessIntro, BusinessLocation, @@ -352,6 +384,11 @@ from ._choseninlineresult import ChosenInlineResult from ._copytextbutton import CopyTextButton from ._dice import Dice +from ._files._inputstorycontent import ( + InputStoryContent, + InputStoryContentPhoto, + InputStoryContentVideo, +) from ._files.animation import Animation from ._files.audio import Audio from ._files.chatphoto import ChatPhoto @@ -370,6 +407,11 @@ InputPaidMediaPhoto, InputPaidMediaVideo, ) +from ._files.inputprofilephoto import ( + InputProfilePhoto, + InputProfilePhotoAnimated, + InputProfilePhotoStatic, +) from ._files.inputsticker import InputSticker from ._files.location import Location from ._files.photosize import PhotoSize @@ -391,7 +433,7 @@ from ._games.callbackgame import CallbackGame from ._games.game import Game from ._games.gamehighscore import GameHighScore -from ._gifts import Gift, Gifts +from ._gifts import AcceptedGiftTypes, Gift, GiftInfo, Gifts from ._giveaway import Giveaway, GiveawayCompleted, GiveawayCreated, GiveawayWinners from ._inline.inlinekeyboardbutton import InlineKeyboardButton from ._inline.inlinekeyboardmarkup import InlineKeyboardMarkup @@ -443,6 +485,7 @@ MessageOriginUser, ) from ._messagereactionupdated import MessageReactionCountUpdated, MessageReactionUpdated +from ._ownedgift import OwnedGift, OwnedGiftRegular, OwnedGifts, OwnedGiftUnique from ._paidmedia import ( PaidMedia, PaidMediaInfo, @@ -451,6 +494,7 @@ PaidMediaPurchased, PaidMediaVideo, ) +from ._paidmessagepricechanged import PaidMessagePriceChanged from ._passport.credentials import ( Credentials, DataCredentials, @@ -506,8 +550,27 @@ from ._sentwebappmessage import SentWebAppMessage from ._shared import ChatShared, SharedUser, UsersShared from ._story import Story +from ._storyarea import ( + LocationAddress, + StoryArea, + StoryAreaPosition, + StoryAreaType, + StoryAreaTypeLink, + StoryAreaTypeLocation, + StoryAreaTypeSuggestedReaction, + StoryAreaTypeUniqueGift, + StoryAreaTypeWeather, +) from ._switchinlinequerychosenchat import SwitchInlineQueryChosenChat from ._telegramobject import TelegramObject +from ._uniquegift import ( + UniqueGift, + UniqueGiftBackdrop, + UniqueGiftBackdropColors, + UniqueGiftInfo, + UniqueGiftModel, + UniqueGiftSymbol, +) from ._update import Update from ._user import User from ._userprofilephotos import UserProfilePhotos diff --git a/telegram/__main__.py b/src/telegram/__main__.py similarity index 100% rename from telegram/__main__.py rename to src/telegram/__main__.py diff --git a/telegram/_birthdate.py b/src/telegram/_birthdate.py similarity index 100% rename from telegram/_birthdate.py rename to src/telegram/_birthdate.py diff --git a/telegram/_bot.py b/src/telegram/_bot.py similarity index 91% rename from telegram/_bot.py rename to src/telegram/_bot.py index 49d7177b311..90f6cf0bf42 100644 --- a/telegram/_bot.py +++ b/src/telegram/_bot.py @@ -75,17 +75,20 @@ from telegram._files.voice import Voice from telegram._forumtopic import ForumTopic from telegram._games.gamehighscore import GameHighScore -from telegram._gifts import Gift, Gifts +from telegram._gifts import AcceptedGiftTypes, Gift, Gifts from telegram._inline.inlinequeryresultsbutton import InlineQueryResultsButton from telegram._inline.preparedinlinemessage import PreparedInlineMessage from telegram._menubutton import MenuButton from telegram._message import Message from telegram._messageid import MessageId +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 @@ -112,7 +115,7 @@ from telegram.request import BaseRequest, RequestData from telegram.request._httpxrequest import HTTPXRequest from telegram.request._requestparameter import RequestParameter -from telegram.warnings import PTBDeprecationWarning, PTBUserWarning +from telegram.warnings import PTBUserWarning if TYPE_CHECKING: from telegram import ( @@ -123,12 +126,15 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, + InputProfilePhoto, InputSticker, + InputStoryContent, LabeledPrice, LinkPreviewOptions, MessageEntity, PassportElementError, ShippingOption, + StoryArea, ) BT = TypeVar("BT", bound="Bot") @@ -829,13 +835,15 @@ async def initialize(self) -> None: return await asyncio.gather(self._request[0].initialize(), self._request[1].initialize()) + # this needs to be set before we call get_me, since this can trigger an error in the + # request backend, which would then NOT lead to a proper shutdown if this flag isn't set + self._initialized = True # 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() 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 @@ -3926,7 +3934,7 @@ 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}" @@ -4581,19 +4589,7 @@ async def get_updates( if not isinstance(read_timeout, DefaultValue): arg_read_timeout: float = read_timeout or 0 else: - try: - arg_read_timeout = self._request[0].read_timeout or 0 - except NotImplementedError: - arg_read_timeout = 2 - self._warn( - PTBDeprecationWarning( - "20.7", - f"The class {self._request[0].__class__.__name__} does not override " - "the property `read_timeout`. Overriding this property will be mandatory " - "in future versions. Using 2 seconds as fallback.", - ), - stacklevel=2, - ) + arg_read_timeout = self._request[0].read_timeout or 0 # Ideally we'd use an aggressive read timeout for the polling. However, # * Short polling should return within 2 seconds. @@ -4601,7 +4597,7 @@ async def get_updates( # 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, @@ -9372,6 +9368,83 @@ async def set_message_reaction( api_kwargs=api_kwargs, ) + async def gift_premium_subscription( + self, + user_id: int, + month_count: int, + star_count: int, + text: Optional[str] = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_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, + ) -> 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, @@ -9411,36 +9484,145 @@ async def get_business_connection( bot=self, ) - async def replace_sticker_in_set( + async def get_business_account_gifts( self, - user_id: int, - name: str, - old_sticker: Union[str, "Sticker"], - sticker: "InputSticker", + business_connection_id: str, + exclude_unsaved: Optional[bool] = None, + exclude_saved: Optional[bool] = None, + exclude_unlimited: Optional[bool] = None, + exclude_limited: Optional[bool] = None, + exclude_unique: Optional[bool] = None, + sort_by_price: Optional[bool] = None, + offset: Optional[str] = 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, - ) -> 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`. + ) -> 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:: 21.1 + .. versionadded:: 22.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. + 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. + 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 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` - .. 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. + Raises: + :class:`telegram.error.TelegramError` + """ + 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_unique": exclude_unique, + "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: Optional[JSONDict] = 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: Optional[JSONDict] = 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. @@ -9449,14 +9631,12 @@ async def replace_sticker_in_set( :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, + "business_connection_id": business_connection_id, + "chat_id": chat_id, + "message_id": message_id, } - return await self._post( - "replaceStickerInSet", + "readBusinessMessage", data, read_timeout=read_timeout, write_timeout=write_timeout, @@ -9465,10 +9645,10 @@ async def replace_sticker_in_set( api_kwargs=api_kwargs, ) - async def refund_star_payment( + async def delete_business_messages( self, - user_id: int, - telegram_payment_charge_id: str, + business_connection_id: str, + message_ids: Sequence[int], *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -9476,28 +9656,36 @@ async def refund_star_payment( pool_timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: Optional[JSONDict] = None, ) -> bool: - """Refunds a successful payment in |tg_stars|. + """ + 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:: 21.3 + .. versionadded:: 22.1 Args: - user_id (:obj:`int`): User identifier of the user whose payment will be refunded. - telegram_payment_charge_id (:obj:`str`): Telegram payment identifier. + 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 = { - "user_id": user_id, - "telegram_payment_charge_id": telegram_payment_charge_id, + "business_connection_id": business_connection_id, + "message_ids": message_ids, } - return await self._post( - "refundStarPayment", + "deleteBusinessMessages", data, read_timeout=read_timeout, write_timeout=write_timeout, @@ -9506,93 +9694,872 @@ async def refund_star_payment( api_kwargs=api_kwargs, ) - async def get_star_transactions( + async def post_story( self, - offset: Optional[int] = None, - limit: Optional[int] = None, + business_connection_id: str, + content: "InputStoryContent", + active_period: TimePeriod, + caption: Optional[str] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Optional[Sequence["MessageEntity"]] = None, + areas: Optional[Sequence["StoryArea"]] = None, + post_to_chat_page: Optional[bool] = 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: Optional[JSONDict] = None, - ) -> StarTransactions: - """Returns the bot's Telegram Star transactions in chronological order. + ) -> Story: + """ + Posts a story on behalf of a managed business account. Requires the + :attr:`~telegram.BusinessBotRights.can_manage_stories` business bot right. - .. versionadded:: 21.4 + .. versionadded:: 22.1 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`. + 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:`telegram.StarTransactions`: On success. + :class:`Story` Raises: :class:`telegram.error.TelegramError` """ - - data: JSONDict = {"offset": offset, "limit": limit} - - return StarTransactions.de_json( + 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( - "getStarTransactions", + "postStory", 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( + async def edit_story( self, - user_id: int, - telegram_payment_charge_id: str, - is_canceled: bool, + business_connection_id: str, + story_id: int, + content: "InputStoryContent", + caption: Optional[str] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Optional[Sequence["MessageEntity"]] = None, + areas: Optional[Sequence["StoryArea"]] = None, *, read_timeout: ODVInput[float] = DEFAULT_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: - """Allows the bot to cancel or re-enable extension of a subscription paid in Telegram - Stars. + ) -> 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:: 21.8 + .. versionadded:: 22.1 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. + 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: - :obj:`bool`: On success, :obj:`True` is returned. + :class:`Story` Raises: :class:`telegram.error.TelegramError` """ data: JSONDict = { - "user_id": user_id, - "telegram_payment_charge_id": telegram_payment_charge_id, - "is_canceled": is_canceled, + "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 await self._post( - "editUserStarSubscription", - data, - read_timeout=read_timeout, - write_timeout=write_timeout, - connect_timeout=connect_timeout, + 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: Optional[JSONDict] = 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: 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: + """ + 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: 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: + """ + 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: 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: + """ + 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: Optional[JSONDict] = 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: 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, + ) -> 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: 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, + ) -> 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: Optional[JSONDict] = 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: Optional[bool] = None, + star_count: 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: + """ + 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: 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: + """ + 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: Optional[JSONDict] = 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: Union[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: Optional[JSONDict] = 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: Optional[JSONDict] = 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: Optional[int] = None, + limit: Optional[int] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + ) -> StarTransactions: + """Returns the bot's Telegram Star transactions in chronological order. + + .. versionadded:: 21.4 + + Args: + offset (:obj:`int`, optional): Number of transactions to skip in the response. + limit (:obj:`int`, optional): The maximum number of transactions to be retrieved. + Values between :tg-const:`telegram.constants.StarTransactionsLimit.MIN_LIMIT`- + :tg-const:`telegram.constants.StarTransactionsLimit.MAX_LIMIT` are accepted. + Defaults to :tg-const:`telegram.constants.StarTransactionsLimit.MAX_LIMIT`. + + Returns: + :class:`telegram.StarTransactions`: On success. + + Raises: + :class:`telegram.error.TelegramError` + """ + + data: JSONDict = {"offset": offset, "limit": limit} + + return StarTransactions.de_json( + 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: Optional[JSONDict] = 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, ) @@ -9855,13 +10822,13 @@ async def get_available_gifts( async def send_gift( self, - user_id: Optional[int] = None, - gift_id: Union[str, Gift] = None, # type: ignore + gift_id: Union[str, Gift], text: Optional[str] = None, text_parse_mode: ODVInput[str] = DEFAULT_NONE, text_entities: Optional[Sequence["MessageEntity"]] = None, pay_for_upgrade: Optional[bool] = None, chat_id: Optional[Union[str, int]] = None, + user_id: Optional[int] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -9873,15 +10840,18 @@ async def send_gift( 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 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. - gift_id (:obj:`str` | :class:`~telegram.Gift`): Identifier of the gift or a - :class:`~telegram.Gift` object chat_id (:obj:`int` | :obj:`str`, optional): Required if :paramref:`user_id` is not specified. |chat_id_channel| It will receive the gift. @@ -9912,11 +10882,6 @@ async def send_gift( Raises: :class:`telegram.error.TelegramError` """ - # TODO: Remove when stability policy allows, tags: deprecated 21.11 - # also we should raise a deprecation warnung if anything is passed by - # position since it will be moved, not sure how - if gift_id is None: - raise TypeError("Missing required argument `gift_id`.") data: JSONDict = { "user_id": user_id, "gift_id": gift_id.id if isinstance(gift_id, Gift) else gift_id, @@ -10348,8 +11313,44 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """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 diff --git a/telegram/_botcommand.py b/src/telegram/_botcommand.py similarity index 100% rename from telegram/_botcommand.py rename to src/telegram/_botcommand.py diff --git a/telegram/_botcommandscope.py b/src/telegram/_botcommandscope.py similarity index 100% rename from telegram/_botcommandscope.py rename to src/telegram/_botcommandscope.py diff --git a/telegram/_botdescription.py b/src/telegram/_botdescription.py similarity index 100% rename from telegram/_botdescription.py rename to src/telegram/_botdescription.py diff --git a/telegram/_botname.py b/src/telegram/_botname.py similarity index 100% rename from telegram/_botname.py rename to src/telegram/_botname.py diff --git a/telegram/_business.py b/src/telegram/_business.py similarity index 60% rename from telegram/_business.py rename to src/telegram/_business.py index 95607c24344..dd055426654 100644 --- a/telegram/_business.py +++ b/src/telegram/_business.py @@ -30,20 +30,172 @@ 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 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 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: Optional[bool] = None, + can_read_messages: Optional[bool] = None, + can_delete_sent_messages: Optional[bool] = None, + can_delete_all_messages: Optional[bool] = None, + can_edit_name: Optional[bool] = None, + can_edit_bio: Optional[bool] = None, + can_edit_profile_photo: Optional[bool] = None, + can_edit_username: Optional[bool] = None, + can_change_gift_settings: Optional[bool] = None, + can_view_gifts_and_stars: Optional[bool] = None, + can_convert_gifts_to_stars: Optional[bool] = None, + can_transfer_and_upgrade_gifts: Optional[bool] = None, + can_transfer_stars: Optional[bool] = None, + can_manage_stories: Optional[bool] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.can_reply: Optional[bool] = can_reply + self.can_read_messages: Optional[bool] = can_read_messages + self.can_delete_sent_messages: Optional[bool] = can_delete_sent_messages + self.can_delete_all_messages: Optional[bool] = can_delete_all_messages + self.can_edit_name: Optional[bool] = can_edit_name + self.can_edit_bio: Optional[bool] = can_edit_bio + self.can_edit_profile_photo: Optional[bool] = can_edit_profile_photo + self.can_edit_username: Optional[bool] = can_edit_username + self.can_change_gift_settings: Optional[bool] = can_change_gift_settings + self.can_view_gifts_and_stars: Optional[bool] = can_view_gifts_and_stars + self.can_convert_gifts_to_stars: Optional[bool] = can_convert_gifts_to_stars + self.can_transfer_and_upgrade_gifts: Optional[bool] = can_transfer_and_upgrade_gifts + self.can_transfer_stars: Optional[bool] = can_transfer_stars + self.can_manage_stories: Optional[bool] = 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:`can_reply`, and :attr:`is_enabled` are equal. + :attr:`rights`, and :attr:`is_enabled` are equal. .. versionadded:: 21.1 + .. versionchanged:: 22.1 + Equality comparison now considers :attr:`rights` instead of :attr:`can_reply`. Args: id (:obj:`str`): Unique identifier of the business connection. @@ -51,9 +203,15 @@ class BusinessConnection(TelegramObject): 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. - can_reply (:obj:`bool`): True, if the bot can act on behalf of the business account in - chats that were active in the last 24 hours. + can_reply (:obj:`bool`, optional): True, if the bot can act on behalf of the business + account in chats that were active in the last 24 hours. + + .. deprecated:: 22.1 + Bot API 9.0 deprecated this argument in favor of :paramref:`rights`. 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. @@ -61,16 +219,18 @@ class BusinessConnection(TelegramObject): 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. - can_reply (:obj:`bool`): True, if the bot can act on behalf of the business account in - chats that were active in the last 24 hours. is_enabled (:obj:`bool`): True, if the connection is active. + rights (:class:`BusinessBotRights`): Optional. Rights of the business bot. + + .. versionadded:: 22.1 """ __slots__ = ( - "can_reply", + "_can_reply", "date", "id", "is_enabled", + "rights", "user", "user_chat_id", ) @@ -81,30 +241,67 @@ def __init__( user: "User", user_chat_id: int, date: dtm.datetime, - can_reply: bool, - is_enabled: bool, + can_reply: Optional[bool] = None, + # temporarily optional to account for changed signature + # tags: deprecated 22.1; bot api 9.0 + is_enabled: Optional[bool] = None, + rights: Optional[BusinessBotRights] = None, *, api_kwargs: Optional[JSONDict] = None, ): + if is_enabled is None: + raise TypeError("Missing required argument `is_enabled`") + + if can_reply is not None: + warn( + PTBDeprecationWarning( + version="22.1", + message=build_deprecation_warning_message( + deprecated_name="can_reply", + new_name="rights", + bot_api_version="9.0", + object_type="parameter", + ), + ), + stacklevel=2, + ) + 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.can_reply: bool = can_reply + self._can_reply: Optional[bool] = can_reply self.is_enabled: bool = is_enabled + self.rights: Optional[BusinessBotRights] = rights self._id_attrs = ( self.id, self.user, self.user_chat_id, self.date, - self.can_reply, + self.rights, self.is_enabled, ) self._freeze() + @property + def can_reply(self) -> Optional[bool]: + """:obj:`bool`: Optional. True, if the bot can act on behalf of the business account in + chats that were active in the last 24 hours. + + .. deprecated:: 22.1 + Bot API 9.0 deprecated this argument in favor of :attr:`rights` + """ + warn_about_deprecated_attr_in_property( + deprecated_attr_name="can_reply", + new_attr_name="rights", + bot_api_version="9.0", + ptb_version="22.1", + ) + return self._can_reply + @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "BusinessConnection": """See :meth:`telegram.TelegramObject.de_json`.""" @@ -115,6 +312,7 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "BusinessConnec 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) diff --git a/telegram/_callbackquery.py b/src/telegram/_callbackquery.py similarity index 100% rename from telegram/_callbackquery.py rename to src/telegram/_callbackquery.py diff --git a/telegram/_chat.py b/src/telegram/_chat.py similarity index 98% rename from telegram/_chat.py rename to src/telegram/_chat.py index fe49dc3593e..02eb6629d6d 100644 --- a/telegram/_chat.py +++ b/src/telegram/_chat.py @@ -3506,6 +3506,41 @@ async def send_gift( **{"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: 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.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: Optional[str] = None, @@ -3568,6 +3603,40 @@ async def remove_verification( 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: Optional[JSONDict] = 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, + ) + class Chat(_ChatBase): """This object represents a chat. diff --git a/telegram/_chatadministratorrights.py b/src/telegram/_chatadministratorrights.py similarity index 100% rename from telegram/_chatadministratorrights.py rename to src/telegram/_chatadministratorrights.py diff --git a/telegram/_chatbackground.py b/src/telegram/_chatbackground.py similarity index 100% rename from telegram/_chatbackground.py rename to src/telegram/_chatbackground.py diff --git a/telegram/_chatboost.py b/src/telegram/_chatboost.py similarity index 100% rename from telegram/_chatboost.py rename to src/telegram/_chatboost.py diff --git a/telegram/_chatfullinfo.py b/src/telegram/_chatfullinfo.py similarity index 91% rename from telegram/_chatfullinfo.py rename to src/telegram/_chatfullinfo.py index 1ce640638e1..4b0fae53c6b 100644 --- a/telegram/_chatfullinfo.py +++ b/src/telegram/_chatfullinfo.py @@ -27,10 +27,17 @@ 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._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 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, BusinessIntro, BusinessLocation, BusinessOpeningHours, Message @@ -64,6 +71,10 @@ class ChatFullInfo(_ChatBase): 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. @@ -204,6 +215,10 @@ class ChatFullInfo(_ChatBase): .. versionadded:: 21.11 + .. deprecated:: 22.1 + Bot API 9.0 introduced :paramref:`accepted_gift_types`, replacing this argument. + Hence, this argument will be removed in future versions. + Attributes: id (:obj:`int`): Unique identifier for this chat. type (:obj:`str`): Type of chat, can be either :attr:`PRIVATE`, :attr:`GROUP`, @@ -218,6 +233,10 @@ class ChatFullInfo(_ChatBase): 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. @@ -357,16 +376,15 @@ class ChatFullInfo(_ChatBase): sent or forwarded to the channel chat. The field is available only for channel chats. .. versionadded:: 21.4 - can_send_gift (:obj:`bool`): Optional. :obj:`True`, if gifts can be sent to the chat. - - .. versionadded:: 21.11 .. _accent colors: https://core.telegram.org/bots/api#accent-colors .. _topics: https://telegram.org/blog/topics-in-groups-collectible-usernames#topics-in-groups """ __slots__ = ( + "_can_send_gift", "accent_color_id", + "accepted_gift_types", "active_usernames", "available_reactions", "background_custom_emoji_id", @@ -375,7 +393,6 @@ class ChatFullInfo(_ChatBase): "business_intro", "business_location", "business_opening_hours", - "can_send_gift", "can_send_paid_media", "can_set_sticker_set", "custom_emoji_sticker_set_name", @@ -452,7 +469,10 @@ def __init__( linked_chat_id: Optional[int] = None, location: Optional[ChatLocation] = None, can_send_paid_media: Optional[bool] = None, + # tags: deprecated 22.1; bot api 9.0 can_send_gift: Optional[bool] = None, + # temporarily optional to account for changed signature + accepted_gift_types: Optional[AcceptedGiftTypes] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -466,6 +486,22 @@ def __init__( is_forum=is_forum, api_kwargs=api_kwargs, ) + if accepted_gift_types is None: + raise TypeError("`accepted_gift_type` is a required argument since Bot API 9.0") + + if can_send_gift is not None: + warn( + PTBDeprecationWarning( + "22.1", + build_deprecation_warning_message( + deprecated_name="can_send_gift", + new_name="accepted_gift_types", + object_type="parameter", + bot_api_version="9.0", + ), + ), + stacklevel=2, + ) # Required and unique to this class- with self._unfrozen(): @@ -518,7 +554,27 @@ def __init__( self.business_location: Optional[BusinessLocation] = business_location self.business_opening_hours: Optional[BusinessOpeningHours] = business_opening_hours self.can_send_paid_media: Optional[bool] = can_send_paid_media - self.can_send_gift: Optional[bool] = can_send_gift + self._can_send_gift: Optional[bool] = can_send_gift + self.accepted_gift_types: AcceptedGiftTypes = accepted_gift_types + + @property + def can_send_gift(self) -> Optional[bool]: + """ + :obj:`bool`: Optional. :obj:`True`, if gifts can be sent to the chat. + + .. deprecated:: 22.1 + As Bot API 9.0 replaces this attribute with :attr:`accepted_gift_types`, this attribute + will be removed in future versions. + + """ + warn_about_deprecated_attr_in_property( + deprecated_attr_name="can_send_gift", + new_attr_name="accepted_gift_types", + bot_api_version="9.0", + ptb_version="22.1", + stacklevel=2, + ) + return self._can_send_gift @classmethod def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatFullInfo": @@ -533,6 +589,9 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "ChatFullInfo": ) 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 BusinessIntro, diff --git a/telegram/_chatinvitelink.py b/src/telegram/_chatinvitelink.py similarity index 100% rename from telegram/_chatinvitelink.py rename to src/telegram/_chatinvitelink.py diff --git a/telegram/_chatjoinrequest.py b/src/telegram/_chatjoinrequest.py similarity index 100% rename from telegram/_chatjoinrequest.py rename to src/telegram/_chatjoinrequest.py diff --git a/telegram/_chatlocation.py b/src/telegram/_chatlocation.py similarity index 100% rename from telegram/_chatlocation.py rename to src/telegram/_chatlocation.py diff --git a/telegram/_chatmember.py b/src/telegram/_chatmember.py similarity index 100% rename from telegram/_chatmember.py rename to src/telegram/_chatmember.py diff --git a/telegram/_chatmemberupdated.py b/src/telegram/_chatmemberupdated.py similarity index 100% rename from telegram/_chatmemberupdated.py rename to src/telegram/_chatmemberupdated.py diff --git a/telegram/_chatpermissions.py b/src/telegram/_chatpermissions.py similarity index 100% rename from telegram/_chatpermissions.py rename to src/telegram/_chatpermissions.py diff --git a/telegram/_choseninlineresult.py b/src/telegram/_choseninlineresult.py similarity index 100% rename from telegram/_choseninlineresult.py rename to src/telegram/_choseninlineresult.py diff --git a/telegram/_copytextbutton.py b/src/telegram/_copytextbutton.py similarity index 100% rename from telegram/_copytextbutton.py rename to src/telegram/_copytextbutton.py diff --git a/telegram/_dice.py b/src/telegram/_dice.py similarity index 100% rename from telegram/_dice.py rename to src/telegram/_dice.py diff --git a/telegram/_games/__init__.py b/src/telegram/_files/__init__.py similarity index 100% rename from telegram/_games/__init__.py rename to src/telegram/_files/__init__.py diff --git a/telegram/_files/_basemedium.py b/src/telegram/_files/_basemedium.py similarity index 100% rename from telegram/_files/_basemedium.py rename to src/telegram/_files/_basemedium.py diff --git a/telegram/_files/_basethumbedmedium.py b/src/telegram/_files/_basethumbedmedium.py similarity index 100% rename from telegram/_files/_basethumbedmedium.py rename to src/telegram/_files/_basethumbedmedium.py diff --git a/src/telegram/_files/_inputstorycontent.py b/src/telegram/_files/_inputstorycontent.py new file mode 100644 index 00000000000..1eaf14682f3 --- /dev/null +++ b/src/telegram/_files/_inputstorycontent.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# 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, Optional, Union + +from telegram import constants +from telegram._files.inputfile import InputFile +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +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: Optional[JSONDict] = 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) -> Union[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: Optional[JSONDict] = None, + ) -> None: + super().__init__(type=InputStoryContent.PHOTO, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.photo: Union[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: Optional[Union[float, dtm.timedelta]] = None, + cover_frame_timestamp: Optional[Union[float, dtm.timedelta]] = None, + is_animation: Optional[bool] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(type=InputStoryContent.VIDEO, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.video: Union[str, InputFile] = self._parse_file_input(video) + self.duration: Optional[dtm.timedelta] = self._parse_period_arg(duration) + self.cover_frame_timestamp: Optional[dtm.timedelta] = self._parse_period_arg( + cover_frame_timestamp + ) + self.is_animation: Optional[bool] = is_animation + + # This helper is temporarly here until we can use `argumentparsing.parse_period_arg` + # from https://github.com/python-telegram-bot/python-telegram-bot/pull/4750 + @staticmethod + def _parse_period_arg(arg: Optional[Union[float, dtm.timedelta]]) -> Optional[dtm.timedelta]: + if arg is None: + return None + if isinstance(arg, dtm.timedelta): + return arg + return dtm.timedelta(seconds=arg) diff --git a/telegram/_files/animation.py b/src/telegram/_files/animation.py similarity index 100% rename from telegram/_files/animation.py rename to src/telegram/_files/animation.py diff --git a/telegram/_files/audio.py b/src/telegram/_files/audio.py similarity index 100% rename from telegram/_files/audio.py rename to src/telegram/_files/audio.py diff --git a/telegram/_files/chatphoto.py b/src/telegram/_files/chatphoto.py similarity index 100% rename from telegram/_files/chatphoto.py rename to src/telegram/_files/chatphoto.py diff --git a/telegram/_files/contact.py b/src/telegram/_files/contact.py similarity index 100% rename from telegram/_files/contact.py rename to src/telegram/_files/contact.py diff --git a/telegram/_files/document.py b/src/telegram/_files/document.py similarity index 100% rename from telegram/_files/document.py rename to src/telegram/_files/document.py diff --git a/telegram/_files/file.py b/src/telegram/_files/file.py similarity index 100% rename from telegram/_files/file.py rename to src/telegram/_files/file.py diff --git a/telegram/_files/inputfile.py b/src/telegram/_files/inputfile.py similarity index 100% rename from telegram/_files/inputfile.py rename to src/telegram/_files/inputfile.py diff --git a/telegram/_files/inputmedia.py b/src/telegram/_files/inputmedia.py similarity index 97% rename from telegram/_files/inputmedia.py rename to src/telegram/_files/inputmedia.py index a36590f9746..2b7e6b21fd5 100644 --- a/telegram/_files/inputmedia.py +++ b/src/telegram/_files/inputmedia.py @@ -51,13 +51,8 @@ class InputMedia(TelegramObject): Args: media_type (:obj:`str`): Type of media that the instance represents. - media (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :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. @@ -89,7 +84,7 @@ class InputMedia(TelegramObject): def __init__( self, media_type: str, - media: Union[str, InputFile, MediaType], + media: Union[str, InputFile], caption: Optional[str] = None, caption_entities: Optional[Sequence[MessageEntity]] = None, parse_mode: ODVInput[str] = DEFAULT_NONE, @@ -98,7 +93,7 @@ def __init__( ): super().__init__(api_kwargs=api_kwargs) self.type: str = enum.get_member(constants.InputMediaType, media_type, media_type) - self.media: Union[str, InputFile, Animation, Audio, Document, PhotoSize, Video] = media + self.media: Union[str, InputFile] = media self.caption: Optional[str] = caption self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) self.parse_mode: ODVInput[str] = parse_mode @@ -120,8 +115,8 @@ class InputPaidMedia(TelegramObject): """ Base class for Telegram InputPaidMedia Objects. Currently, it can be one of: - * :class:`telegram.InputMediaPhoto` - * :class:`telegram.InputMediaVideo` + * :class:`telegram.InputPaidMediaPhoto` + * :class:`telegram.InputPaidMediaVideo` .. seealso:: :wiki:`Working with Files and Media ` @@ -129,11 +124,8 @@ class InputPaidMedia(TelegramObject): Args: type (:obj:`str`): Type of media that the instance represents. - media (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ - :class:`pathlib.Path` | :class:`telegram.PhotoSize` | :class:`telegram.Video`): File + 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. Attributes: type (:obj:`str`): Type of the input media. @@ -150,13 +142,13 @@ class InputPaidMedia(TelegramObject): def __init__( self, type: str, # pylint: disable=redefined-builtin - media: Union[str, InputFile, PhotoSize, Video], + media: Union[str, InputFile], *, api_kwargs: Optional[JSONDict] = None, ): super().__init__(api_kwargs=api_kwargs) self.type: str = enum.get_member(constants.InputPaidMediaType, type, type) - self.media: Union[str, InputFile, PhotoSize, Video] = media + self.media: Union[str, InputFile] = media self._freeze() diff --git a/src/telegram/_files/inputprofilephoto.py b/src/telegram/_files/inputprofilephoto.py new file mode 100644 index 00000000000..8ec1ae93492 --- /dev/null +++ b/src/telegram/_files/inputprofilephoto.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# 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, Optional, Union + +from telegram import constants +from telegram._telegramobject import TelegramObject +from telegram._utils import enum +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: Optional[JSONDict] = 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: Optional[JSONDict] = 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: Union[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: Union[float, dtm.timedelta, None] = None, + *, + api_kwargs: Optional[JSONDict] = 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: Union[str, InputFile] = parse_file_input( + animation, attach=True, local_mode=True + ) + + if isinstance(main_frame_timestamp, dtm.timedelta): + self.main_frame_timestamp: Optional[dtm.timedelta] = main_frame_timestamp + elif main_frame_timestamp is None: + self.main_frame_timestamp = None + else: + self.main_frame_timestamp = dtm.timedelta(seconds=main_frame_timestamp) diff --git a/telegram/_files/inputsticker.py b/src/telegram/_files/inputsticker.py similarity index 100% rename from telegram/_files/inputsticker.py rename to src/telegram/_files/inputsticker.py diff --git a/telegram/_files/location.py b/src/telegram/_files/location.py similarity index 100% rename from telegram/_files/location.py rename to src/telegram/_files/location.py diff --git a/telegram/_files/photosize.py b/src/telegram/_files/photosize.py similarity index 100% rename from telegram/_files/photosize.py rename to src/telegram/_files/photosize.py diff --git a/telegram/_files/sticker.py b/src/telegram/_files/sticker.py similarity index 100% rename from telegram/_files/sticker.py rename to src/telegram/_files/sticker.py diff --git a/telegram/_files/venue.py b/src/telegram/_files/venue.py similarity index 100% rename from telegram/_files/venue.py rename to src/telegram/_files/venue.py diff --git a/telegram/_files/video.py b/src/telegram/_files/video.py similarity index 100% rename from telegram/_files/video.py rename to src/telegram/_files/video.py diff --git a/telegram/_files/videonote.py b/src/telegram/_files/videonote.py similarity index 100% rename from telegram/_files/videonote.py rename to src/telegram/_files/videonote.py diff --git a/telegram/_files/voice.py b/src/telegram/_files/voice.py similarity index 100% rename from telegram/_files/voice.py rename to src/telegram/_files/voice.py diff --git a/telegram/_forcereply.py b/src/telegram/_forcereply.py similarity index 100% rename from telegram/_forcereply.py rename to src/telegram/_forcereply.py diff --git a/telegram/_forumtopic.py b/src/telegram/_forumtopic.py similarity index 100% rename from telegram/_forumtopic.py rename to src/telegram/_forumtopic.py diff --git a/telegram/_inline/__init__.py b/src/telegram/_games/__init__.py similarity index 100% rename from telegram/_inline/__init__.py rename to src/telegram/_games/__init__.py diff --git a/telegram/_games/callbackgame.py b/src/telegram/_games/callbackgame.py similarity index 100% rename from telegram/_games/callbackgame.py rename to src/telegram/_games/callbackgame.py diff --git a/telegram/_games/game.py b/src/telegram/_games/game.py similarity index 100% rename from telegram/_games/game.py rename to src/telegram/_games/game.py diff --git a/telegram/_games/gamehighscore.py b/src/telegram/_games/gamehighscore.py similarity index 100% rename from telegram/_games/gamehighscore.py rename to src/telegram/_games/gamehighscore.py diff --git a/src/telegram/_gifts.py b/src/telegram/_gifts.py new file mode 100644 index 00000000000..42ec1c45297 --- /dev/null +++ b/src/telegram/_gifts.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python +# pylint: disable=redefined-builtin +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# 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, Optional + +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 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; for limited gifts only + remaining_count (:obj:`int`, optional): The number of remaining gifts of this type that can + be sent; 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 + + 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; for limited gifts only + remaining_count (:obj:`int`): Optional. The number of remaining gifts of this type that can + be sent; 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 + + """ + + __slots__ = ( + "id", + "remaining_count", + "star_count", + "sticker", + "total_count", + "upgrade_star_count", + ) + + def __init__( + self, + id: str, + sticker: Sticker, + star_count: int, + total_count: Optional[int] = None, + remaining_count: Optional[int] = None, + upgrade_star_count: Optional[int] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.id: str = id + self.sticker: Sticker = sticker + self.star_count: int = star_count + self.total_count: Optional[int] = total_count + self.remaining_count: Optional[int] = remaining_count + self.upgrade_star_count: Optional[int] = upgrade_star_count + + self._id_attrs = (self.id,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Gift": + """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 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: Optional[JSONDict] = 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: Optional["Bot"] = 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 by the sender 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. + + 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 by the sender 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. + + """ + + __slots__ = ( + "can_be_upgraded", + "convert_star_count", + "entities", + "gift", + "is_private", + "owned_gift_id", + "prepaid_upgrade_star_count", + "text", + ) + + def __init__( + self, + gift: Gift, + owned_gift_id: Optional[str] = None, + convert_star_count: Optional[int] = None, + prepaid_upgrade_star_count: Optional[int] = None, + can_be_upgraded: Optional[bool] = None, + text: Optional[str] = None, + entities: Optional[Sequence[MessageEntity]] = None, + is_private: Optional[bool] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.gift: Gift = gift + # Optional + self.owned_gift_id: Optional[str] = owned_gift_id + self.convert_star_count: Optional[int] = convert_star_count + self.prepaid_upgrade_star_count: Optional[int] = prepaid_upgrade_star_count + self.can_be_upgraded: Optional[bool] = can_be_upgraded + self.text: Optional[str] = text + self.entities: tuple[MessageEntity, ...] = parse_sequence_arg(entities) + self.is_private: Optional[bool] = is_private + + self._id_attrs = (self.gift,) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = 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: Optional[list[str]] = 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` and :attr:`premium_subscription` are equal. + + .. versionadded:: 22.1 + + 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. + + 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. + + """ + + __slots__ = ( + "limited_gifts", + "premium_subscription", + "unique_gifts", + "unlimited_gifts", + ) + + def __init__( + self, + unlimited_gifts: bool, + limited_gifts: bool, + unique_gifts: bool, + premium_subscription: bool, + *, + api_kwargs: Optional[JSONDict] = 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._id_attrs = ( + self.unlimited_gifts, + self.limited_gifts, + self.unique_gifts, + self.premium_subscription, + ) + + self._freeze() diff --git a/telegram/_giveaway.py b/src/telegram/_giveaway.py similarity index 100% rename from telegram/_giveaway.py rename to src/telegram/_giveaway.py diff --git a/telegram/_passport/__init__.py b/src/telegram/_inline/__init__.py similarity index 100% rename from telegram/_passport/__init__.py rename to src/telegram/_inline/__init__.py diff --git a/telegram/_inline/inlinekeyboardbutton.py b/src/telegram/_inline/inlinekeyboardbutton.py similarity index 100% rename from telegram/_inline/inlinekeyboardbutton.py rename to src/telegram/_inline/inlinekeyboardbutton.py diff --git a/telegram/_inline/inlinekeyboardmarkup.py b/src/telegram/_inline/inlinekeyboardmarkup.py similarity index 100% rename from telegram/_inline/inlinekeyboardmarkup.py rename to src/telegram/_inline/inlinekeyboardmarkup.py diff --git a/telegram/_inline/inlinequery.py b/src/telegram/_inline/inlinequery.py similarity index 95% rename from telegram/_inline/inlinequery.py rename to src/telegram/_inline/inlinequery.py index 1aa80014d77..73bb3b43b4d 100644 --- a/telegram/_inline/inlinequery.py +++ b/src/telegram/_inline/inlinequery.py @@ -62,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. @@ -202,16 +208,6 @@ async def answer( .. versionadded:: 13.2 """ - MIN_SWITCH_PM_TEXT_LENGTH: Final[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: Final[int] = constants.InlineQueryLimit.MAX_SWITCH_PM_TEXT_LENGTH - """:const:`telegram.constants.InlineQueryLimit.MAX_SWITCH_PM_TEXT_LENGTH` - - .. versionadded:: 20.0 - """ MAX_OFFSET_LENGTH: Final[int] = constants.InlineQueryLimit.MAX_OFFSET_LENGTH """:const:`telegram.constants.InlineQueryLimit.MAX_OFFSET_LENGTH` diff --git a/telegram/_inline/inlinequeryresult.py b/src/telegram/_inline/inlinequeryresult.py similarity index 100% rename from telegram/_inline/inlinequeryresult.py rename to src/telegram/_inline/inlinequeryresult.py diff --git a/telegram/_inline/inlinequeryresultarticle.py b/src/telegram/_inline/inlinequeryresultarticle.py similarity index 100% rename from telegram/_inline/inlinequeryresultarticle.py rename to src/telegram/_inline/inlinequeryresultarticle.py diff --git a/telegram/_inline/inlinequeryresultaudio.py b/src/telegram/_inline/inlinequeryresultaudio.py similarity index 100% rename from telegram/_inline/inlinequeryresultaudio.py rename to src/telegram/_inline/inlinequeryresultaudio.py diff --git a/telegram/_inline/inlinequeryresultcachedaudio.py b/src/telegram/_inline/inlinequeryresultcachedaudio.py similarity index 100% rename from telegram/_inline/inlinequeryresultcachedaudio.py rename to src/telegram/_inline/inlinequeryresultcachedaudio.py diff --git a/telegram/_inline/inlinequeryresultcacheddocument.py b/src/telegram/_inline/inlinequeryresultcacheddocument.py similarity index 100% rename from telegram/_inline/inlinequeryresultcacheddocument.py rename to src/telegram/_inline/inlinequeryresultcacheddocument.py diff --git a/telegram/_inline/inlinequeryresultcachedgif.py b/src/telegram/_inline/inlinequeryresultcachedgif.py similarity index 100% rename from telegram/_inline/inlinequeryresultcachedgif.py rename to src/telegram/_inline/inlinequeryresultcachedgif.py diff --git a/telegram/_inline/inlinequeryresultcachedmpeg4gif.py b/src/telegram/_inline/inlinequeryresultcachedmpeg4gif.py similarity index 100% rename from telegram/_inline/inlinequeryresultcachedmpeg4gif.py rename to src/telegram/_inline/inlinequeryresultcachedmpeg4gif.py diff --git a/telegram/_inline/inlinequeryresultcachedphoto.py b/src/telegram/_inline/inlinequeryresultcachedphoto.py similarity index 100% rename from telegram/_inline/inlinequeryresultcachedphoto.py rename to src/telegram/_inline/inlinequeryresultcachedphoto.py diff --git a/telegram/_inline/inlinequeryresultcachedsticker.py b/src/telegram/_inline/inlinequeryresultcachedsticker.py similarity index 100% rename from telegram/_inline/inlinequeryresultcachedsticker.py rename to src/telegram/_inline/inlinequeryresultcachedsticker.py diff --git a/telegram/_inline/inlinequeryresultcachedvideo.py b/src/telegram/_inline/inlinequeryresultcachedvideo.py similarity index 100% rename from telegram/_inline/inlinequeryresultcachedvideo.py rename to src/telegram/_inline/inlinequeryresultcachedvideo.py diff --git a/telegram/_inline/inlinequeryresultcachedvoice.py b/src/telegram/_inline/inlinequeryresultcachedvoice.py similarity index 100% rename from telegram/_inline/inlinequeryresultcachedvoice.py rename to src/telegram/_inline/inlinequeryresultcachedvoice.py diff --git a/telegram/_inline/inlinequeryresultcontact.py b/src/telegram/_inline/inlinequeryresultcontact.py similarity index 100% rename from telegram/_inline/inlinequeryresultcontact.py rename to src/telegram/_inline/inlinequeryresultcontact.py diff --git a/telegram/_inline/inlinequeryresultdocument.py b/src/telegram/_inline/inlinequeryresultdocument.py similarity index 100% rename from telegram/_inline/inlinequeryresultdocument.py rename to src/telegram/_inline/inlinequeryresultdocument.py diff --git a/telegram/_inline/inlinequeryresultgame.py b/src/telegram/_inline/inlinequeryresultgame.py similarity index 100% rename from telegram/_inline/inlinequeryresultgame.py rename to src/telegram/_inline/inlinequeryresultgame.py diff --git a/telegram/_inline/inlinequeryresultgif.py b/src/telegram/_inline/inlinequeryresultgif.py similarity index 100% rename from telegram/_inline/inlinequeryresultgif.py rename to src/telegram/_inline/inlinequeryresultgif.py diff --git a/telegram/_inline/inlinequeryresultlocation.py b/src/telegram/_inline/inlinequeryresultlocation.py similarity index 100% rename from telegram/_inline/inlinequeryresultlocation.py rename to src/telegram/_inline/inlinequeryresultlocation.py diff --git a/telegram/_inline/inlinequeryresultmpeg4gif.py b/src/telegram/_inline/inlinequeryresultmpeg4gif.py similarity index 100% rename from telegram/_inline/inlinequeryresultmpeg4gif.py rename to src/telegram/_inline/inlinequeryresultmpeg4gif.py diff --git a/telegram/_inline/inlinequeryresultphoto.py b/src/telegram/_inline/inlinequeryresultphoto.py similarity index 100% rename from telegram/_inline/inlinequeryresultphoto.py rename to src/telegram/_inline/inlinequeryresultphoto.py diff --git a/telegram/_inline/inlinequeryresultsbutton.py b/src/telegram/_inline/inlinequeryresultsbutton.py similarity index 88% rename from telegram/_inline/inlinequeryresultsbutton.py rename to src/telegram/_inline/inlinequeryresultsbutton.py index 513a366df9f..dd482534eb9 100644 --- a/telegram/_inline/inlinequeryresultsbutton.py +++ b/src/telegram/_inline/inlinequeryresultsbutton.py @@ -47,9 +47,10 @@ class InlineQueryResultsButton(TelegramObject): 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,10 +68,10 @@ 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. """ diff --git a/telegram/_inline/inlinequeryresultvenue.py b/src/telegram/_inline/inlinequeryresultvenue.py similarity index 100% rename from telegram/_inline/inlinequeryresultvenue.py rename to src/telegram/_inline/inlinequeryresultvenue.py diff --git a/telegram/_inline/inlinequeryresultvideo.py b/src/telegram/_inline/inlinequeryresultvideo.py similarity index 100% rename from telegram/_inline/inlinequeryresultvideo.py rename to src/telegram/_inline/inlinequeryresultvideo.py diff --git a/telegram/_inline/inlinequeryresultvoice.py b/src/telegram/_inline/inlinequeryresultvoice.py similarity index 100% rename from telegram/_inline/inlinequeryresultvoice.py rename to src/telegram/_inline/inlinequeryresultvoice.py diff --git a/telegram/_inline/inputcontactmessagecontent.py b/src/telegram/_inline/inputcontactmessagecontent.py similarity index 100% rename from telegram/_inline/inputcontactmessagecontent.py rename to src/telegram/_inline/inputcontactmessagecontent.py diff --git a/telegram/_inline/inputinvoicemessagecontent.py b/src/telegram/_inline/inputinvoicemessagecontent.py similarity index 100% rename from telegram/_inline/inputinvoicemessagecontent.py rename to src/telegram/_inline/inputinvoicemessagecontent.py diff --git a/telegram/_inline/inputlocationmessagecontent.py b/src/telegram/_inline/inputlocationmessagecontent.py similarity index 100% rename from telegram/_inline/inputlocationmessagecontent.py rename to src/telegram/_inline/inputlocationmessagecontent.py diff --git a/telegram/_inline/inputmessagecontent.py b/src/telegram/_inline/inputmessagecontent.py similarity index 100% rename from telegram/_inline/inputmessagecontent.py rename to src/telegram/_inline/inputmessagecontent.py diff --git a/telegram/_inline/inputtextmessagecontent.py b/src/telegram/_inline/inputtextmessagecontent.py similarity index 100% rename from telegram/_inline/inputtextmessagecontent.py rename to src/telegram/_inline/inputtextmessagecontent.py diff --git a/telegram/_inline/inputvenuemessagecontent.py b/src/telegram/_inline/inputvenuemessagecontent.py similarity index 100% rename from telegram/_inline/inputvenuemessagecontent.py rename to src/telegram/_inline/inputvenuemessagecontent.py diff --git a/telegram/_inline/preparedinlinemessage.py b/src/telegram/_inline/preparedinlinemessage.py similarity index 100% rename from telegram/_inline/preparedinlinemessage.py rename to src/telegram/_inline/preparedinlinemessage.py diff --git a/telegram/_keyboardbutton.py b/src/telegram/_keyboardbutton.py similarity index 100% rename from telegram/_keyboardbutton.py rename to src/telegram/_keyboardbutton.py diff --git a/telegram/_keyboardbuttonpolltype.py b/src/telegram/_keyboardbuttonpolltype.py similarity index 100% rename from telegram/_keyboardbuttonpolltype.py rename to src/telegram/_keyboardbuttonpolltype.py diff --git a/telegram/_keyboardbuttonrequest.py b/src/telegram/_keyboardbuttonrequest.py similarity index 100% rename from telegram/_keyboardbuttonrequest.py rename to src/telegram/_keyboardbuttonrequest.py diff --git a/telegram/_linkpreviewoptions.py b/src/telegram/_linkpreviewoptions.py similarity index 100% rename from telegram/_linkpreviewoptions.py rename to src/telegram/_linkpreviewoptions.py diff --git a/telegram/_loginurl.py b/src/telegram/_loginurl.py similarity index 100% rename from telegram/_loginurl.py rename to src/telegram/_loginurl.py diff --git a/telegram/_menubutton.py b/src/telegram/_menubutton.py similarity index 100% rename from telegram/_menubutton.py rename to src/telegram/_menubutton.py diff --git a/telegram/_message.py b/src/telegram/_message.py similarity index 96% rename from telegram/_message.py rename to src/telegram/_message.py index 7eefb8b0857..58e18aa0e01 100644 --- a/telegram/_message.py +++ b/src/telegram/_message.py @@ -49,11 +49,13 @@ GeneralForumTopicUnhidden, ) from telegram._games.game import Game +from telegram._gifts import GiftInfo from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup 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 @@ -64,6 +66,7 @@ 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 @@ -443,6 +446,10 @@ class Message(MaybeInaccessibleMessage): `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. @@ -525,6 +532,14 @@ class Message(MaybeInaccessibleMessage): 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 giveaway_created (:class:`telegram.GiveawayCreated`, optional): Service message: a scheduled giveaway was created @@ -541,6 +556,10 @@ class Message(MaybeInaccessibleMessage): 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 external_reply (:class:`telegram.ExternalReplyInfo`, optional): Information about the message that is being replied to, which may come from another chat or forum topic. @@ -771,6 +790,10 @@ class Message(MaybeInaccessibleMessage): `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: @@ -853,6 +876,14 @@ class Message(MaybeInaccessibleMessage): 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 giveaway_created (:class:`telegram.GiveawayCreated`): Optional. Service message: a scheduled giveaway was created @@ -869,6 +900,10 @@ class Message(MaybeInaccessibleMessage): 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 external_reply (:class:`telegram.ExternalReplyInfo`): Optional. Information about the message that is being replied to, which may come from another chat or forum topic. @@ -930,6 +965,9 @@ class Message(MaybeInaccessibleMessage): .. |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 @@ -963,6 +1001,7 @@ class Message(MaybeInaccessibleMessage): "game", "general_forum_topic_hidden", "general_forum_topic_unhidden", + "gift", "giveaway", "giveaway_completed", "giveaway_created", @@ -986,6 +1025,8 @@ class Message(MaybeInaccessibleMessage): "new_chat_photo", "new_chat_title", "paid_media", + "paid_message_price_changed", + "paid_star_count", "passport_data", "photo", "pinned_message", @@ -1005,6 +1046,7 @@ class Message(MaybeInaccessibleMessage): "successful_payment", "supergroup_chat_created", "text", + "unique_gift", "users_shared", "venue", "via_bot", @@ -1106,6 +1148,10 @@ def __init__( show_caption_above_media: Optional[bool] = None, paid_media: Optional[PaidMediaInfo] = None, refunded_payment: Optional[RefundedPayment] = None, + gift: Optional[GiftInfo] = None, + unique_gift: Optional[UniqueGiftInfo] = None, + paid_message_price_changed: Optional[PaidMessagePriceChanged] = None, + paid_star_count: Optional[int] = None, *, api_kwargs: Optional[JSONDict] = None, ): @@ -1209,6 +1255,12 @@ def __init__( self.show_caption_above_media: Optional[bool] = show_caption_above_media self.paid_media: Optional[PaidMediaInfo] = paid_media self.refunded_payment: Optional[RefundedPayment] = refunded_payment + self.gift: Optional[GiftInfo] = gift + self.unique_gift: Optional[UniqueGiftInfo] = unique_gift + self.paid_message_price_changed: Optional[PaidMessagePriceChanged] = ( + paid_message_price_changed + ) + self.paid_star_count: Optional[int] = paid_star_count self._effective_attachment = DEFAULT_NONE @@ -1343,6 +1395,11 @@ def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Message": 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=import-outside-toplevel @@ -1484,22 +1541,17 @@ def effective_attachment( return self._effective_attachment # type: ignore[return-value] - def _quote( - self, quote: Optional[bool], reply_to_message_id: Optional[int] = None - ) -> Optional[ReplyParameters]: + def _do_quote(self, do_quote: Optional[bool]) -> Optional[ReplyParameters]: """Modify kwargs for replying with or without quoting.""" - if reply_to_message_id is not None: - return ReplyParameters(reply_to_message_id) - - if quote is not None: - if quote: + if do_quote is not None: + if do_quote: return ReplyParameters(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] + 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: @@ -1675,29 +1727,14 @@ def build_reply_arguments( async def _parse_quote_arguments( self, do_quote: Optional[Union[bool, _ReplyKwargs]], - quote: Optional[bool], reply_to_message_id: Optional[int], reply_parameters: Optional["ReplyParameters"], ) -> tuple[Union[str, int], ReplyParameters]: - if quote and do_quote: - raise ValueError("The arguments `quote` and `do_quote` 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 quote is not None: - warn( - PTBDeprecationWarning( - "20.8", - "The `quote` parameter is deprecated in favor of the `do_quote` parameter. " - "Please update your code to use `do_quote` instead.", - ), - stacklevel=2, - ) - - effective_do_quote = quote or do_quote chat_id: Union[str, int] = self.chat_id # reply_parameters and reply_to_message_id overrule the do_quote parameter @@ -1705,11 +1742,11 @@ async def _parse_quote_arguments( effective_reply_parameters = reply_parameters elif reply_to_message_id is not None: effective_reply_parameters = ReplyParameters(message_id=reply_to_message_id) - elif isinstance(effective_do_quote, dict): - effective_reply_parameters = effective_do_quote["reply_parameters"] - chat_id = effective_do_quote["chat_id"] + elif isinstance(do_quote, dict): + effective_reply_parameters = do_quote["reply_parameters"] + chat_id = do_quote["chat_id"] else: - effective_reply_parameters = self._quote(effective_do_quote) + effective_reply_parameters = self._do_quote(do_quote) return chat_id, effective_reply_parameters @@ -1750,7 +1787,6 @@ async def reply_text( reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, disable_web_page_preview: Optional[bool] = None, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1773,11 +1809,10 @@ async def reply_text( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -1788,7 +1823,7 @@ async def reply_text( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_message( @@ -1830,7 +1865,6 @@ async def reply_markdown( reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, disable_web_page_preview: Optional[bool] = None, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1856,15 +1890,14 @@ async def reply_markdown( .. 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: - quote (:obj:`bool`, optional): |reply_quote| - - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -1874,7 +1907,7 @@ async def reply_markdown( :class:`telegram.Message`: On success, instance representing the message posted. """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_message( @@ -1916,7 +1949,6 @@ async def reply_markdown_v2( reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, disable_web_page_preview: Optional[bool] = None, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1942,11 +1974,10 @@ async def reply_markdown_v2( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -1956,7 +1987,7 @@ async def reply_markdown_v2( :class:`telegram.Message`: On success, instance representing the message posted. """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_message( @@ -1998,7 +2029,6 @@ async def reply_html( reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, disable_web_page_preview: Optional[bool] = None, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2024,11 +2054,10 @@ async def reply_html( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -2038,7 +2067,7 @@ async def reply_html( :class:`telegram.Message`: On success, instance representing the message posted. """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_message( @@ -2078,7 +2107,6 @@ async def reply_media_group( *, reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2104,11 +2132,10 @@ async def reply_media_group( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -2121,7 +2148,7 @@ async def reply_media_group( :class:`telegram.error.TelegramError` """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_media_group( @@ -2164,7 +2191,6 @@ async def reply_photo( reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, filename: Optional[str] = None, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2187,11 +2213,10 @@ async def reply_photo( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -2202,7 +2227,7 @@ async def reply_photo( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_photo( @@ -2251,7 +2276,6 @@ async def reply_audio( reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, filename: Optional[str] = None, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2274,11 +2298,10 @@ async def reply_audio( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -2289,7 +2312,7 @@ async def reply_audio( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_audio( @@ -2338,7 +2361,6 @@ async def reply_document( reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, filename: Optional[str] = None, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2361,11 +2383,10 @@ async def reply_document( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -2376,7 +2397,7 @@ async def reply_document( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_document( @@ -2427,7 +2448,6 @@ async def reply_animation( reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, filename: Optional[str] = None, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2450,11 +2470,10 @@ async def reply_animation( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -2465,7 +2484,7 @@ async def reply_animation( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_animation( @@ -2511,7 +2530,6 @@ async def reply_sticker( *, reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2534,11 +2552,10 @@ async def reply_sticker( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -2549,7 +2566,7 @@ async def reply_sticker( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_sticker( @@ -2598,7 +2615,6 @@ async def reply_video( reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, filename: Optional[str] = None, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2621,11 +2637,10 @@ async def reply_video( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -2636,7 +2651,7 @@ async def reply_video( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_video( @@ -2688,7 +2703,6 @@ async def reply_video_note( reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, filename: Optional[str] = None, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2711,11 +2725,10 @@ async def reply_video_note( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -2726,7 +2739,7 @@ async def reply_video_note( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_video_note( @@ -2770,7 +2783,6 @@ async def reply_voice( reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, filename: Optional[str] = None, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2793,11 +2805,10 @@ async def reply_voice( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -2808,7 +2819,7 @@ async def reply_voice( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_voice( @@ -2854,7 +2865,6 @@ async def reply_location( reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, location: Optional[Location] = None, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2877,11 +2887,10 @@ async def reply_location( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -2892,7 +2901,7 @@ async def reply_location( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_location( @@ -2941,7 +2950,6 @@ async def reply_venue( reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, venue: Optional[Venue] = None, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -2964,11 +2972,10 @@ async def reply_venue( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -2979,7 +2986,7 @@ async def reply_venue( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_venue( @@ -3026,7 +3033,6 @@ async def reply_contact( reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, contact: Optional[Contact] = None, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -3049,11 +3055,10 @@ async def reply_contact( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -3064,7 +3069,7 @@ async def reply_contact( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_contact( @@ -3116,7 +3121,6 @@ async def reply_poll( *, reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -3139,11 +3143,10 @@ async def reply_poll( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -3154,7 +3157,7 @@ async def reply_poll( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_poll( @@ -3202,7 +3205,6 @@ async def reply_dice( *, reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -3225,11 +3227,10 @@ async def reply_dice( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -3240,7 +3241,7 @@ async def reply_dice( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_dice( @@ -3319,7 +3320,6 @@ async def reply_game( *, reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -3342,11 +3342,10 @@ async def reply_game( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -3359,7 +3358,7 @@ async def reply_game( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_game( @@ -3414,7 +3413,6 @@ async def reply_invoice( *, reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -3436,6 +3434,9 @@ async def reply_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 @@ -3449,10 +3450,6 @@ async def reply_invoice( :paramref:`start_parameter ` is optional. Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| - - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -3463,7 +3460,7 @@ async def reply_invoice( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_invoice( @@ -3637,7 +3634,6 @@ async def reply_copy( *, reply_to_message_id: Optional[int] = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - quote: Optional[bool] = None, do_quote: Optional[Union[bool, _ReplyKwargs]] = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -3660,12 +3656,10 @@ async def reply_copy( .. versionchanged:: 21.1 |reply_same_thread| - Keyword Args: - quote (:obj:`bool`, optional): |reply_quote| + .. versionchanged:: 22.0 + |quote_removed| - .. versionadded:: 13.1 - .. deprecated:: 20.8 - This argument is deprecated in favor of :paramref:`do_quote` + Keyword Args: do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| Mutually exclusive with :paramref:`quote`. @@ -3676,7 +3670,7 @@ async def reply_copy( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, quote, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().copy_message( @@ -3748,7 +3742,7 @@ async def reply_paid_media( """ chat_id, effective_reply_parameters = await self._parse_quote_arguments( - do_quote, None, reply_to_message_id, reply_parameters + do_quote, reply_to_message_id, reply_parameters ) return await self.get_bot().send_paid_media( chat_id=chat_id, @@ -4539,6 +4533,43 @@ async def set_reaction( 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: Optional[JSONDict] = 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, + ) + def parse_entity(self, entity: MessageEntity) -> str: """Returns the text from a given :class:`telegram.MessageEntity`. diff --git a/telegram/_messageautodeletetimerchanged.py b/src/telegram/_messageautodeletetimerchanged.py similarity index 100% rename from telegram/_messageautodeletetimerchanged.py rename to src/telegram/_messageautodeletetimerchanged.py diff --git a/telegram/_messageentity.py b/src/telegram/_messageentity.py similarity index 100% rename from telegram/_messageentity.py rename to src/telegram/_messageentity.py diff --git a/telegram/_messageid.py b/src/telegram/_messageid.py similarity index 100% rename from telegram/_messageid.py rename to src/telegram/_messageid.py diff --git a/telegram/_messageorigin.py b/src/telegram/_messageorigin.py similarity index 100% rename from telegram/_messageorigin.py rename to src/telegram/_messageorigin.py diff --git a/telegram/_messagereactionupdated.py b/src/telegram/_messagereactionupdated.py similarity index 100% rename from telegram/_messagereactionupdated.py rename to src/telegram/_messagereactionupdated.py diff --git a/src/telegram/_ownedgift.py b/src/telegram/_ownedgift.py new file mode 100644 index 00000000000..875a01540f1 --- /dev/null +++ b/src/telegram/_ownedgift.py @@ -0,0 +1,419 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# 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, Optional + +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: Optional[JSONDict] = 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: Optional["Bot"] = 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: Optional[str] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.total_count: int = total_count + self.gifts: tuple[OwnedGift, ...] = parse_sequence_arg(gifts) + self.next_offset: Optional[str] = next_offset + + self._id_attrs = (self.total_count, self.gifts) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = 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. + prepaid_upgrade_star_count (:obj:`int`, optional): Number of Telegram Stars that were + paid by the sender for the ability to upgrade the gift. + + 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. + prepaid_upgrade_star_count (:obj:`int`): Optional. Number of Telegram Stars that were + paid by the sender for the ability to upgrade the gift. + + """ + + __slots__ = ( + "can_be_upgraded", + "convert_star_count", + "entities", + "gift", + "is_private", + "is_saved", + "owned_gift_id", + "prepaid_upgrade_star_count", + "send_date", + "sender_user", + "text", + "was_refunded", + ) + + def __init__( + self, + gift: Gift, + send_date: dtm.datetime, + owned_gift_id: Optional[str] = None, + sender_user: Optional[User] = None, + text: Optional[str] = None, + entities: Optional[Sequence[MessageEntity]] = None, + is_private: Optional[bool] = None, + is_saved: Optional[bool] = None, + can_be_upgraded: Optional[bool] = None, + was_refunded: Optional[bool] = None, + convert_star_count: Optional[int] = None, + prepaid_upgrade_star_count: Optional[int] = None, + *, + api_kwargs: Optional[JSONDict] = 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: Optional[str] = owned_gift_id + self.sender_user: Optional[User] = sender_user + self.text: Optional[str] = text + self.entities: tuple[MessageEntity, ...] = parse_sequence_arg(entities) + self.is_private: Optional[bool] = is_private + self.is_saved: Optional[bool] = is_saved + self.can_be_upgraded: Optional[bool] = can_be_upgraded + self.was_refunded: Optional[bool] = was_refunded + self.convert_star_count: Optional[int] = convert_star_count + self.prepaid_upgrade_star_count: Optional[int] = prepaid_upgrade_star_count + + self._id_attrs = (self.type, self.gift, self.send_date) + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = 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: Optional[list[str]] = 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. + + 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. + """ + + __slots__ = ( + "can_be_transferred", + "gift", + "is_saved", + "owned_gift_id", + "send_date", + "sender_user", + "transfer_star_count", + ) + + def __init__( + self, + gift: UniqueGift, + send_date: dtm.datetime, + owned_gift_id: Optional[str] = None, + sender_user: Optional[User] = None, + is_saved: Optional[bool] = None, + can_be_transferred: Optional[bool] = None, + transfer_star_count: Optional[int] = None, + *, + api_kwargs: Optional[JSONDict] = 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: Optional[str] = owned_gift_id + self.sender_user: Optional[User] = sender_user + self.is_saved: Optional[bool] = is_saved + self.can_be_transferred: Optional[bool] = can_be_transferred + self.transfer_star_count: Optional[int] = transfer_star_count + + self._id_attrs = (self.type, self.gift, self.send_date) + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = 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) + + return super().de_json(data=data, bot=bot) # type: ignore[return-value] diff --git a/telegram/_paidmedia.py b/src/telegram/_paidmedia.py similarity index 100% rename from telegram/_paidmedia.py rename to src/telegram/_paidmedia.py diff --git a/src/telegram/_paidmessagepricechanged.py b/src/telegram/_paidmessagepricechanged.py new file mode 100644 index 00000000000..d77cb6d54b0 --- /dev/null +++ b/src/telegram/_paidmessagepricechanged.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# 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 typing import Optional + +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: Optional[JSONDict] = 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/_payment/__init__.py b/src/telegram/_passport/__init__.py similarity index 100% rename from telegram/_payment/__init__.py rename to src/telegram/_passport/__init__.py diff --git a/telegram/_passport/credentials.py b/src/telegram/_passport/credentials.py similarity index 100% rename from telegram/_passport/credentials.py rename to src/telegram/_passport/credentials.py diff --git a/telegram/_passport/data.py b/src/telegram/_passport/data.py similarity index 100% rename from telegram/_passport/data.py rename to src/telegram/_passport/data.py diff --git a/telegram/_passport/encryptedpassportelement.py b/src/telegram/_passport/encryptedpassportelement.py similarity index 98% rename from telegram/_passport/encryptedpassportelement.py rename to src/telegram/_passport/encryptedpassportelement.py index ae7e6517ac9..c231c51640b 100644 --- a/telegram/_passport/encryptedpassportelement.py +++ b/src/telegram/_passport/encryptedpassportelement.py @@ -164,10 +164,6 @@ def __init__( reverse_side: Optional[PassportFile] = None, selfie: Optional[PassportFile] = None, translation: Optional[Sequence[PassportFile]] = None, - # TODO: Remove the credentials argument in 22.0 or later - credentials: Optional[ # pylint: disable=unused-argument # noqa: ARG002 - "Credentials" - ] = None, *, api_kwargs: Optional[JSONDict] = None, ): diff --git a/telegram/_passport/passportdata.py b/src/telegram/_passport/passportdata.py similarity index 100% rename from telegram/_passport/passportdata.py rename to src/telegram/_passport/passportdata.py diff --git a/telegram/_passport/passportelementerrors.py b/src/telegram/_passport/passportelementerrors.py similarity index 88% rename from telegram/_passport/passportelementerrors.py rename to src/telegram/_passport/passportelementerrors.py index d5a74462833..00c5bd50d7e 100644 --- a/telegram/_passport/passportelementerrors.py +++ b/src/telegram/_passport/passportelementerrors.py @@ -19,12 +19,12 @@ # pylint: disable=redefined-builtin """This module contains the classes that represent Telegram PassportElementError.""" +from collections.abc import Sequence from typing import Optional from telegram._telegramobject import TelegramObject +from telegram._utils.argumentparsing import parse_sequence_arg from telegram._utils.types import JSONDict -from telegram._utils.warnings import warn -from telegram.warnings import PTBDeprecationWarning class PassportElementError(TelegramObject): @@ -168,23 +168,30 @@ 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 (tuple[:obj:`str`]): List of base64-encoded file hashes. + + .. versionchanged:: 22.0 + |tupleclassattrs| message (:obj:`str`): Error message. """ - __slots__ = ("_file_hashes",) + __slots__ = ("file_hashes",) def __init__( self, type: str, - file_hashes: list[str], + file_hashes: Sequence[str], message: str, *, api_kwargs: Optional[JSONDict] = None, @@ -192,32 +199,9 @@ def __init__( # Required super().__init__("files", type, message, api_kwargs=api_kwargs) with self._unfrozen(): - self._file_hashes: list[str] = file_hashes - - self._id_attrs = (self.source, self.type, self.message, *tuple(file_hashes)) - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict` for details.""" - data = super().to_dict(recursive) - data["file_hashes"] = self._file_hashes - return data - - @property - def file_hashes(self) -> list[str]: - """List of base64-encoded file hashes. - - .. deprecated:: 20.6 - This attribute will return a tuple instead of a list in future major versions. - """ - warn( - PTBDeprecationWarning( - "20.6", - "The attribute `file_hashes` will return a tuple instead of a list in future major" - " versions.", - ), - stacklevel=2, - ) - return self._file_hashes + self.file_hashes: tuple[str, ...] = parse_sequence_arg(file_hashes) + + self._id_attrs = (self.source, self.type, self.message, self.file_hashes) class PassportElementErrorFrontSide(PassportElementError): @@ -386,7 +370,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: @@ -394,16 +381,20 @@ class PassportElementErrorTranslationFiles(PassportElementError): one of ``"passport"``, ``"driver_license"``, ``"identity_card"``, ``"internal_passport"``, ``"utility_bill"``, ``"bank_statement"``, ``"rental_agreement"``, ``"passport_registration"``, ``"temporary_registration"``. + file_hashes (tuple[:obj:`str`]): List of base64-encoded file hashes. + + .. versionchanged:: 22.0 + |tupleclassattrs| message (:obj:`str`): Error message. """ - __slots__ = ("_file_hashes",) + __slots__ = ("file_hashes",) def __init__( self, type: str, - file_hashes: list[str], + file_hashes: Sequence[str], message: str, *, api_kwargs: Optional[JSONDict] = None, @@ -411,33 +402,9 @@ def __init__( # Required super().__init__("translation_files", type, message, api_kwargs=api_kwargs) with self._unfrozen(): - self._file_hashes: list[str] = file_hashes - - self._id_attrs = (self.source, self.type, self.message, *tuple(file_hashes)) - - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict` for details.""" - data = super().to_dict(recursive) - data["file_hashes"] = self._file_hashes - return data - - @property - def file_hashes(self) -> list[str]: - """List of base64-encoded file hashes. - - .. deprecated:: 20.6 - This attribute will return a tuple instead of a list in future major versions. - """ - warn( - PTBDeprecationWarning( - "20.6", - "The attribute `file_hashes` will return a tuple instead of a list in future major" - " versions. See the stability policy:" - " https://docs.python-telegram-bot.org/en/stable/stability_policy.html", - ), - stacklevel=2, - ) - return self._file_hashes + self.file_hashes: tuple[str, ...] = parse_sequence_arg(file_hashes) + + self._id_attrs = (self.source, self.type, self.message, self.file_hashes) class PassportElementErrorUnspecified(PassportElementError): diff --git a/telegram/_passport/passportfile.py b/src/telegram/_passport/passportfile.py similarity index 84% rename from telegram/_passport/passportfile.py rename to src/telegram/_passport/passportfile.py index 2fe0d34e08b..1f944a2610d 100644 --- a/telegram/_passport/passportfile.py +++ b/src/telegram/_passport/passportfile.py @@ -18,13 +18,13 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains an object that represents a Encrypted PassportFile.""" +import datetime as dtm from typing import TYPE_CHECKING, Optional 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 -from telegram._utils.warnings import warn -from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram import Bot, File, FileCredentials @@ -45,11 +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. - .. deprecated:: 20.6 - This argument will only accept a datetime instead of an integer in future - major versions. + .. 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 @@ -58,11 +58,16 @@ 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 (: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_date", "file_id", "file_size", "file_unique_id", @@ -72,7 +77,7 @@ def __init__( self, file_id: str, file_unique_id: str, - file_date: int, + file_date: dtm.datetime, file_size: int, credentials: Optional["FileCredentials"] = None, *, @@ -84,7 +89,7 @@ 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 @@ -93,28 +98,16 @@ def __init__( self._freeze() - def to_dict(self, recursive: bool = True) -> JSONDict: - """See :meth:`telegram.TelegramObject.to_dict` for details.""" - data = super().to_dict(recursive) - data["file_date"] = self._file_date - return data + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "PassportFile": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) - @property - def file_date(self) -> int: - """:obj:`int`: Unix time when the file was uploaded. + # 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) - .. deprecated:: 20.6 - This attribute will return a datetime instead of a integer in future major versions. - """ - warn( - PTBDeprecationWarning( - "20.6", - "The attribute `file_date` will return a datetime instead of an integer in future" - " major versions.", - ), - stacklevel=2, - ) - return self._file_date + return super().de_json(data=data, bot=bot) @classmethod def de_json_decrypted( diff --git a/telegram/_payment/stars/__init__.py b/src/telegram/_payment/__init__.py similarity index 100% rename from telegram/_payment/stars/__init__.py rename to src/telegram/_payment/__init__.py diff --git a/telegram/_payment/invoice.py b/src/telegram/_payment/invoice.py similarity index 100% rename from telegram/_payment/invoice.py rename to src/telegram/_payment/invoice.py diff --git a/telegram/_payment/labeledprice.py b/src/telegram/_payment/labeledprice.py similarity index 100% rename from telegram/_payment/labeledprice.py rename to src/telegram/_payment/labeledprice.py diff --git a/telegram/_payment/orderinfo.py b/src/telegram/_payment/orderinfo.py similarity index 100% rename from telegram/_payment/orderinfo.py rename to src/telegram/_payment/orderinfo.py diff --git a/telegram/_payment/precheckoutquery.py b/src/telegram/_payment/precheckoutquery.py similarity index 100% rename from telegram/_payment/precheckoutquery.py rename to src/telegram/_payment/precheckoutquery.py diff --git a/telegram/_payment/refundedpayment.py b/src/telegram/_payment/refundedpayment.py similarity index 100% rename from telegram/_payment/refundedpayment.py rename to src/telegram/_payment/refundedpayment.py diff --git a/telegram/_payment/shippingaddress.py b/src/telegram/_payment/shippingaddress.py similarity index 100% rename from telegram/_payment/shippingaddress.py rename to src/telegram/_payment/shippingaddress.py diff --git a/telegram/_payment/shippingoption.py b/src/telegram/_payment/shippingoption.py similarity index 100% rename from telegram/_payment/shippingoption.py rename to src/telegram/_payment/shippingoption.py diff --git a/telegram/_payment/shippingquery.py b/src/telegram/_payment/shippingquery.py similarity index 100% rename from telegram/_payment/shippingquery.py rename to src/telegram/_payment/shippingquery.py diff --git a/telegram/_utils/__init__.py b/src/telegram/_payment/stars/__init__.py similarity index 100% rename from telegram/_utils/__init__.py rename to src/telegram/_payment/stars/__init__.py diff --git a/telegram/_payment/stars/affiliateinfo.py b/src/telegram/_payment/stars/affiliateinfo.py similarity index 89% rename from telegram/_payment/stars/affiliateinfo.py rename to src/telegram/_payment/stars/affiliateinfo.py index 80349290b44..64fd7224e23 100644 --- a/telegram/_payment/stars/affiliateinfo.py +++ b/src/telegram/_payment/stars/affiliateinfo.py @@ -48,10 +48,10 @@ class AffiliateInfo(TelegramObject): 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.StarTransactions.NANOSTAR_VALUE` shares of Telegram + :tg-const:`~telegram.constants.Nanostar.VALUE` shares of Telegram Stars received by the affiliate; from - :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MIN_AMOUNT` to - :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MAX_AMOUNT`; + :tg-const:`~telegram.constants.NanostarLimit.MIN_AMOUNT` to + :tg-const:`~telegram.constants.NanostarLimit.MAX_AMOUNT`; can be negative for refunds Attributes: @@ -64,10 +64,10 @@ class AffiliateInfo(TelegramObject): 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.StarTransactions.NANOSTAR_VALUE` shares of Telegram + :tg-const:`~telegram.constants.Nanostar.VALUE` shares of Telegram Stars received by the affiliate; from - :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MIN_AMOUNT` to - :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MAX_AMOUNT`; + :tg-const:`~telegram.constants.NanostarLimit.MIN_AMOUNT` to + :tg-const:`~telegram.constants.NanostarLimit.MAX_AMOUNT`; can be negative for refunds """ diff --git a/telegram/_payment/stars/revenuewithdrawalstate.py b/src/telegram/_payment/stars/revenuewithdrawalstate.py similarity index 100% rename from telegram/_payment/stars/revenuewithdrawalstate.py rename to src/telegram/_payment/stars/revenuewithdrawalstate.py diff --git a/src/telegram/_payment/stars/staramount.py b/src/telegram/_payment/stars/staramount.py new file mode 100644 index 00000000000..a8d61b2a118 --- /dev/null +++ b/src/telegram/_payment/stars/staramount.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# 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 an object that represents a Telegram StarAmount.""" + + +from typing import Optional + +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: Optional[int] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.amount: int = amount + self.nanostar_amount: Optional[int] = nanostar_amount + + self._id_attrs = (self.amount, self.nanostar_amount) + + self._freeze() diff --git a/telegram/_payment/stars/startransactions.py b/src/telegram/_payment/stars/startransactions.py similarity index 95% rename from telegram/_payment/stars/startransactions.py rename to src/telegram/_payment/stars/startransactions.py index 7ac1ef7e338..09f314985ff 100644 --- a/telegram/_payment/stars/startransactions.py +++ b/src/telegram/_payment/stars/startransactions.py @@ -52,9 +52,9 @@ class StarTransaction(TelegramObject): 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.StarTransactions.NANOSTAR_VALUE` shares of Telegram + :tg-const:`~telegram.constants.Nanostar.VALUE` shares of Telegram Stars transferred by the transaction; from 0 to - :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MAX_AMOUNT` + :tg-const:`~telegram.constants.NanostarLimit.MAX_AMOUNT` .. versionadded:: 21.9 date (:obj:`datetime.datetime`): Date the transaction was created as a datetime object. @@ -72,9 +72,9 @@ class StarTransaction(TelegramObject): 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.StarTransactions.NANOSTAR_VALUE` shares of Telegram + :tg-const:`~telegram.constants.Nanostar.VALUE` shares of Telegram Stars transferred by the transaction; from 0 to - :tg-const:`~telegram.constants.StarTransactionsLimit.NANOSTAR_MAX_AMOUNT` + :tg-const:`~telegram.constants.NanostarLimit.MAX_AMOUNT` .. versionadded:: 21.9 date (:obj:`datetime.datetime`): Date the transaction was created as a datetime object. diff --git a/telegram/_payment/stars/transactionpartner.py b/src/telegram/_payment/stars/transactionpartner.py similarity index 76% rename from telegram/_payment/stars/transactionpartner.py rename to src/telegram/_payment/stars/transactionpartner.py index 4efe50cb7ee..723e4d826c7 100644 --- a/telegram/_payment/stars/transactionpartner.py +++ b/src/telegram/_payment/stars/transactionpartner.py @@ -56,7 +56,7 @@ class TransactionPartner(TelegramObject): .. versionadded:: 21.4 - ..versionchanged:: 21.11 + .. versionchanged:: 21.11 Added :class:`TransactionPartnerChat` Args: @@ -281,55 +281,115 @@ class TransactionPartnerUser(TransactionPartner): """Describes a transaction with a user. Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`user` are equal. + 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 + 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. + 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 + subscription. Can be available only for + :tg-const:`telegram.constants.TransactionPartnerUser.INVOICE_PAYMENT` transactions. .. versionadded:: 21.8 paid_media (Sequence[:class:`telegram.PaidMedia`], optional): Information about the paid - media bought by the user. + 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. + 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 + 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 + 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. + 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 + 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. + 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. + 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 + 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 """ @@ -339,7 +399,9 @@ class TransactionPartnerUser(TransactionPartner): "invoice_payload", "paid_media", "paid_media_payload", + "premium_subscription_duration", "subscription_period", + "transaction_type", "user", ) @@ -352,11 +414,18 @@ def __init__( subscription_period: Optional[dtm.timedelta] = None, gift: Optional[Gift] = None, affiliate: Optional[AffiliateInfo] = None, + premium_subscription_duration: Optional[int] = None, + # temporarily optional to account for changed signature + transaction_type: Optional[str] = None, *, api_kwargs: Optional[JSONDict] = None, ) -> None: super().__init__(type=TransactionPartner.USER, api_kwargs=api_kwargs) + # tags: deprecated 22.1, bot api 9.0 + if transaction_type is None: + raise TypeError("`transaction_type` is a required argument since Bot API 9.0") + with self._unfrozen(): self.user: User = user self.affiliate: Optional[AffiliateInfo] = affiliate @@ -365,10 +434,13 @@ def __init__( self.paid_media_payload: Optional[str] = paid_media_payload self.subscription_period: Optional[dtm.timedelta] = subscription_period self.gift: Optional[Gift] = gift + self.premium_subscription_duration: Optional[int] = premium_subscription_duration + self.transaction_type: str = transaction_type self._id_attrs = ( self.type, self.user, + self.transaction_type, ) @classmethod diff --git a/telegram/_payment/successfulpayment.py b/src/telegram/_payment/successfulpayment.py similarity index 100% rename from telegram/_payment/successfulpayment.py rename to src/telegram/_payment/successfulpayment.py diff --git a/telegram/_poll.py b/src/telegram/_poll.py similarity index 100% rename from telegram/_poll.py rename to src/telegram/_poll.py diff --git a/telegram/_proximityalerttriggered.py b/src/telegram/_proximityalerttriggered.py similarity index 100% rename from telegram/_proximityalerttriggered.py rename to src/telegram/_proximityalerttriggered.py diff --git a/telegram/_reaction.py b/src/telegram/_reaction.py similarity index 100% rename from telegram/_reaction.py rename to src/telegram/_reaction.py diff --git a/telegram/_reply.py b/src/telegram/_reply.py similarity index 100% rename from telegram/_reply.py rename to src/telegram/_reply.py diff --git a/telegram/_replykeyboardmarkup.py b/src/telegram/_replykeyboardmarkup.py similarity index 100% rename from telegram/_replykeyboardmarkup.py rename to src/telegram/_replykeyboardmarkup.py diff --git a/telegram/_replykeyboardremove.py b/src/telegram/_replykeyboardremove.py similarity index 100% rename from telegram/_replykeyboardremove.py rename to src/telegram/_replykeyboardremove.py diff --git a/telegram/_sentwebappmessage.py b/src/telegram/_sentwebappmessage.py similarity index 100% rename from telegram/_sentwebappmessage.py rename to src/telegram/_sentwebappmessage.py diff --git a/telegram/_shared.py b/src/telegram/_shared.py similarity index 100% rename from telegram/_shared.py rename to src/telegram/_shared.py diff --git a/telegram/_story.py b/src/telegram/_story.py similarity index 100% rename from telegram/_story.py rename to src/telegram/_story.py diff --git a/src/telegram/_storyarea.py b/src/telegram/_storyarea.py new file mode 100644 index 00000000000..7335be68951 --- /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-2025 +# 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, Optional + +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: Optional[JSONDict] = 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: Optional[str] = None, + city: Optional[str] = None, + street: Optional[str] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(api_kwargs=api_kwargs) + self.country_code: str = country_code + self.state: Optional[str] = state + self.city: Optional[str] = city + self.street: Optional[str] = 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: Optional[JSONDict] = 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: Optional[LocationAddress] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(type=StoryAreaType.LOCATION, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.latitude: float = latitude + self.longitude: float = longitude + self.address: Optional[LocationAddress] = 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: Optional[bool] = None, + is_flipped: Optional[bool] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ) -> None: + super().__init__(type=StoryAreaType.SUGGESTED_REACTION, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.reaction_type: ReactionType = reaction_type + self.is_dark: Optional[bool] = is_dark + self.is_flipped: Optional[bool] = 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: Optional[JSONDict] = 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: Optional[JSONDict] = 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: Optional[JSONDict] = 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: Optional[JSONDict] = 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/telegram/_switchinlinequerychosenchat.py b/src/telegram/_switchinlinequerychosenchat.py similarity index 100% rename from telegram/_switchinlinequerychosenchat.py rename to src/telegram/_switchinlinequerychosenchat.py diff --git a/telegram/_telegramobject.py b/src/telegram/_telegramobject.py similarity index 99% rename from telegram/_telegramobject.py rename to src/telegram/_telegramobject.py index 1b1b30ed092..ca0d20555eb 100644 --- a/telegram/_telegramobject.py +++ b/src/telegram/_telegramobject.py @@ -290,7 +290,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) diff --git a/src/telegram/_uniquegift.py b/src/telegram/_uniquegift.py new file mode 100644 index 00000000000..fa494a8e55a --- /dev/null +++ b/src/telegram/_uniquegift.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python +# +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# 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.""" +from typing import TYPE_CHECKING, Final, Optional + +from telegram import constants +from telegram._files.sticker import Sticker +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 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: Optional[JSONDict] = 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: Optional["Bot"] = 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: Optional[JSONDict] = 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: Optional["Bot"] = 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: Optional[JSONDict] = 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: Optional[JSONDict] = 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: Optional["Bot"] = 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: + 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. + + Attributes: + 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. + + """ + + __slots__ = ( + "backdrop", + "base_name", + "model", + "name", + "number", + "symbol", + ) + + def __init__( + self, + base_name: str, + name: str, + number: int, + model: UniqueGiftModel, + symbol: UniqueGiftSymbol, + backdrop: UniqueGiftBackdrop, + *, + api_kwargs: Optional[JSONDict] = None, + ): + super().__init__(api_kwargs=api_kwargs) + 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._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: Optional["Bot"] = 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) + + 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` + or :attr:`TRANSFER`. + 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. + + Attributes: + gift (:class:`UniqueGift`): Information about the gift. + origin (:obj:`str`): Origin of the gift. Currently, either :attr:`UPGRADE` + or :attr:`TRANSFER`. + 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. + + """ + + UPGRADE: Final[str] = constants.UniqueGiftInfoOrigin.UPGRADE + """:const:`telegram.constants.UniqueGiftInfoOrigin.UPGRADE`""" + TRANSFER: Final[str] = constants.UniqueGiftInfoOrigin.TRANSFER + """:const:`telegram.constants.UniqueGiftInfoOrigin.TRANSFER`""" + + __slots__ = ( + "gift", + "origin", + "owned_gift_id", + "transfer_star_count", + ) + + def __init__( + self, + gift: UniqueGift, + origin: str, + owned_gift_id: Optional[str] = None, + transfer_star_count: Optional[int] = None, + *, + api_kwargs: Optional[JSONDict] = None, + ): + 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: Optional[str] = owned_gift_id + self.transfer_star_count: Optional[int] = transfer_star_count + + self._id_attrs = (self.gift, self.origin) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "UniqueGiftInfo": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["gift"] = de_json_optional(data.get("gift"), UniqueGift, bot) + + return super().de_json(data=data, bot=bot) diff --git a/telegram/_update.py b/src/telegram/_update.py similarity index 100% rename from telegram/_update.py rename to src/telegram/_update.py diff --git a/telegram/_user.py b/src/telegram/_user.py similarity index 98% rename from telegram/_user.py rename to src/telegram/_user.py index 640a3573acc..ce6c3bbbb7b 100644 --- a/telegram/_user.py +++ b/src/telegram/_user.py @@ -247,6 +247,9 @@ async def get_profile_photos( 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, @@ -1698,6 +1701,46 @@ async def send_gift( api_kwargs=api_kwargs, ) + async def gift_premium_subscription( + self, + month_count: int, + star_count: int, + text: Optional[str] = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_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, + ) -> 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: Union[str, int], diff --git a/telegram/_userprofilephotos.py b/src/telegram/_userprofilephotos.py similarity index 100% rename from telegram/_userprofilephotos.py rename to src/telegram/_userprofilephotos.py diff --git a/telegram/ext/_handlers/__init__.py b/src/telegram/_utils/__init__.py similarity index 100% rename from telegram/ext/_handlers/__init__.py rename to src/telegram/_utils/__init__.py diff --git a/telegram/_utils/argumentparsing.py b/src/telegram/_utils/argumentparsing.py similarity index 100% rename from telegram/_utils/argumentparsing.py rename to src/telegram/_utils/argumentparsing.py diff --git a/telegram/_utils/datetime.py b/src/telegram/_utils/datetime.py similarity index 100% rename from telegram/_utils/datetime.py rename to src/telegram/_utils/datetime.py diff --git a/telegram/_utils/defaultvalue.py b/src/telegram/_utils/defaultvalue.py similarity index 100% rename from telegram/_utils/defaultvalue.py rename to src/telegram/_utils/defaultvalue.py diff --git a/telegram/_utils/entities.py b/src/telegram/_utils/entities.py similarity index 100% rename from telegram/_utils/entities.py rename to src/telegram/_utils/entities.py diff --git a/telegram/_utils/enum.py b/src/telegram/_utils/enum.py similarity index 100% rename from telegram/_utils/enum.py rename to src/telegram/_utils/enum.py diff --git a/telegram/_utils/files.py b/src/telegram/_utils/files.py similarity index 97% rename from telegram/_utils/files.py rename to src/telegram/_utils/files.py index 8bce30d64c5..a750e1512e1 100644 --- a/telegram/_utils/files.py +++ b/src/telegram/_utils/files.py @@ -59,7 +59,7 @@ 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("Union[bytes, InputFile, str, Path]", obj) filename = guess_file_name(obj) @@ -151,7 +151,7 @@ def parse_file_input( # pylint: disable=too-many-return-statements 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 100% rename from telegram/_utils/logging.py rename to src/telegram/_utils/logging.py diff --git a/telegram/_utils/markup.py b/src/telegram/_utils/markup.py similarity index 100% rename from telegram/_utils/markup.py rename to src/telegram/_utils/markup.py diff --git a/telegram/_utils/repr.py b/src/telegram/_utils/repr.py similarity index 100% rename from telegram/_utils/repr.py rename to src/telegram/_utils/repr.py diff --git a/telegram/_utils/strings.py b/src/telegram/_utils/strings.py similarity index 100% rename from telegram/_utils/strings.py rename to src/telegram/_utils/strings.py diff --git a/telegram/_utils/types.py b/src/telegram/_utils/types.py similarity index 100% rename from telegram/_utils/types.py rename to src/telegram/_utils/types.py diff --git a/telegram/_utils/warnings.py b/src/telegram/_utils/warnings.py similarity index 100% rename from telegram/_utils/warnings.py rename to src/telegram/_utils/warnings.py diff --git a/telegram/_utils/warnings_transition.py b/src/telegram/_utils/warnings_transition.py similarity index 97% rename from telegram/_utils/warnings_transition.py rename to src/telegram/_utils/warnings_transition.py index cd9fecd7562..7aca62c2ac6 100644 --- a/telegram/_utils/warnings_transition.py +++ b/src/telegram/_utils/warnings_transition.py @@ -35,12 +35,12 @@ def build_deprecation_warning_message( object_type: str, bot_api_version: str, ) -> str: - """Builds a warning message for the transition in API when an object is renamed. + """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 renamed to '{new_name}' in Bot API " + 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}'." ) diff --git a/telegram/_version.py b/src/telegram/_version.py similarity index 96% rename from telegram/_version.py rename to src/telegram/_version.py index 22c0fdc63d6..412650e88d6 100644 --- a/telegram/_version.py +++ b/src/telegram/_version.py @@ -51,6 +51,6 @@ def __str__(self) -> str: __version_info__: Final[Version] = Version( - major=21, minor=11, micro=1, releaselevel="final", serial=0 + major=22, minor=1, 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 100% rename from telegram/_videochat.py rename to src/telegram/_videochat.py diff --git a/telegram/_webappdata.py b/src/telegram/_webappdata.py similarity index 100% rename from telegram/_webappdata.py rename to src/telegram/_webappdata.py diff --git a/telegram/_webappinfo.py b/src/telegram/_webappinfo.py similarity index 100% rename from telegram/_webappinfo.py rename to src/telegram/_webappinfo.py diff --git a/telegram/_webhookinfo.py b/src/telegram/_webhookinfo.py similarity index 100% rename from telegram/_webhookinfo.py rename to src/telegram/_webhookinfo.py diff --git a/telegram/_writeaccessallowed.py b/src/telegram/_writeaccessallowed.py similarity index 100% rename from telegram/_writeaccessallowed.py rename to src/telegram/_writeaccessallowed.py diff --git a/telegram/constants.py b/src/telegram/constants.py similarity index 87% rename from telegram/constants.py rename to src/telegram/constants.py index e66eedca995..2c4b7526354 100644 --- a/telegram/constants.py +++ b/src/telegram/constants.py @@ -46,6 +46,7 @@ "BotDescriptionLimit", "BotNameLimit", "BulkRequestLimit", + "BusinessLimit", "CallbackQueryLimit", "ChatAction", "ChatBoostSources", @@ -74,6 +75,9 @@ "InlineQueryResultsButtonLimit", "InputMediaType", "InputPaidMediaType", + "InputProfilePhotoType", + "InputStoryContentLimit", + "InputStoryContentType", "InvoiceLimit", "KeyboardButtonRequestUsersLimit", "LocationLimit", @@ -85,11 +89,15 @@ "MessageLimit", "MessageOriginType", "MessageType", + "Nanostar", + "NanostarLimit", + "OwnedGiftType", "PaidMediaType", "ParseMode", "PollLimit", "PollType", "PollingLimit", + "PremiumSubscription", "ProfileAccentColor", "ReactionEmoji", "ReactionType", @@ -101,7 +109,13 @@ "StickerLimit", "StickerSetLimit", "StickerType", + "StoryAreaPositionLimit", + "StoryAreaTypeLimit", + "StoryAreaTypeType", + "StoryLimit", "TransactionPartnerType", + "TransactionPartnerUser", + "UniqueGiftInfoOrigin", "UpdateType", "UserProfilePhotosLimit", "VerifyLimit", @@ -155,7 +169,7 @@ class _AccentColor(NamedTuple): #: :data:`telegram.__bot_api_version_info__`. #: #: .. versionadded:: 20.0 -BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=8, minor=3) +BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=9, minor=0) #: :obj:`str`: Telegram Bot API #: version supported by this version of `python-telegram-bot`. Also available as #: :data:`telegram.__bot_api_version__`. @@ -702,6 +716,63 @@ class BulkRequestLimit(IntEnum): """: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`. + """ + 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`. + """ + 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 @@ -887,8 +958,12 @@ class ChatSubscriptionLimit(IntEnum): """: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 = 2500 - """:obj:`int`: Amount of stars a user pays, maximum 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): @@ -1369,12 +1444,95 @@ class InputPaidMediaType(StringEnum): """: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__ = () @@ -1389,22 +1547,6 @@ class InlineQueryLimit(IntEnum): 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): @@ -1437,12 +1579,12 @@ class InlineQueryResultsButtonLimit(IntEnum): __slots__ = () - MIN_START_PARAMETER_LENGTH = InlineQueryLimit.MIN_SWITCH_PM_TEXT_LENGTH + 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 = InlineQueryLimit.MAX_SWITCH_PM_TEXT_LENGTH + 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`.""" @@ -1959,6 +2101,11 @@ class MessageType(StringEnum): .. versionadded:: 20.8 """ + GIFT = "gift" + """:obj:`str`: Messages with :attr:`telegram.Message.gift`. + + .. versionadded:: 22.1 + """ GIVEAWAY = "giveaway" """:obj:`str`: Messages with :attr:`telegram.Message.giveaway`. @@ -2002,6 +2149,11 @@ class MessageType(StringEnum): .. versionadded:: 21.4 """ + PAID_MESSAGE_PRICE_CHANGED = "paid_message_price_changed" + """:obj:`str`: Messages with :attr:`telegram.Message.paid_message_price_changed`. + + .. versionadded:: Next.VERSION + """ PASSPORT_DATA = "passport_data" """:obj:`str`: Messages with :attr:`telegram.Message.passport_data`.""" PHOTO = "photo" @@ -2042,6 +2194,11 @@ class MessageType(StringEnum): """: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`. @@ -2075,6 +2232,69 @@ class MessageType(StringEnum): """ +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 @@ -2112,6 +2332,58 @@ class PollingLimit(IntEnum): """ +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`. @@ -2465,19 +2737,27 @@ class RevenueWithdrawalStateType(StringEnum): """:obj:`str`: A withdrawal failed and the transaction was refunded.""" +# tags: deprecated 22.1, bot api 9.0 class StarTransactions(FloatEnum): """This enum contains constants for :class:`telegram.StarTransaction`. The enum members of this enumeration are instances of :class:`float` and can be treated as such. .. versionadded:: 21.9 + + .. deprecated:: 22.1 + This class will be removed as its only member :attr:`NANOSTAR_VALUE` will be replaced + by :attr:`telegram.constants.Nanostar.VALUE`. """ __slots__ = () - NANOSTAR_VALUE = 1 / 1000000000 + NANOSTAR_VALUE = Nanostar.VALUE """:obj:`float`: The value of one nanostar as used in :attr:`telegram.StarTransaction.nanostar_amount`. + + .. deprecated:: 22.1 + This member will be replaced by :attr:`telegram.constants.Nanostar.VALUE`. """ @@ -2499,19 +2779,27 @@ class StarTransactionsLimit(IntEnum): """:obj:`int`: Maximum value allowed for the :paramref:`~telegram.Bot.get_star_transactions.limit` parameter of :meth:`telegram.Bot.get_star_transactions`.""" - NANOSTAR_MIN_AMOUNT = -999999999 + # tags: deprecated 22.1, bot api 9.0 + NANOSTAR_MIN_AMOUNT = NanostarLimit.MIN_AMOUNT """:obj:`int`: Minimum value allowed for :paramref:`~telegram.AffiliateInfo.nanostar_amount` parameter of :class:`telegram.AffiliateInfo`. .. versionadded:: 21.9 + + .. deprecated:: 22.1 + This member will be replaced by :attr:`telegram.constants.NanostarLimit.MIN_AMOUNT`. """ - NANOSTAR_MAX_AMOUNT = 999999999 + # tags: deprecated 22.1, bot api 9.0 + NANOSTAR_MAX_AMOUNT = NanostarLimit.MAX_AMOUNT """:obj:`int`: Maximum value allowed for :paramref:`~telegram.StarTransaction.nanostar_amount` parameter of :class:`telegram.StarTransaction` and :paramref:`~telegram.AffiliateInfo.nanostar_amount` parameter of :class:`telegram.AffiliateInfo`. .. versionadded:: 21.9 + + .. deprecated:: 22.1 + This member will be replaced by :attr:`telegram.constants.NanostarLimit.MAX_AMOUNT`. """ @@ -2648,6 +2936,98 @@ class StickerType(StringEnum): """: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(StringEnum): + """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.caption`` parameter of + :meth:`telegram.Bot.post_story`.""" + ACTIVITY_TWELVE_HOURS = 12 * 3600 + """:obj:`int`: Possible value for :paramref:`~telegram.Bot.post_story.caption`` parameter of + :meth:`telegram.Bot.post_story`.""" + ACTIVITY_ONE_DAY = 86400 + """:obj:`int`: Possible value for :paramref:`~telegram.Bot.post_story.caption`` parameter of + :meth:`telegram.Bot.post_story`.""" + ACTIVITY_TWO_DAYS = 2 * 86400 + """:obj:`int`: Possible value for :paramref:`~telegram.Bot.post_story.caption`` parameter of + :meth:`telegram.Bot.post_story`.""" + + 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. @@ -2683,6 +3063,38 @@ class TransactionPartnerType(StringEnum): """: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. @@ -2785,6 +3197,21 @@ class PollType(StringEnum): """: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__ = () + + UPGRADE = "upgrade" + """:obj:`str` gift upgraded""" + TRANSFER = "transfer" + """:obj:`str` gift transfered""" + + 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. @@ -2956,12 +3383,14 @@ class InvoiceLimit(IntEnum): .. versionadded:: 21.6 """ - MAX_STAR_COUNT = 2500 + MAX_STAR_COUNT = 10000 """: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. """ SUBSCRIPTION_PERIOD = dtm.timedelta(days=30).total_seconds() """:obj:`int`: The period of time for which the subscription is active before @@ -2970,11 +3399,13 @@ class InvoiceLimit(IntEnum): .. versionadded:: 21.8 """ - SUBSCRIPTION_MAX_PRICE = 2500 + 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. """ diff --git a/telegram/error.py b/src/telegram/error.py similarity index 100% rename from telegram/error.py rename to src/telegram/error.py diff --git a/telegram/ext/__init__.py b/src/telegram/ext/__init__.py similarity index 100% rename from telegram/ext/__init__.py rename to src/telegram/ext/__init__.py diff --git a/telegram/ext/_aioratelimiter.py b/src/telegram/ext/_aioratelimiter.py similarity index 100% rename from telegram/ext/_aioratelimiter.py rename to src/telegram/ext/_aioratelimiter.py diff --git a/telegram/ext/_application.py b/src/telegram/ext/_application.py similarity index 96% rename from telegram/ext/_application.py rename to src/telegram/ext/_application.py index 6c405980230..e856fa85321 100644 --- a/telegram/ext/_application.py +++ b/src/telegram/ext/_application.py @@ -355,7 +355,7 @@ def __init__( self.__create_task_tasks: set[asyncio.Task] = set() # Used for awaiting tasks upon exit self.__stop_running_marker = asyncio.Event() - async def __aenter__(self: _AppType) -> _AppType: # noqa: PYI019 + async def __aenter__(self: _AppType) -> _AppType: """|async_context_manager| :meth:`initializes ` the App. Returns: @@ -741,10 +741,6 @@ def run_polling( poll_interval: float = 0.0, timeout: int = 10, bootstrap_retries: int = 0, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, allowed_updates: Optional[Sequence[str]] = None, drop_pending_updates: Optional[bool] = None, close_loop: bool = True, @@ -776,6 +772,11 @@ def run_polling( .. include:: inclusions/application_run_tip.rst + .. 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``. @@ -794,38 +795,6 @@ def run_polling( The default value will be changed to from ``-1`` to ``0``. Indefinite retries during bootstrapping are not recommended. - read_timeout (:obj:`float`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.read_timeout`. Defaults to - :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. - - .. versionchanged:: 20.7 - Defaults to :attr:`~telegram.request.BaseRequest.DEFAULT_NONE` instead of - ``2``. - - .. deprecated:: 20.7 - Deprecated in favor of setting the timeout via - :meth:`telegram.ext.ApplicationBuilder.get_updates_read_timeout`. - 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`. - - .. deprecated:: 20.7 - Deprecated in favor of setting the timeout via - :meth:`telegram.ext.ApplicationBuilder.get_updates_write_timeout`. - 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`. - - .. deprecated:: 20.7 - Deprecated in favor of setting the timeout via - :meth:`telegram.ext.ApplicationBuilder.get_updates_connect_timeout`. - 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`. - - .. deprecated:: 20.7 - Deprecated in favor of setting the timeout via - :meth:`telegram.ext.ApplicationBuilder.get_updates_pool_timeout`. 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 (Sequence[:obj:`str`], optional): Passed to @@ -857,16 +826,6 @@ def run_polling( "Application.run_polling is only available if the application has an Updater." ) - if (read_timeout, write_timeout, connect_timeout, pool_timeout) != ((DEFAULT_NONE,) * 4): - warn( - PTBDeprecationWarning( - "20.6", - "Setting timeouts via `Application.run_polling` is deprecated. " - "Please use `ApplicationBuilder.get_updates_*_timeout` instead.", - ), - stacklevel=2, - ) - def error_callback(exc: TelegramError) -> None: self.create_task(self.process_error(error=exc, update=None)) @@ -875,10 +834,6 @@ 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 diff --git a/telegram/ext/_applicationbuilder.py b/src/telegram/ext/_applicationbuilder.py similarity index 96% rename from telegram/ext/_applicationbuilder.py rename to src/telegram/ext/_applicationbuilder.py index 82885e7d45e..0b258584ab5 100644 --- a/telegram/ext/_applicationbuilder.py +++ b/src/telegram/ext/_applicationbuilder.py @@ -35,7 +35,6 @@ ODVInput, SocketOpt, ) -from telegram._utils.warnings import warn from telegram.ext._application import Application from telegram.ext._baseupdateprocessor import BaseUpdateProcessor, SimpleUpdateProcessor from telegram.ext._contexttypes import ContextTypes @@ -45,7 +44,6 @@ from telegram.ext._utils.types import BD, BT, CCT, CD, JQ, UD from telegram.request import BaseRequest from telegram.request._httpxrequest import HTTPXRequest -from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram import Update @@ -124,6 +122,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 """ @@ -516,30 +517,6 @@ 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: - """Legacy name for :meth:`proxy`, kept for backward compatibility. - - .. seealso:: :meth:`get_updates_proxy` - - .. deprecated:: 20.7 - - Args: - proxy_url (:obj:`str` | ``httpx.Proxy`` | ``httpx.URL``): See - :paramref:`telegram.ext.ApplicationBuilder.proxy.proxy`. - - Returns: - :class:`ApplicationBuilder`: The same builder with the updated argument. - """ - warn( - PTBDeprecationWarning( - "20.7", - "`ApplicationBuilder.proxy_url` is deprecated. Use `ApplicationBuilder.proxy` " - "instead.", - ), - stacklevel=2, - ) - return self.proxy(proxy_url) - def proxy(self: BuilderType, proxy: Union[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`. @@ -750,30 +727,6 @@ 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: - """Legacy name for :meth:`get_updates_proxy`, kept for backward compatibility. - - .. seealso:: :meth:`proxy` - - .. deprecated:: 20.7 - - Args: - get_updates_proxy_url (:obj:`str` | ``httpx.Proxy`` | ``httpx.URL``): See - :paramref:`telegram.ext.ApplicationBuilder.get_updates_proxy.get_updates_proxy`. - - Returns: - :class:`ApplicationBuilder`: The same builder with the updated argument. - """ - warn( - PTBDeprecationWarning( - "20.7", - "`ApplicationBuilder.get_updates_proxy_url` is deprecated. Use " - "`ApplicationBuilder.get_updates_proxy` instead.", - ), - stacklevel=2, - ) - return self.get_updates_proxy(get_updates_proxy_url) - def get_updates_proxy( self: BuilderType, get_updates_proxy: Union[str, httpx.Proxy, httpx.URL] ) -> BuilderType: diff --git a/telegram/ext/_basepersistence.py b/src/telegram/ext/_basepersistence.py similarity index 100% rename from telegram/ext/_basepersistence.py rename to src/telegram/ext/_basepersistence.py diff --git a/telegram/ext/_baseratelimiter.py b/src/telegram/ext/_baseratelimiter.py similarity index 100% rename from telegram/ext/_baseratelimiter.py rename to src/telegram/ext/_baseratelimiter.py diff --git a/telegram/ext/_baseupdateprocessor.py b/src/telegram/ext/_baseupdateprocessor.py similarity index 99% rename from telegram/ext/_baseupdateprocessor.py rename to src/telegram/ext/_baseupdateprocessor.py index ea9649d1f68..c08afec0b41 100644 --- a/telegram/ext/_baseupdateprocessor.py +++ b/src/telegram/ext/_baseupdateprocessor.py @@ -74,7 +74,7 @@ def __init__(self, max_concurrent_updates: int): raise ValueError("`max_concurrent_updates` must be a positive integer!") self._semaphore = TrackedBoundedSemaphore(self.max_concurrent_updates) - async def __aenter__(self: _BUPT) -> _BUPT: # noqa: PYI019 + async def __aenter__(self: _BUPT) -> _BUPT: """|async_context_manager| :meth:`initializes ` the Processor. Returns: diff --git a/telegram/ext/_callbackcontext.py b/src/telegram/ext/_callbackcontext.py similarity index 100% rename from telegram/ext/_callbackcontext.py rename to src/telegram/ext/_callbackcontext.py diff --git a/telegram/ext/_callbackdatacache.py b/src/telegram/ext/_callbackdatacache.py similarity index 99% rename from telegram/ext/_callbackdatacache.py rename to src/telegram/ext/_callbackdatacache.py index 1052bd5a2a5..a24befd719d 100644 --- a/telegram/ext/_callbackdatacache.py +++ b/src/telegram/ext/_callbackdatacache.py @@ -347,7 +347,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 ) diff --git a/telegram/ext/_contexttypes.py b/src/telegram/ext/_contexttypes.py similarity index 100% rename from telegram/ext/_contexttypes.py rename to src/telegram/ext/_contexttypes.py diff --git a/telegram/ext/_defaults.py b/src/telegram/ext/_defaults.py similarity index 80% rename from telegram/ext/_defaults.py rename to src/telegram/ext/_defaults.py index 86fb59a38fd..d7134766695 100644 --- a/telegram/ext/_defaults.py +++ b/src/telegram/ext/_defaults.py @@ -18,14 +18,15 @@ # 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 Any, NoReturn, Optional, final +from typing import TYPE_CHECKING, Any, NoReturn, Optional, final -from telegram import LinkPreviewOptions from telegram._utils.datetime import UTC -from telegram._utils.types import ODVInput from telegram._utils.warnings import warn from telegram.warnings import PTBDeprecationWarning +if TYPE_CHECKING: + from telegram import LinkPreviewOptions + @final class Defaults: @@ -38,23 +39,15 @@ class Defaults: 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| - disable_web_page_preview (:obj:`bool`, optional): Disables link previews for links in this - message. Mutually exclusive with :paramref:`link_preview_options`. - - .. deprecated:: 20.8 - Use :paramref:`link_preview_options` instead. This parameter will be removed in - future versions. - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply|. Will be used for :attr:`telegram.ReplyParameters.allow_sending_without_reply`. - quote (:obj:`bool`, optional): |reply_quote| - - .. deprecated:: 20.8 - Use :paramref:`do_quote` instead. This parameter will be removed in future - versions. 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 @@ -135,8 +128,6 @@ 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: dtm.tzinfo = UTC, block: bool = True, allow_sending_without_reply: Optional[bool] = None, @@ -164,37 +155,9 @@ def __init__( stacklevel=2, ) - if disable_web_page_preview is not None and link_preview_options is not None: - raise ValueError( - "`disable_web_page_preview` and `link_preview_options` are mutually exclusive." - ) - if quote is not None and do_quote is not None: - raise ValueError("`quote` and `do_quote` are mutually exclusive") - if disable_web_page_preview is not None: - warn( - PTBDeprecationWarning( - "20.8", - "`Defaults.disable_web_page_preview` is deprecated. Use " - "`Defaults.link_preview_options` instead.", - ), - stacklevel=2, - ) - self._link_preview_options: Optional[LinkPreviewOptions] = LinkPreviewOptions( - is_disabled=disable_web_page_preview - ) - else: - self._link_preview_options = link_preview_options + self._link_preview_options = link_preview_options + self._do_quote = do_quote - if quote is not None: - warn( - PTBDeprecationWarning( - "20.8", "`Defaults.quote` is deprecated. Use `Defaults.do_quote` instead." - ), - stacklevel=2, - ) - self._do_quote: Optional[bool] = quote - else: - self._do_quote = do_quote # Gather all defaults that actually have a default value self._api_defaults = {} for kwarg in ( @@ -223,9 +186,9 @@ def __hash__(self) -> int: ( self._parse_mode, self._disable_notification, - self.disable_web_page_preview, + self._link_preview_options, self._allow_sending_without_reply, - self.quote, + self._do_quote, self._tzinfo, self._block, self._protect_content, @@ -329,23 +292,6 @@ def disable_notification(self, _: object) -> NoReturn: "You can not assign a new value to disable_notification after initialization." ) - @property - def disable_web_page_preview(self) -> ODVInput[bool]: - """:obj:`bool`: Optional. Disables link previews for links in all outgoing - messages. - - .. deprecated:: 20.8 - Use :attr:`link_preview_options` instead. This attribute will be removed in future - versions. - """ - return self._link_preview_options.is_disabled if self._link_preview_options else None - - @disable_web_page_preview.setter - def disable_web_page_preview(self, _: 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 @@ -359,20 +305,6 @@ def allow_sending_without_reply(self, _: object) -> NoReturn: "You can not assign a new value to allow_sending_without_reply after initialization." ) - @property - def quote(self) -> Optional[bool]: - """:obj:`bool`: Optional. |reply_quote| - - .. deprecated:: 20.8 - Use :attr:`do_quote` instead. This attribute will be removed in future - versions. - """ - return self._do_quote if self._do_quote is not None else None - - @quote.setter - def quote(self, _: object) -> NoReturn: - raise AttributeError("You can not assign a new value to quote after initialization.") - @property def tzinfo(self) -> dtm.tzinfo: """:obj:`tzinfo`: A timezone to be used for all date(time) objects appearing diff --git a/telegram/ext/_dictpersistence.py b/src/telegram/ext/_dictpersistence.py similarity index 99% rename from telegram/ext/_dictpersistence.py rename to src/telegram/ext/_dictpersistence.py index da9749dfc85..758a8dd5436 100644 --- a/telegram/ext/_dictpersistence.py +++ b/src/telegram/ext/_dictpersistence.py @@ -145,7 +145,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 diff --git a/telegram/ext/_extbot.py b/src/telegram/ext/_extbot.py similarity index 90% rename from telegram/ext/_extbot.py rename to src/telegram/ext/_extbot.py index f77cfbf631b..7afadaa89fa 100644 --- a/telegram/ext/_extbot.py +++ b/src/telegram/ext/_extbot.py @@ -36,6 +36,7 @@ from uuid import uuid4 from telegram import ( + AcceptedGiftTypes, Animation, Audio, Bot, @@ -62,22 +63,27 @@ InlineKeyboardMarkup, InlineQueryResultsButton, InputMedia, + InputPaidMedia, InputPollOption, + InputProfilePhoto, LinkPreviewOptions, Location, MaskPosition, MenuButton, Message, MessageId, + OwnedGifts, PhotoSize, Poll, PreparedInlineMessage, ReactionType, ReplyParameters, SentWebAppMessage, + StarAmount, StarTransactions, Sticker, StickerSet, + Story, TelegramObject, Update, User, @@ -114,12 +120,13 @@ InputMediaDocument, InputMediaPhoto, InputMediaVideo, - InputPaidMedia, InputSticker, + InputStoryContent, LabeledPrice, MessageEntity, PassportElementError, ShippingOption, + StoryArea, ) from telegram.ext import BaseRateLimiter, Defaults @@ -253,7 +260,7 @@ def __init__( return if not isinstance(arbitrary_callback_data, bool): - maxsize = cast(int, arbitrary_callback_data) + maxsize = cast("int", arbitrary_callback_data) else: maxsize = 1024 @@ -466,7 +473,11 @@ def _insert_defaults(self, data: dict[str, object]) -> None: with copied_val._unfrozen(): 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: @@ -4242,6 +4253,36 @@ async def set_message_reaction( 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: Optional[str] = None, + text_parse_mode: ODVInput[str] = DEFAULT_NONE, + text_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, + rate_limit_args: Optional[RLARGS] = 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, @@ -4262,6 +4303,432 @@ async def get_business_connection( 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: Optional[bool] = None, + exclude_saved: Optional[bool] = None, + exclude_unlimited: Optional[bool] = None, + exclude_limited: Optional[bool] = None, + exclude_unique: Optional[bool] = None, + sort_by_price: Optional[bool] = None, + offset: Optional[str] = None, + limit: Optional[int] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = None, + ) -> 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_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_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: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = 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: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = 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: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = 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: Optional[str] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Optional[Sequence["MessageEntity"]] = None, + areas: Optional[Sequence["StoryArea"]] = None, + post_to_chat_page: Optional[bool] = 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: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = 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: Optional[str] = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Optional[Sequence["MessageEntity"]] = None, + areas: Optional[Sequence["StoryArea"]] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_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, + ) -> 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: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = 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: 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, + rate_limit_args: Optional[RLARGS] = 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: 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, + rate_limit_args: Optional[RLARGS] = 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: 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, + rate_limit_args: Optional[RLARGS] = 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: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = 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: 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, + rate_limit_args: Optional[RLARGS] = 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: 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, + rate_limit_args: Optional[RLARGS] = 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: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = 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: Optional[bool] = None, + star_count: Optional[int] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = None, + ) -> 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: Optional[int] = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = None, + ) -> 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: Optional[JSONDict] = None, + rate_limit_args: Optional[RLARGS] = 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, @@ -4476,13 +4943,13 @@ async def get_available_gifts( async def send_gift( self, - user_id: Optional[int] = None, - gift_id: Union[str, Gift] = None, # type: ignore + gift_id: Union[str, Gift], text: Optional[str] = None, text_parse_mode: ODVInput[str] = DEFAULT_NONE, text_entities: Optional[Sequence["MessageEntity"]] = None, pay_for_upgrade: Optional[bool] = None, chat_id: Optional[Union[str, int]] = None, + user_id: Optional[int] = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -4711,7 +5178,25 @@ async def remove_user_verification( 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 diff --git a/telegram/py.typed b/src/telegram/ext/_handlers/__init__.py similarity index 100% rename from telegram/py.typed rename to src/telegram/ext/_handlers/__init__.py diff --git a/telegram/ext/_handlers/basehandler.py b/src/telegram/ext/_handlers/basehandler.py similarity index 100% rename from telegram/ext/_handlers/basehandler.py rename to src/telegram/ext/_handlers/basehandler.py diff --git a/telegram/ext/_handlers/businessconnectionhandler.py b/src/telegram/ext/_handlers/businessconnectionhandler.py similarity index 100% rename from telegram/ext/_handlers/businessconnectionhandler.py rename to src/telegram/ext/_handlers/businessconnectionhandler.py diff --git a/telegram/ext/_handlers/businessmessagesdeletedhandler.py b/src/telegram/ext/_handlers/businessmessagesdeletedhandler.py similarity index 100% rename from telegram/ext/_handlers/businessmessagesdeletedhandler.py rename to src/telegram/ext/_handlers/businessmessagesdeletedhandler.py diff --git a/telegram/ext/_handlers/callbackqueryhandler.py b/src/telegram/ext/_handlers/callbackqueryhandler.py similarity index 99% rename from telegram/ext/_handlers/callbackqueryhandler.py rename to src/telegram/ext/_handlers/callbackqueryhandler.py index b09f8249c35..27ddc5b2ec4 100644 --- a/telegram/ext/_handlers/callbackqueryhandler.py +++ b/src/telegram/ext/_handlers/callbackqueryhandler.py @@ -204,5 +204,5 @@ def collect_additional_context( :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/_handlers/chatboosthandler.py b/src/telegram/ext/_handlers/chatboosthandler.py similarity index 100% rename from telegram/ext/_handlers/chatboosthandler.py rename to src/telegram/ext/_handlers/chatboosthandler.py diff --git a/telegram/ext/_handlers/chatjoinrequesthandler.py b/src/telegram/ext/_handlers/chatjoinrequesthandler.py similarity index 100% rename from telegram/ext/_handlers/chatjoinrequesthandler.py rename to src/telegram/ext/_handlers/chatjoinrequesthandler.py diff --git a/telegram/ext/_handlers/chatmemberhandler.py b/src/telegram/ext/_handlers/chatmemberhandler.py similarity index 100% rename from telegram/ext/_handlers/chatmemberhandler.py rename to src/telegram/ext/_handlers/chatmemberhandler.py diff --git a/telegram/ext/_handlers/choseninlineresulthandler.py b/src/telegram/ext/_handlers/choseninlineresulthandler.py similarity index 98% rename from telegram/ext/_handlers/choseninlineresulthandler.py rename to src/telegram/ext/_handlers/choseninlineresulthandler.py index 3bc80ed144b..2faa0bc862c 100644 --- a/telegram/ext/_handlers/choseninlineresulthandler.py +++ b/src/telegram/ext/_handlers/choseninlineresulthandler.py @@ -118,5 +118,5 @@ def collect_additional_context( :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/_handlers/commandhandler.py b/src/telegram/ext/_handlers/commandhandler.py similarity index 100% rename from telegram/ext/_handlers/commandhandler.py rename to src/telegram/ext/_handlers/commandhandler.py diff --git a/telegram/ext/_handlers/conversationhandler.py b/src/telegram/ext/_handlers/conversationhandler.py similarity index 99% rename from telegram/ext/_handlers/conversationhandler.py rename to src/telegram/ext/_handlers/conversationhandler.py index 1bc5f0398de..dd824bf5601 100644 --- a/telegram/ext/_handlers/conversationhandler.py +++ b/src/telegram/ext/_handlers/conversationhandler.py @@ -599,7 +599,7 @@ async def _initialize_persistence( current_conversations = self._conversations self._conversations = cast( - TrackingDict[ConversationKey, object], + "TrackingDict[ConversationKey, object]", TrackingDict(), ) # In the conversation already processed updates @@ -924,7 +924,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/_handlers/inlinequeryhandler.py b/src/telegram/ext/_handlers/inlinequeryhandler.py similarity index 99% rename from telegram/ext/_handlers/inlinequeryhandler.py rename to src/telegram/ext/_handlers/inlinequeryhandler.py index 31106ba33a6..0285d259c25 100644 --- a/telegram/ext/_handlers/inlinequeryhandler.py +++ b/src/telegram/ext/_handlers/inlinequeryhandler.py @@ -139,5 +139,5 @@ def collect_additional_context( :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/_handlers/messagehandler.py b/src/telegram/ext/_handlers/messagehandler.py similarity index 100% rename from telegram/ext/_handlers/messagehandler.py rename to src/telegram/ext/_handlers/messagehandler.py diff --git a/telegram/ext/_handlers/messagereactionhandler.py b/src/telegram/ext/_handlers/messagereactionhandler.py similarity index 100% rename from telegram/ext/_handlers/messagereactionhandler.py rename to src/telegram/ext/_handlers/messagereactionhandler.py diff --git a/telegram/ext/_handlers/paidmediapurchasedhandler.py b/src/telegram/ext/_handlers/paidmediapurchasedhandler.py similarity index 100% rename from telegram/ext/_handlers/paidmediapurchasedhandler.py rename to src/telegram/ext/_handlers/paidmediapurchasedhandler.py diff --git a/telegram/ext/_handlers/pollanswerhandler.py b/src/telegram/ext/_handlers/pollanswerhandler.py similarity index 100% rename from telegram/ext/_handlers/pollanswerhandler.py rename to src/telegram/ext/_handlers/pollanswerhandler.py diff --git a/telegram/ext/_handlers/pollhandler.py b/src/telegram/ext/_handlers/pollhandler.py similarity index 100% rename from telegram/ext/_handlers/pollhandler.py rename to src/telegram/ext/_handlers/pollhandler.py diff --git a/telegram/ext/_handlers/precheckoutqueryhandler.py b/src/telegram/ext/_handlers/precheckoutqueryhandler.py similarity index 100% rename from telegram/ext/_handlers/precheckoutqueryhandler.py rename to src/telegram/ext/_handlers/precheckoutqueryhandler.py diff --git a/telegram/ext/_handlers/prefixhandler.py b/src/telegram/ext/_handlers/prefixhandler.py similarity index 100% rename from telegram/ext/_handlers/prefixhandler.py rename to src/telegram/ext/_handlers/prefixhandler.py diff --git a/telegram/ext/_handlers/shippingqueryhandler.py b/src/telegram/ext/_handlers/shippingqueryhandler.py similarity index 100% rename from telegram/ext/_handlers/shippingqueryhandler.py rename to src/telegram/ext/_handlers/shippingqueryhandler.py diff --git a/telegram/ext/_handlers/stringcommandhandler.py b/src/telegram/ext/_handlers/stringcommandhandler.py similarity index 100% rename from telegram/ext/_handlers/stringcommandhandler.py rename to src/telegram/ext/_handlers/stringcommandhandler.py diff --git a/telegram/ext/_handlers/stringregexhandler.py b/src/telegram/ext/_handlers/stringregexhandler.py similarity index 100% rename from telegram/ext/_handlers/stringregexhandler.py rename to src/telegram/ext/_handlers/stringregexhandler.py diff --git a/telegram/ext/_handlers/typehandler.py b/src/telegram/ext/_handlers/typehandler.py similarity index 100% rename from telegram/ext/_handlers/typehandler.py rename to src/telegram/ext/_handlers/typehandler.py diff --git a/telegram/ext/_jobqueue.py b/src/telegram/ext/_jobqueue.py similarity index 100% rename from telegram/ext/_jobqueue.py rename to src/telegram/ext/_jobqueue.py diff --git a/telegram/ext/_picklepersistence.py b/src/telegram/ext/_picklepersistence.py similarity index 99% rename from telegram/ext/_picklepersistence.py rename to src/telegram/ext/_picklepersistence.py index c2f4c01e383..1602eabed0e 100644 --- a/telegram/ext/_picklepersistence.py +++ b/src/telegram/ext/_picklepersistence.py @@ -237,7 +237,7 @@ def __init__( self.callback_data: Optional[CDCData] = None self.conversations: Optional[dict[str, dict[tuple[Union[int, str], ...], object]]] = 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: diff --git a/telegram/ext/_updater.py b/src/telegram/ext/_updater.py similarity index 90% rename from telegram/ext/_updater.py rename to src/telegram/ext/_updater.py index a474d4dd68b..95f7e225ed1 100644 --- a/telegram/ext/_updater.py +++ b/src/telegram/ext/_updater.py @@ -26,10 +26,10 @@ from types import TracebackType from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union -from telegram._utils.defaultvalue import DEFAULT_80, DEFAULT_IP, DEFAULT_NONE, DefaultValue +from telegram._utils.defaultvalue import DEFAULT_80, DEFAULT_IP, DefaultValue from telegram._utils.logging import get_logger from telegram._utils.repr import build_repr_with_selected_attrs -from telegram._utils.types import DVType, ODVInput +from telegram._utils.types import DVType from telegram.error import TelegramError from telegram.ext._utils.networkloop import network_retry_loop @@ -124,7 +124,7 @@ def __init__( self.__polling_task_stop_event: asyncio.Event = asyncio.Event() self.__polling_cleanup_cb: Optional[Callable[[], Coroutine[Any, Any, None]]] = None - async def __aenter__(self: _UpdaterType) -> _UpdaterType: # noqa: PYI019 + async def __aenter__(self: _UpdaterType) -> _UpdaterType: """ |async_context_manager| :meth:`initializes ` the Updater. @@ -208,10 +208,6 @@ async def start_polling( poll_interval: float = 0.0, timeout: int = 10, bootstrap_retries: int = 0, - read_timeout: ODVInput[float] = DEFAULT_NONE, - write_timeout: ODVInput[float] = DEFAULT_NONE, - connect_timeout: ODVInput[float] = DEFAULT_NONE, - pool_timeout: ODVInput[float] = DEFAULT_NONE, allowed_updates: Optional[Sequence[str]] = None, drop_pending_updates: Optional[bool] = None, error_callback: Optional[Callable[[TelegramError], None]] = None, @@ -221,6 +217,12 @@ async def start_polling( .. 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``. @@ -236,41 +238,6 @@ async def start_polling( .. versionchanged:: 21.11 The default value will be changed to from ``-1`` to ``0``. Indefinite retries during bootstrapping are not recommended. - read_timeout (:obj:`float`, optional): Value to pass to - :paramref:`telegram.Bot.get_updates.read_timeout`. Defaults to - :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. - - .. versionchanged:: 20.7 - Defaults to :attr:`~telegram.request.BaseRequest.DEFAULT_NONE` instead of - ``2``. - .. deprecated:: 20.7 - Deprecated in favor of setting the timeout via - :meth:`telegram.ext.ApplicationBuilder.get_updates_read_timeout` or - :paramref:`telegram.Bot.get_updates_request`. - 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`. - - .. deprecated:: 20.7 - Deprecated in favor of setting the timeout via - :meth:`telegram.ext.ApplicationBuilder.get_updates_write_timeout` or - :paramref:`telegram.Bot.get_updates_request`. - 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`. - - .. deprecated:: 20.7 - Deprecated in favor of setting the timeout via - :meth:`telegram.ext.ApplicationBuilder.get_updates_connect_timeout` or - :paramref:`telegram.Bot.get_updates_request`. - 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`. - - .. deprecated:: 20.7 - Deprecated in favor of setting the timeout via - :meth:`telegram.ext.ApplicationBuilder.get_updates_pool_timeout` or - :paramref:`telegram.Bot.get_updates_request`. allowed_updates (Sequence[:obj:`str`], optional): Passed to :meth:`telegram.Bot.get_updates`. @@ -324,10 +291,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, @@ -347,10 +310,6 @@ async def _start_polling( self, poll_interval: float, timeout: int, - read_timeout: ODVInput[float], - write_timeout: ODVInput[float], - connect_timeout: ODVInput[float], - pool_timeout: ODVInput[float], bootstrap_retries: int, drop_pending_updates: Optional[bool], allowed_updates: Optional[Sequence[str]], @@ -376,10 +335,6 @@ async def polling_action_cb() -> bool: 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 TelegramError: @@ -440,10 +395,6 @@ async def _get_updates_cleanup() -> None: offset=self._last_update_id, # We don't want to do long polling here! timeout=0, - read_timeout=read_timeout, - connect_timeout=connect_timeout, - write_timeout=write_timeout, - pool_timeout=pool_timeout, allowed_updates=allowed_updates, ) except TelegramError: diff --git a/telegram/ext/_utils/__init__.py b/src/telegram/ext/_utils/__init__.py similarity index 100% rename from telegram/ext/_utils/__init__.py rename to src/telegram/ext/_utils/__init__.py diff --git a/telegram/ext/_utils/_update_parsing.py b/src/telegram/ext/_utils/_update_parsing.py similarity index 100% rename from telegram/ext/_utils/_update_parsing.py rename to src/telegram/ext/_utils/_update_parsing.py diff --git a/telegram/ext/_utils/asyncio.py b/src/telegram/ext/_utils/asyncio.py similarity index 100% rename from telegram/ext/_utils/asyncio.py rename to src/telegram/ext/_utils/asyncio.py diff --git a/telegram/ext/_utils/networkloop.py b/src/telegram/ext/_utils/networkloop.py similarity index 100% rename from telegram/ext/_utils/networkloop.py rename to src/telegram/ext/_utils/networkloop.py diff --git a/telegram/ext/_utils/stack.py b/src/telegram/ext/_utils/stack.py similarity index 100% rename from telegram/ext/_utils/stack.py rename to src/telegram/ext/_utils/stack.py diff --git a/telegram/ext/_utils/trackingdict.py b/src/telegram/ext/_utils/trackingdict.py similarity index 100% rename from telegram/ext/_utils/trackingdict.py rename to src/telegram/ext/_utils/trackingdict.py diff --git a/telegram/ext/_utils/types.py b/src/telegram/ext/_utils/types.py similarity index 100% rename from telegram/ext/_utils/types.py rename to src/telegram/ext/_utils/types.py diff --git a/telegram/ext/_utils/webhookhandler.py b/src/telegram/ext/_utils/webhookhandler.py similarity index 100% rename from telegram/ext/_utils/webhookhandler.py rename to src/telegram/ext/_utils/webhookhandler.py diff --git a/telegram/ext/filters.py b/src/telegram/ext/filters.py similarity index 98% rename from telegram/ext/filters.py rename to src/telegram/ext/filters.py index e1e16c6b2da..6322dafd296 100644 --- a/telegram/ext/filters.py +++ b/src/telegram/ext/filters.py @@ -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__ = ( @@ -43,7 +46,6 @@ "AUDIO", "BOOST_ADDED", "CAPTION", - "CHAT", "COMMAND", "CONTACT", "EFFECT_ID", @@ -859,21 +861,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`. - -.. deprecated:: 20.8 - This filter has no effect since :attr:`telegram.Message.chat` is always present. -""" - - class ChatType: # A convenience namespace for Chat types. """Subset for filtering the type of chat. @@ -1601,10 +1588,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})") @@ -1915,6 +1902,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__ = () @@ -1936,6 +1926,7 @@ def filter(self, update: Update) -> bool: 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.GIVEAWAY_COMPLETED.check_update(update) or StatusUpdate.GIVEAWAY_CREATED.check_update(update) or StatusUpdate.LEFT_CHAT_MEMBER.check_update(update) @@ -1944,11 +1935,12 @@ def filter(self, update: Update) -> bool: 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.PROXIMITY_ALERT_TRIGGERED.check_update(update) or StatusUpdate.REFUNDED_PAYMENT.check_update(update) + or StatusUpdate.UNIQUE_GIFT.check_update(update) or StatusUpdate.USERS_SHARED.check_update(update) - or StatusUpdate.USER_SHARED.check_update(update) or StatusUpdate.VIDEO_CHAT_ENDED.check_update(update) or StatusUpdate.VIDEO_CHAT_PARTICIPANTS_INVITED.check_update(update) or StatusUpdate.VIDEO_CHAT_SCHEDULED.check_update(update) @@ -2090,6 +2082,18 @@ 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 _GiveawayCreated(MessageFilter): __slots__ = () @@ -2173,6 +2177,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__ = () @@ -2204,26 +2222,16 @@ def filter(self, message: Message) -> bool: .. versionadded:: 21.4 """ - class _UserShared(MessageFilter): + class _UniqueGift(MessageFilter): __slots__ = () def filter(self, message: Message) -> bool: - return bool(message.api_kwargs.get("user_shared")) + return bool(message.unique_gift) - USER_SHARED = _UserShared(name="filters.StatusUpdate.USER_SHARED") - """Messages that contain ``"user_shared"`` in :attr:`telegram.TelegramObject.api_kwargs`. + UNIQUE_GIFT = _UniqueGift(name="filters.StatusUpdate.UNIQUE_GIFT") + """Messages that contain :attr:`telegram.Message.unique_gift`. - Warning: - This will only catch the legacy ``user_shared`` field, not the - new :attr:`telegram.Message.users_shared` attribute! - - .. versionchanged:: 21.0 - Now relies on :attr:`telegram.TelegramObject.api_kwargs` as the native attribute - ``Message.user_shared`` was removed. - - .. versionadded:: 20.1 - .. deprecated:: 20.8 - Use :attr:`USERS_SHARED` instead. + .. versionadded:: 22.1 """ class _UsersShared(MessageFilter): diff --git a/telegram/helpers.py b/src/telegram/helpers.py similarity index 100% rename from telegram/helpers.py rename to src/telegram/helpers.py 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 100% rename from telegram/request/__init__.py rename to src/telegram/request/__init__.py diff --git a/telegram/request/_baserequest.py b/src/telegram/request/_baserequest.py similarity index 91% rename from telegram/request/_baserequest.py rename to src/telegram/request/_baserequest.py index 38729186440..666f2d042db 100644 --- a/telegram/request/_baserequest.py +++ b/src/telegram/request/_baserequest.py @@ -29,7 +29,6 @@ from telegram._utils.logging import get_logger from telegram._utils.strings import TextEncoding from telegram._utils.types import JSONDict, ODVInput -from telegram._utils.warnings import warn from telegram._version import __version__ as ptb_ver from telegram.error import ( BadRequest, @@ -42,7 +41,6 @@ TelegramError, ) from telegram.request._requestdata import RequestData -from telegram.warnings import PTBDeprecationWarning RT = TypeVar("RT", bound="BaseRequest") @@ -133,22 +131,19 @@ async def __aexit__( await self.shutdown() @property + @abc.abstractmethod def read_timeout(self) -> Optional[float]: """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 - - Warning: - For now this property does not need to be implemented by subclasses and will raise - :exc:`NotImplementedError` if accessed without being overridden. However, in future - versions, this property will be abstract and must be implemented by subclasses. + .. versionchanged:: 22.0 + This property is now required to be implemented by subclasses. Returns: :obj:`float` | :obj:`None`: The read timeout in seconds. """ - raise NotImplementedError @abc.abstractmethod async def initialize(self) -> None: @@ -305,31 +300,6 @@ async def _request_wrapper( TelegramError """ - # Import needs to be here since HTTPXRequest is a subclass of BaseRequest - from telegram.request import HTTPXRequest # pylint: disable=import-outside-toplevel - - # 20 is the documented default value for all the media related bot methods and custom - # implementations of BaseRequest may explicitly rely on that. Hence, we follow the - # standard deprecation policy and deprecate starting with version 20.7. - # For our own implementation HTTPXRequest, we can handle that ourselves, so we skip the - # warning in that case. - has_files = request_data and request_data.multipart_data - if ( - has_files - and not isinstance(self, HTTPXRequest) - and isinstance(write_timeout, DefaultValue) - ): - warn( - PTBDeprecationWarning( - "20.7", - f"The `write_timeout` parameter passed to {self.__class__.__name__}.do_request" - " will default to `BaseRequest.DEFAULT_NONE` instead of 20 in future versions " - "for *all* methods of the `Bot` class, including methods sending media.", - ), - stacklevel=3, - ) - write_timeout = 20 - try: code, payload = await self.do_request( url=url, diff --git a/telegram/request/_httpxrequest.py b/src/telegram/request/_httpxrequest.py similarity index 93% rename from telegram/request/_httpxrequest.py rename to src/telegram/request/_httpxrequest.py index b31efbfcb07..bb35501f178 100644 --- a/telegram/request/_httpxrequest.py +++ b/src/telegram/request/_httpxrequest.py @@ -25,11 +25,9 @@ from telegram._utils.defaultvalue import DefaultValue from telegram._utils.logging import get_logger from telegram._utils.types import HTTPVersion, ODVInput, SocketOpt -from telegram._utils.warnings import warn from telegram.error import NetworkError, TimedOut from telegram.request._baserequest import BaseRequest from telegram.request._requestdata import RequestData -from telegram.warnings import PTBDeprecationWarning # Note to future devs: # Proxies are currently only tested manually. The httpx development docs have a nice guide on that: @@ -45,17 +43,12 @@ 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): Legacy name for :paramref:`proxy`, kept for backward - compatibility. Defaults to :obj:`None`. - - .. deprecated:: 20.7 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`. @@ -145,7 +138,6 @@ class HTTPXRequest(BaseRequest): def __init__( self, connection_pool_size: int = 1, - proxy_url: Optional[Union[str, httpx.Proxy, httpx.URL]] = None, read_timeout: Optional[float] = 5.0, write_timeout: Optional[float] = 5.0, connect_timeout: Optional[float] = 5.0, @@ -156,18 +148,6 @@ def __init__( media_write_timeout: Optional[float] = 20.0, httpx_kwargs: Optional[dict[str, Any]] = None, ): - if proxy_url is not None and proxy is not None: - raise ValueError("The parameters `proxy_url` and `proxy` are mutually exclusive.") - - if proxy_url is not None: - proxy = proxy_url - warn( - PTBDeprecationWarning( - "20.7", "The parameter `proxy_url` is deprecated. Use `proxy` instead." - ), - stacklevel=2, - ) - self._http_version = http_version self._media_write_timeout = media_write_timeout timeout = httpx.Timeout( diff --git a/telegram/request/_requestdata.py b/src/telegram/request/_requestdata.py similarity index 100% rename from telegram/request/_requestdata.py rename to src/telegram/request/_requestdata.py diff --git a/telegram/request/_requestparameter.py b/src/telegram/request/_requestparameter.py similarity index 86% rename from telegram/request/_requestparameter.py rename to src/telegram/request/_requestparameter.py index 89796713772..f0664c7943d 100644 --- a/telegram/request/_requestparameter.py +++ b/src/telegram/request/_requestparameter.py @@ -23,8 +23,10 @@ from dataclasses import dataclass from typing import Optional, final +from telegram._files._inputstorycontent import InputStoryContent from telegram._files.inputfile import InputFile 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 @@ -148,6 +150,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 diff --git a/telegram/warnings.py b/src/telegram/warnings.py similarity index 100% rename from telegram/warnings.py rename to src/telegram/warnings.py diff --git a/telegram/_gifts.py b/telegram/_gifts.py deleted file mode 100644 index d068923c6df..00000000000 --- a/telegram/_gifts.py +++ /dev/null @@ -1,147 +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-2025 -# 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, Optional - -from telegram._files.sticker import Sticker -from telegram._telegramobject import TelegramObject -from telegram._utils.argumentparsing import de_json_optional, de_list_optional, parse_sequence_arg -from telegram._utils.types import JSONDict - -if TYPE_CHECKING: - from telegram import Bot - - -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; for limited gifts only - remaining_count (:obj:`int`, optional): The number of remaining gifts of this type that can - be sent; 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 - - 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; for limited gifts only - remaining_count (:obj:`int`): Optional. The number of remaining gifts of this type that can - be sent; 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 - - """ - - __slots__ = ( - "id", - "remaining_count", - "star_count", - "sticker", - "total_count", - "upgrade_star_count", - ) - - def __init__( - self, - id: str, - sticker: Sticker, - star_count: int, - total_count: Optional[int] = None, - remaining_count: Optional[int] = None, - upgrade_star_count: Optional[int] = None, - *, - api_kwargs: Optional[JSONDict] = None, - ): - super().__init__(api_kwargs=api_kwargs) - self.id: str = id - self.sticker: Sticker = sticker - self.star_count: int = star_count - self.total_count: Optional[int] = total_count - self.remaining_count: Optional[int] = remaining_count - self.upgrade_star_count: Optional[int] = upgrade_star_count - - self._id_attrs = (self.id,) - - self._freeze() - - @classmethod - def de_json(cls, data: JSONDict, bot: Optional["Bot"] = None) -> "Gift": - """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 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: Optional[JSONDict] = 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: Optional["Bot"] = 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) diff --git a/tests/README.rst b/tests/README.rst index a6724558041..822c90d35c7 100644 --- a/tests/README.rst +++ b/tests/README.rst @@ -6,6 +6,12 @@ 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 ``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 ============= diff --git a/tests/_files/test_inputmedia.py b/tests/_files/test_inputmedia.py index b362411cbd8..a077c309cc5 100644 --- a/tests/_files/test_inputmedia.py +++ b/tests/_files/test_inputmedia.py @@ -710,7 +710,6 @@ async def test_send_media_group_with_thumbs( 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 = ( diff --git a/tests/_files/test_inputprofilephoto.py b/tests/_files/test_inputprofilephoto.py new file mode 100644 index 00000000000..363bf5a9fd2 --- /dev/null +++ b/tests/_files/test_inputprofilephoto.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# 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_inputstorycontent.py b/tests/_files/test_inputstorycontent.py new file mode 100644 index 00000000000..9e826409584 --- /dev/null +++ b/tests/_files/test_inputstorycontent.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# 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 TestInputMediaVideoWithoutRequest(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 + + 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/_passport/test_passport.py b/tests/_passport/test_passport.py index 8f5776fd819..4104a2c6b4c 100644 --- a/tests/_passport/test_passport.py +++ b/tests/_passport/test_passport.py @@ -418,7 +418,10 @@ def test_bot_init_invalid_key(self, offline_bot): with pytest.raises(TypeError): Bot(offline_bot.token, private_key="Invalid key!") - with pytest.raises(ValueError, match="Could not deserialize key data"): + # Different error messages for different cryptography versions + with pytest.raises( + ValueError, match="(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, offline_bot, all_passport_data): diff --git a/tests/_passport/test_passportelementerrorfiles.py b/tests/_passport/test_passportelementerrorfiles.py index d5b9ad14530..61a32376835 100644 --- a/tests/_passport/test_passportelementerrorfiles.py +++ b/tests/_passport/test_passportelementerrorfiles.py @@ -19,7 +19,6 @@ import pytest from telegram import PassportElementErrorFiles, PassportElementErrorSelfie -from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -49,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): @@ -60,9 +59,8 @@ def test_to_dict(self, passport_element_error_files): 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["message"] == passport_element_error_files.message - assert ( - passport_element_error_files_dict["file_hashes"] - == passport_element_error_files.file_hashes + assert passport_element_error_files_dict["file_hashes"] == list( + passport_element_error_files.file_hashes ) def test_equality(self): @@ -88,13 +86,3 @@ def test_equality(self): assert a != f assert hash(a) != hash(f) - - def test_file_hashes_deprecated(self, passport_element_error_files, recwarn): - passport_element_error_files.file_hashes - assert len(recwarn) == 1 - assert ( - "The attribute `file_hashes` will return a tuple instead of a list in future major" - " versions." in str(recwarn[0].message) - ) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__ diff --git a/tests/_passport/test_passportelementerrortranslationfiles.py b/tests/_passport/test_passportelementerrortranslationfiles.py index 8694de896e9..29988fb8f7a 100644 --- a/tests/_passport/test_passportelementerrortranslationfiles.py +++ b/tests/_passport/test_passportelementerrortranslationfiles.py @@ -19,7 +19,6 @@ import pytest from telegram import PassportElementErrorSelfie, PassportElementErrorTranslationFiles -from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -51,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): @@ -73,9 +72,8 @@ def test_to_dict(self, passport_element_error_translation_files): passport_element_error_translation_files_dict["message"] == passport_element_error_translation_files.message ) - assert ( - passport_element_error_translation_files_dict["file_hashes"] - == passport_element_error_translation_files.file_hashes + assert passport_element_error_translation_files_dict["file_hashes"] == list( + passport_element_error_translation_files.file_hashes ) def test_equality(self): @@ -101,13 +99,3 @@ def test_equality(self): assert a != f assert hash(a) != hash(f) - - def test_file_hashes_deprecated(self, passport_element_error_translation_files, recwarn): - passport_element_error_translation_files.file_hashes - assert len(recwarn) == 1 - assert ( - "The attribute `file_hashes` will return a tuple instead of a list in future major" - " versions." in str(recwarn[0].message) - ) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__ diff --git a/tests/_passport/test_passportfile.py b/tests/_passport/test_passportfile.py index add24ab5b08..65e83335508 100644 --- a/tests/_passport/test_passportfile.py +++ b/tests/_passport/test_passportfile.py @@ -16,10 +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.warnings import PTBDeprecationWarning +from telegram._utils.datetime import UTC, to_timestamp from tests.auxil.bot_method_checks import ( check_defaults_handling, check_shortcut_call, @@ -44,7 +46,7 @@ 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(PassportFileTestBase): @@ -67,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) @@ -89,16 +111,6 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) - def test_file_date_deprecated(self, passport_file, recwarn): - passport_file.file_date - assert len(recwarn) == 1 - assert ( - "The attribute `file_date` will return a datetime instead of an integer in future" - " major versions." in str(recwarn[0].message) - ) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__ - async def test_get_file_instance_method(self, monkeypatch, passport_file): async def make_assertion(*_, **kwargs): result = kwargs["file_id"] == passport_file.file_id diff --git a/tests/_payment/stars/test_staramount.py b/tests/_payment/stars/test_staramount.py new file mode 100644 index 00000000000..f0438910b00 --- /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-2025 +# 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 index 0878e8cbede..f90361e2b99 100644 --- a/tests/_payment/stars/test_startransactions.py +++ b/tests/_payment/stars/test_startransactions.py @@ -63,6 +63,7 @@ class StarTransactionTestBase: 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, @@ -144,6 +145,7 @@ def test_equality(self): amount=3, date=to_timestamp(dtm.datetime.utcnow()), source=TransactionPartnerUser( + transaction_type="other_type", user=User( id=3, is_bot=False, diff --git a/tests/_payment/stars/test_transactionpartner.py b/tests/_payment/stars/test_transactionpartner.py index 3f795b93ca2..f89568901a6 100644 --- a/tests/_payment/stars/test_transactionpartner.py +++ b/tests/_payment/stars/test_transactionpartner.py @@ -63,6 +63,7 @@ class TransactionPartnerTestBase: first_name="user", last_name="user", ) + transaction_type = "premium_purchase" invoice_payload = "invoice_payload" paid_media = ( PaidMediaVideo( @@ -101,6 +102,7 @@ class TransactionPartnerTestBase: id=3, type=Chat.CHANNEL, ) + premium_subscription_duration = 3 class TestTransactionPartnerWithoutRequest(TransactionPartnerTestBase): @@ -137,6 +139,7 @@ def test_subclass(self, offline_bot, tp_type, subclass): "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) @@ -268,11 +271,13 @@ def test_equality(self, transaction_partner_fragment): @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, ) @@ -288,36 +293,48 @@ def test_slot_behaviour(self, transaction_partner_user): 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_transaction_type_is_required_argument(self): + with pytest.raises(TypeError, match="`transaction_type` is a required argument"): + TransactionPartnerUser(user=self.user) def test_equality(self, transaction_partner_user): a = transaction_partner_user b = TransactionPartnerUser( user=self.user, + transaction_type=self.transaction_type, ) c = TransactionPartnerUser( user=User(id=1, is_bot=False, first_name="user", last_name="user"), + transaction_type=self.transaction_type, ) d = User(id=1, is_bot=False, first_name="user", last_name="user") diff --git a/tests/auxil/bot_method_checks.py b/tests/auxil/bot_method_checks.py index 0c2b868e081..f7f43088681 100644 --- a/tests/auxil/bot_method_checks.py +++ b/tests/auxil/bot_method_checks.py @@ -35,6 +35,7 @@ InlineQueryResultArticle, InlineQueryResultCachedPhoto, InputMediaPhoto, + InputPaidMediaPhoto, InputTextMessageContent, LinkPreviewOptions, ReplyParameters, @@ -285,8 +286,13 @@ def build_kwargs( elif name in ["prices", "commands", "errors"]: kws[name] = [] elif name == "media": - media = InputMediaPhoto("media", parse_mode=manually_passed_value) - 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 @@ -351,9 +357,6 @@ def build_kwargs( allow_sending_without_reply=manually_passed_value, quote_parse_mode=manually_passed_value, ) - # TODO remove when gift_id isnt marked as optional anymore, tags: deprecated 21.11 - elif name == "gift_id": - kws[name] = "GIFT-ID" return kws @@ -510,7 +513,8 @@ def check_input_media(m: dict): ) media = data.pop("media", None) - if media: + 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: @@ -617,10 +621,8 @@ async def check_defaults_handling( kwargs_need_default.remove("parse_mode") defaults_no_custom_defaults = Defaults() - kwargs = {kwarg: "custom_default" for kwarg in inspect.signature(Defaults).parameters} + kwargs = dict.fromkeys(inspect.signature(Defaults).parameters, "custom_default") kwargs["tzinfo"] = zoneinfo.ZoneInfo("America/New_York") - kwargs.pop("disable_web_page_preview") # mutually exclusive with link_preview_options - kwargs.pop("quote") # mutually exclusive with do_quote kwargs["link_preview_options"] = LinkPreviewOptions( url="custom_default", show_above_text="custom_default" ) diff --git a/tests/auxil/dummy_objects.py b/tests/auxil/dummy_objects.py index 7e504f0db78..9abce52fa23 100644 --- a/tests/auxil/dummy_objects.py +++ b/tests/auxil/dummy_objects.py @@ -3,6 +3,7 @@ from typing import Union from telegram import ( + AcceptedGiftTypes, BotCommand, BotDescription, BotName, @@ -22,14 +23,18 @@ Gifts, MenuButton, MessageId, + OwnedGiftRegular, + OwnedGifts, Poll, PollOption, PreparedInlineMessage, SentWebAppMessage, + StarAmount, StarTransaction, StarTransactions, Sticker, StickerSet, + Story, TelegramObject, Update, User, @@ -74,6 +79,9 @@ 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 + ), ), "ChatInviteLink": ChatInviteLink( "dummy_invite_link", @@ -91,6 +99,22 @@ "MenuButton": MenuButton(type="dummy_type"), "Message": 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", @@ -103,6 +127,7 @@ ), "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)] ), @@ -113,6 +138,7 @@ 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, diff --git a/tests/auxil/files.py b/tests/auxil/files.py index 21571b1988a..583ae615cef 100644 --- a/tests/auxil/files.py +++ b/tests/auxil/files.py @@ -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/networking.py b/tests/auxil/networking.py index d23a1215e25..ceac1a8c05a 100644 --- a/tests/auxil/networking.py +++ b/tests/auxil/networking.py @@ -71,6 +71,10 @@ async def initialize(self) -> None: async def shutdown(self) -> None: pass + @property + def read_timeout(self): + return 1 + def __init__(self, *args, **kwargs): pass diff --git a/tests/ext/test_application.py b/tests/ext/test_application.py index d2d2783ccc0..f375559eb3a 100644 --- a/tests/ext/test_application.py +++ b/tests/ext/test_application.py @@ -16,8 +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/]. -"""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 @@ -59,7 +58,7 @@ 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 PytestApplication, PytestUpdater, make_bot @@ -1007,7 +1006,7 @@ async def callback(update, context): == "ApplicationHandlerStop is not supported with handlers running non-blocking." ) assert ( - Path(recwarn[0].filename) == PROJECT_ROOT_PATH / "telegram" / "ext" / "_application.py" + Path(recwarn[0].filename) == SOURCE_ROOT_PATH / "ext" / "_application.py" ), "incorrect stacklevel!" async def test_non_blocking_no_error_handler(self, app, caplog): @@ -1080,7 +1079,7 @@ async def error_handler(update, context): == "ApplicationHandlerStop is not supported with handlers running non-blocking." ) assert ( - Path(recwarn[0].filename) == PROJECT_ROOT_PATH / "telegram" / "ext" / "_application.py" + Path(recwarn[0].filename) == SOURCE_ROOT_PATH / "ext" / "_application.py" ), "incorrect stacklevel!" @pytest.mark.parametrize(("block", "expected_output"), [(False, 0), (True, 5)]) @@ -1516,49 +1515,6 @@ def thread_target(): found_log = True assert found_log - @pytest.mark.parametrize( - "timeout_name", - ["read_timeout", "connect_timeout", "write_timeout", "pool_timeout", "poll_interval"], - ) - @pytest.mark.skipif( - platform.system() == "Windows", - reason="Can't send signals without stopping whole process on windows", - ) - def test_run_polling_timeout_deprecation_warnings( - self, timeout_name, monkeypatch, recwarn, app - ): - 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.05) - - os.kill(os.getpid(), signal.SIGINT) - - monkeypatch.setattr(app.bot, "get_updates", empty_get_updates) - - thread = Thread(target=thread_target) - thread.start() - - kwargs = {timeout_name: 42} - app.run_polling(drop_pending_updates=True, close_loop=False, **kwargs) - thread.join() - - if timeout_name == "poll_interval": - assert len(recwarn) == 0 - return - - assert len(recwarn) == 1 - assert "Setting timeouts via `Application.run_polling` is deprecated." in str( - recwarn[0].message - ) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__, "wrong stacklevel" - @pytest.mark.skipif( platform.system() == "Windows", reason="Can't send signals without stopping whole process on windows", diff --git a/tests/ext/test_applicationbuilder.py b/tests/ext/test_applicationbuilder.py index fbf6bf917d4..15e85b6416e 100644 --- a/tests/ext/test_applicationbuilder.py +++ b/tests/ext/test_applicationbuilder.py @@ -41,7 +41,6 @@ from telegram.ext._applicationbuilder import _BOT_CHECKS from telegram.ext._baseupdateprocessor import SimpleUpdateProcessor from telegram.request import HTTPXRequest -from telegram.warnings import PTBDeprecationWarning from tests.auxil.constants import PRIVATE_KEY from tests.auxil.envvars import TEST_WITH_OPT_DEPS from tests.auxil.files import data_file @@ -207,7 +206,6 @@ def test_mutually_exclusive_for_bot(self, builder, method, description): "write_timeout", "media_write_timeout", "proxy", - "proxy_url", "socket_options", "bot", "updater", @@ -217,9 +215,8 @@ def test_mutually_exclusive_for_bot(self, builder, method, description): def test_mutually_exclusive_for_request(self, builder, method): builder.request(1) - method_name = method.replace("proxy_url", "proxy") with pytest.raises( - RuntimeError, match=f"`{method_name}` may only be set, if no request instance" + RuntimeError, match=f"`{method}` may only be set, if no request instance" ): getattr(builder, method)(data_file("private.key")) @@ -237,7 +234,6 @@ def test_mutually_exclusive_for_request(self, builder, method): "get_updates_read_timeout", "get_updates_write_timeout", "get_updates_proxy", - "get_updates_proxy_url", "get_updates_socket_options", "get_updates_http_version", "bot", @@ -247,10 +243,9 @@ def test_mutually_exclusive_for_request(self, builder, method): def test_mutually_exclusive_for_get_updates_request(self, builder, method): builder.get_updates_request(1) - method_name = method.replace("proxy_url", "proxy") with pytest.raises( RuntimeError, - match=f"`{method_name}` may only be set, if no get_updates_request instance", + match=f"`{method}` may only be set, if no get_updates_request instance", ): getattr(builder, method)(data_file("private.key")) @@ -267,7 +262,6 @@ 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", @@ -278,7 +272,6 @@ def test_mutually_exclusive_for_get_updates_request(self, builder, method): "write_timeout", "media_write_timeout", "proxy", - "proxy_url", "socket_options", "http_version", "bot", @@ -290,17 +283,15 @@ def test_mutually_exclusive_for_get_updates_request(self, builder, method): def test_mutually_exclusive_for_updater(self, builder, method): builder.updater(1) - method_name = method.replace("proxy_url", "proxy") with pytest.raises( RuntimeError, - match=f"`{method_name}` may only be set, if no updater", + match=f"`{method}` may only be set, if no updater", ): getattr(builder, method)(data_file("private.key")) builder = ApplicationBuilder() getattr(builder, method)(data_file("private.key")) - method = method.replace("proxy_url", "proxy") with pytest.raises(RuntimeError, match=f"`updater` may only be set, if no {method}"): builder.updater(1) @@ -313,7 +304,6 @@ def test_mutually_exclusive_for_updater(self, builder, method): "get_updates_read_timeout", "get_updates_write_timeout", "get_updates_proxy", - "get_updates_proxy_url", "get_updates_socket_options", "get_updates_http_version", "connection_pool_size", @@ -323,7 +313,6 @@ def test_mutually_exclusive_for_updater(self, builder, method): "write_timeout", "media_write_timeout", "proxy", - "proxy_url", "socket_options", "bot", "http_version", @@ -341,14 +330,11 @@ def test_mutually_non_exclusive_for_updater(self, builder, method): getattr(builder, method)(data_file("private.key")) builder.updater(None) - # We test with bot the new & legacy version to ensure that the legacy version still works - @pytest.mark.parametrize( - ("proxy_method", "get_updates_proxy_method"), - [("proxy", "get_updates_proxy"), ("proxy_url", "get_updates_proxy_url")], - ids=["new", "legacy"], - ) def test_all_bot_args_custom( - self, builder, bot, monkeypatch, proxy_method, get_updates_proxy_method + self, + builder, + bot, + monkeypatch, ): # Only socket_options is tested in a standalone test, since that's easier defaults = Defaults() @@ -403,8 +389,7 @@ def init_httpx_request(self_, *args, **kwargs): builder = ApplicationBuilder().token(bot.token) builder.connection_pool_size(1).connect_timeout(2).pool_timeout(3).read_timeout( 4 - ).write_timeout(5).media_write_timeout(6).http_version("1.1") - getattr(builder, proxy_method)("proxy") + ).write_timeout(5).media_write_timeout(6).http_version("1.1").proxy("proxy") app = builder.build() client = app.bot.request._client @@ -423,8 +408,9 @@ def init_httpx_request(self_, *args, **kwargs): 5 ).get_updates_http_version( "1.1" + ).get_updates_proxy( + "get_updates_proxy" ) - getattr(builder, get_updates_proxy_method)("get_updates_proxy") app = builder.build() client = app.bot._request[0]._client @@ -440,7 +426,6 @@ def test_custom_socket_options(self, builder, monkeypatch, bot): httpx_request_init = HTTPXRequest.__init__ def init_transport(*args, **kwargs): - nonlocal httpx_request_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()) @@ -585,22 +570,6 @@ def test_no_job_queue(self, bot, builder): assert isinstance(app.update_queue, asyncio.Queue) assert isinstance(app.updater, Updater) - def test_proxy_url_deprecation_warning(self, bot, builder, recwarn): - builder.token(bot.token).proxy_url("proxy_url") - assert len(recwarn) == 1 - assert "`ApplicationBuilder.proxy_url` is deprecated" in str(recwarn[0].message) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__, "wrong stacklevel" - - def test_get_updates_proxy_url_deprecation_warning(self, bot, builder, recwarn): - builder.token(bot.token).get_updates_proxy_url("get_updates_proxy_url") - assert len(recwarn) == 1 - assert "`ApplicationBuilder.get_updates_proxy_url` is deprecated" in str( - recwarn[0].message - ) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__, "wrong stacklevel" - @pytest.mark.parametrize( ("read_timeout", "timeout", "expected"), [ diff --git a/tests/ext/test_conversationhandler.py b/tests/ext/test_conversationhandler.py index 7d8b7ddb946..64959f47f47 100644 --- a/tests/ext/test_conversationhandler.py +++ b/tests/ext/test_conversationhandler.py @@ -60,7 +60,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 @@ -725,7 +725,7 @@ async def callback(_, __): assert recwarn[0].category is PTBUserWarning assert ( Path(recwarn[0].filename) - == PROJECT_ROOT_PATH / "telegram" / "ext" / "_handlers" / "conversationhandler.py" + == SOURCE_ROOT_PATH / "ext" / "_handlers" / "conversationhandler.py" ), "wrong stacklevel!" assert ( str(recwarn[0].message) @@ -1105,11 +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" - / "_handlers" - / "conversationhandler.py" + == SOURCE_ROOT_PATH / "ext" / "_handlers" / "conversationhandler.py" ), "wrong stacklevel!" # now set app.job_queue back to it's original value @@ -1428,8 +1424,7 @@ def timeout(*args, **kwargs): 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" + Path(recwarn[0].filename) == SOURCE_ROOT_PATH / "ext" / "_jobqueue.py" ), "wrong stacklevel!" await app.stop() @@ -1438,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) @@ -1459,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 diff --git a/tests/ext/test_defaults.py b/tests/ext/test_defaults.py index dc852aba19f..32f4b0c3800 100644 --- a/tests/ext/test_defaults.py +++ b/tests/ext/test_defaults.py @@ -22,7 +22,7 @@ import pytest -from telegram import LinkPreviewOptions, User +from telegram import User from telegram.ext import Defaults from telegram.warnings import PTBDeprecationWarning from tests.auxil.envvars import TEST_WITH_OPT_DEPS @@ -31,7 +31,7 @@ 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" @@ -63,8 +63,8 @@ def test_equality(self): 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", disable_web_page_preview=True) - g = Defaults(parse_mode="HTML", disable_web_page_preview=True) + f = Defaults(parse_mode="HTML", block=True) + g = Defaults(parse_mode="HTML", block=True) assert a == b assert hash(a) == hash(b) @@ -81,29 +81,3 @@ def test_equality(self): assert f == g assert hash(f) == hash(g) - - def test_mutually_exclusive(self): - with pytest.raises(ValueError, match="mutually exclusive"): - Defaults(disable_web_page_preview=True, link_preview_options=LinkPreviewOptions(False)) - with pytest.raises(ValueError, match="mutually exclusive"): - Defaults(quote=True, do_quote=False) - - def test_deprecation_warning_for_disable_web_page_preview(self): - with pytest.warns( - PTBDeprecationWarning, match="`Defaults.disable_web_page_preview` is " - ) as record: - Defaults(disable_web_page_preview=True) - - assert record[0].filename == __file__, "wrong stacklevel!" - - assert Defaults(disable_web_page_preview=True).link_preview_options.is_disabled is True - assert Defaults(disable_web_page_preview=False).disable_web_page_preview is False - - def test_deprecation_warning_for_quote(self): - with pytest.warns(PTBDeprecationWarning, match="`Defaults.quote` is ") as record: - Defaults(quote=True) - - assert record[0].filename == __file__, "wrong stacklevel!" - - assert Defaults(quote=True).do_quote is True - assert Defaults(quote=False).quote is False diff --git a/tests/ext/test_filters.py b/tests/ext/test_filters.py index b7655dd4ddf..ae125c98a40 100644 --- a/tests/ext/test_filters.py +++ b/tests/ext/test_filters.py @@ -1068,11 +1068,6 @@ def test_filters_status_update(self, update): assert filters.StatusUpdate.WRITE_ACCESS_ALLOWED.check_update(update) update.message.write_access_allowed = None - update.message.api_kwargs = {"user_shared": "user_shared"} - assert filters.StatusUpdate.ALL.check_update(update) - assert filters.StatusUpdate.USER_SHARED.check_update(update) - update.message.api_kwargs = {} - update.message.users_shared = "users_shared" assert filters.StatusUpdate.ALL.check_update(update) assert filters.StatusUpdate.USERS_SHARED.check_update(update) @@ -1103,6 +1098,21 @@ def test_filters_status_update(self, update): assert filters.StatusUpdate.REFUNDED_PAYMENT.check_update(update) update.message.refunded_payment = 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.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 + def test_filters_forwarded(self, update, message_origin_user): assert filters.FORWARDED.check_update(update) update.message.forward_origin = MessageOriginHiddenUser(dtm.datetime.utcnow(), 1) @@ -1334,15 +1344,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): diff --git a/tests/ext/test_picklepersistence.py b/tests/ext/test_picklepersistence.py index 5ce998c9018..f5c15c5cb9b 100644 --- a/tests/ext/test_picklepersistence.py +++ b/tests/ext/test_picklepersistence.py @@ -28,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 @@ -899,8 +899,7 @@ async def test_custom_pickler_unpickler_simple( 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" + 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) diff --git a/tests/ext/test_ratelimiter.py b/tests/ext/test_ratelimiter.py index b1c66b6009b..c253ee27c8c 100644 --- a/tests/ext/test_ratelimiter.py +++ b/tests/ext/test_ratelimiter.py @@ -85,6 +85,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 = [] @@ -163,6 +167,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): request_data = kwargs.get("request_data") allow_paid_broadcast = request_data.parameters.get("allow_paid_broadcast", False) diff --git a/tests/ext/test_updater.py b/tests/ext/test_updater.py index 9fdc8e4a769..147fc6128df 100644 --- a/tests/ext/test_updater.py +++ b/tests/ext/test_updater.py @@ -27,7 +27,6 @@ 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 tests.auxil.build_messages import make_message, make_message_update @@ -296,10 +295,6 @@ async def test_polling_mark_updates_as_read(self, monkeypatch, updater, caplog): received_kwargs = {} expected_kwargs = { "timeout": 0, - "read_timeout": "read_timeout", - "connect_timeout": "connect_timeout", - "write_timeout": "write_timeout", - "pool_timeout": "pool_timeout", "allowed_updates": "allowed_updates", } @@ -422,10 +417,6 @@ async def test_start_polling_get_updates_parameters(self, updater, monkeypatch): expected = { "timeout": 10, - "read_timeout": DEFAULT_NONE, - "write_timeout": DEFAULT_NONE, - "connect_timeout": DEFAULT_NONE, - "pool_timeout": DEFAULT_NONE, "allowed_updates": None, "api_kwargs": None, } @@ -466,10 +457,6 @@ async def get_updates(*args, **kwargs): expected = { "timeout": 42, - "read_timeout": 43, - "write_timeout": 44, - "connect_timeout": 45, - "pool_timeout": 46, "allowed_updates": ["message"], "api_kwargs": None, } @@ -477,10 +464,6 @@ async def get_updates(*args, **kwargs): 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, allowed_updates=["message"], ) await update_queue.join() diff --git a/tests/request/test_request.py b/tests/request/test_request.py index 2c35cf5fccc..1672b8fb64e 100644 --- a/tests/request/test_request.py +++ b/tests/request/test_request.py @@ -45,10 +45,9 @@ TelegramError, TimedOut, ) -from telegram.request import BaseRequest, RequestData +from telegram.request import RequestData from telegram.request._httpxrequest import HTTPXRequest from telegram.request._requestparameter import RequestParameter -from telegram.warnings import PTBDeprecationWarning from tests.auxil.envvars import TEST_WITH_OPT_DEPS from tests.auxil.files import data_file from tests.auxil.networking import NonchalantHttpxRequest @@ -390,76 +389,6 @@ async def make_assertion(*args, **kwargs): ) assert self.test_flag == (1, 2, 3, 4) - def test_read_timeout_not_implemented(self): - class SimpleRequest(BaseRequest): - async def do_request(self, *args, **kwargs): - raise httpx.ReadTimeout("read timeout") - - async def initialize(self) -> None: - pass - - async def shutdown(self) -> None: - pass - - with pytest.raises(NotImplementedError): - SimpleRequest().read_timeout - - @pytest.mark.parametrize("media", [True, False]) - async def test_timeout_propagation_write_timeout( - self, monkeypatch, media, input_media_photo, recwarn # noqa: F811 - ): - 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]: - self.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": {}}' - - custom_request = CustomRequest() - 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 custom_request.post( - "url", request_data, read_timeout=1, connect_timeout=2, write_timeout=3, pool_timeout=4 - ) - assert self.test_flag == (1, 2, 3, 4) - - # Now also ensure that the default timeout for media requests is 20 seconds - await custom_request.post("url", request_data) - assert self.test_flag == ( - DEFAULT_NONE, - DEFAULT_NONE, - 20 if media else DEFAULT_NONE, - DEFAULT_NONE, - ) - - print("warnings") - for entry in recwarn: - print(entry.message) - if media: - assert len(recwarn) == 1 - assert "will default to `BaseRequest.DEFAULT_NONE` instead of 20" in str( - recwarn[0].message - ) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__ - else: - assert len(recwarn) == 0 - @pytest.mark.skipif(not TEST_WITH_OPT_DEPS, reason="No need to run this twice") class TestHTTPXRequestWithoutRequest: @@ -469,9 +398,7 @@ class TestHTTPXRequestWithoutRequest: def _reset(self): self.test_flag = None - # We parametrize this to make sure that the legacy `proxy_url` argument is still supported - @pytest.mark.parametrize("proxy_argument", ["proxy", "proxy_url"]) - def test_init(self, monkeypatch, proxy_argument): + def test_init(self, monkeypatch): @dataclass class Client: timeout: object @@ -492,32 +419,20 @@ class Client: assert request._client.http1 is True assert not request._client.http2 - kwargs = { - "connection_pool_size": 42, - proxy_argument: "proxy", - "connect_timeout": 43, - "read_timeout": 44, - "write_timeout": 45, - "pool_timeout": 46, - } - request = HTTPXRequest(**kwargs) + request = HTTPXRequest( + connection_pool_size=42, + proxy="proxy", + connect_timeout=43, + read_timeout=44, + write_timeout=45, + pool_timeout=46, + ) assert request._client.proxy == "proxy" assert request._client.limits == httpx.Limits( max_connections=42, max_keepalive_connections=42 ) assert request._client.timeout == httpx.Timeout(connect=43, read=44, write=45, pool=46) - def test_proxy_mutually_exclusive(self): - with pytest.raises(ValueError, match="mutually exclusive"): - HTTPXRequest(proxy="proxy", proxy_url="proxy_url") - - def test_proxy_url_deprecation_warning(self, recwarn): - HTTPXRequest(proxy_url="http://127.0.0.1:3128") - assert len(recwarn) == 1 - assert recwarn[0].category is PTBDeprecationWarning - assert "`proxy_url` is deprecated" in str(recwarn[0].message) - assert recwarn[0].filename == __file__, "incorrect stacklevel" - async def test_multiple_inits_and_shutdowns(self, monkeypatch): self.test_flag = defaultdict(int) @@ -728,7 +643,7 @@ async def request(_, **kwargs): @pytest.mark.parametrize("media", [True, False]) async def test_do_request_write_timeout( - self, monkeypatch, media, httpx_request, input_media_photo, recwarn # noqa: F811 + self, monkeypatch, media, httpx_request, input_media_photo # noqa: F811 ): async def request(_, **kwargs): self.test_flag = kwargs.get("timeout") @@ -753,10 +668,6 @@ async def request(_, **kwargs): 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) - # Just for double-checking, since warnings are issued for implementations of BaseRequest - # other than HTTPXRequest - assert len(recwarn) == 0 - @pytest.mark.parametrize("init", [True, False]) async def test_setting_media_write_timeout( self, monkeypatch, init, input_media_photo, recwarn # noqa: F811 diff --git a/tests/request/test_requestparameter.py b/tests/request/test_requestparameter.py index 9082a58eae2..7e521b01229 100644 --- a/tests/request/test_requestparameter.py +++ b/tests/request/test_requestparameter.py @@ -21,7 +21,17 @@ 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 @@ -176,6 +186,42 @@ 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"], "static") expected = input_sticker.to_dict() @@ -184,6 +230,36 @@ def test_from_input_inputsticker(self): 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_bot.py b/tests/test_bot.py index 29474ef2bed..16c878dd29c 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -38,7 +38,6 @@ BotDescription, BotName, BotShortDescription, - BusinessConnection, CallbackQuery, Chat, ChatAdministratorRights, @@ -95,7 +94,7 @@ 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_ACTIONS @@ -402,6 +401,21 @@ async def shutdown(): 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, @@ -528,7 +542,7 @@ 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, @@ -681,7 +695,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( @@ -976,7 +990,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): @@ -2257,6 +2271,10 @@ async def do_request(self_, *args, **kwargs) -> tuple[int, bytes]: ) 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) @@ -2271,7 +2289,7 @@ async def do_request(self_, *args, **kwargs) -> tuple[int, bytes]: assert test_flag == ( DEFAULT_NONE, DEFAULT_NONE, - 20, + DEFAULT_NONE, DEFAULT_NONE, ) @@ -2354,26 +2372,6 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): monkeypatch.setattr(offline_bot.request, "post", make_assertion) assert await offline_bot.send_message(2, "text", allow_paid_broadcast=42) - async def test_get_business_connection(self, offline_bot, monkeypatch): - bci = "42" - user = User(1, "first", False) - user_chat_id = 1 - date = dtm.datetime.utcnow() - can_reply = True - is_enabled = True - bc = BusinessConnection(bci, user, user_chat_id, date, can_reply, is_enabled).to_json() - - async def do_request(*args, **kwargs): - data = kwargs.get("request_data") - obj = data.parameters.get("business_connection_id") - if obj == 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=bci) - assert isinstance(obj, BusinessConnection) - async def test_send_chat_action_all_args(self, bot, chat_id, monkeypatch): async def make_assertion(*args, **_): kwargs = args[1] @@ -2387,6 +2385,61 @@ async def make_assertion(*args, **_): 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): @@ -3221,36 +3274,6 @@ async def test_get_updates(self, bot): if updates: assert isinstance(updates[0], Update) - @pytest.mark.parametrize("bot_class", [Bot, ExtBot]) - async def test_get_updates_read_timeout_deprecation_warning( - self, bot, recwarn, monkeypatch, bot_class - ): - # Using the normal HTTPXRequest should not issue any warnings - await bot.get_updates() - assert len(recwarn) == 0 - - # Now let's test deprecation warning when using get_updates for other BaseRequest - # subclasses (we just monkeypatch the existing HTTPXRequest for this) - read_timeout = None - - async def catch_timeouts(*args, **kwargs): - nonlocal read_timeout - read_timeout = kwargs.get("read_timeout") - return HTTPStatus.OK, b'{"ok": "True", "result": {}}' - - monkeypatch.setattr(HTTPXRequest, "read_timeout", BaseRequest.read_timeout) - monkeypatch.setattr(HTTPXRequest, "do_request", catch_timeouts) - - bot = bot_class(get_updates_request=HTTPXRequest(), token=bot.token) - await bot.get_updates() - - assert len(recwarn) == 1 - assert "does not override the property `read_timeout`" in str(recwarn[0].message) - assert recwarn[0].category is PTBDeprecationWarning - assert recwarn[0].filename == __file__, "wrong stacklevel" - - assert read_timeout == 2 - @pytest.mark.parametrize( ("read_timeout", "timeout", "expected"), [ @@ -4211,7 +4234,7 @@ 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 diff --git a/tests/test_business.py b/tests/test_business_classes.py similarity index 63% rename from tests/test_business.py rename to tests/test_business_classes.py index d8e976a9144..aabf60064c6 100644 --- a/tests/test_business.py +++ b/tests/test_business_classes.py @@ -32,7 +32,9 @@ Sticker, User, ) +from telegram._business import BusinessBotRights from telegram._utils.datetime import UTC, to_timestamp +from telegram.warnings import PTBDeprecationWarning from tests.auxil.slots import mro_slots @@ -41,7 +43,20 @@ class BusinessTestBase: 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" @@ -60,7 +75,27 @@ class BusinessTestBase: @pytest.fixture(scope="module") -def business_connection(): +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, @@ -68,6 +103,7 @@ def business_connection(): BusinessTestBase.date, BusinessTestBase.can_reply, BusinessTestBase.is_enabled, + rights=business_bot_rights, ) @@ -113,6 +149,90 @@ def business_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 @@ -120,7 +240,7 @@ def test_slots(self, business_connection): 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): + def test_de_json(self, business_bot_rights): json_dict = { "id": self.id_, "user": self.user.to_dict(), @@ -128,6 +248,7 @@ def test_de_json(self): "date": to_timestamp(self.date), "can_reply": self.can_reply, "is_enabled": self.is_enabled, + "rights": business_bot_rights.to_dict(), } bc = BusinessConnection.de_json(json_dict, None) assert bc.id == self.id_ @@ -136,10 +257,11 @@ def test_de_json(self): assert bc.date == self.date assert bc.can_reply == self.can_reply 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): + 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(), @@ -147,6 +269,7 @@ def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): "date": to_timestamp(self.date), "can_reply": self.can_reply, "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) @@ -160,25 +283,52 @@ def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): assert chat_bot_raw.date.tzinfo == UTC assert date_offset_tz == date_offset - def test_to_dict(self, business_connection): + 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["can_reply"] == self.can_reply assert bc_dict["is_enabled"] == self.is_enabled + assert bc_dict["rights"] == business_bot_rights.to_dict() - def test_equality(self): + def test_equality(self, business_bot_rights): bc1 = BusinessConnection( - self.id_, self.user, self.user_chat_id, self.date, self.can_reply, self.is_enabled + self.id_, + self.user, + self.user_chat_id, + self.date, + self.can_reply, + self.is_enabled, + rights=business_bot_rights, ) bc2 = BusinessConnection( - self.id_, self.user, self.user_chat_id, self.date, self.can_reply, self.is_enabled + self.id_, + self.user, + self.user_chat_id, + self.date, + self.can_reply, + self.is_enabled, + rights=business_bot_rights, ) bc3 = BusinessConnection( - "321", self.user, self.user_chat_id, self.date, self.can_reply, self.is_enabled + "321", + self.user, + self.user_chat_id, + self.date, + self.can_reply, + self.is_enabled, + rights=business_bot_rights, + ) + bc4 = BusinessConnection( + self.id_, + self.user, + self.user_chat_id, + self.date, + self.can_reply, + self.is_enabled, + rights=BusinessBotRights(), ) assert bc1 == bc2 @@ -187,6 +337,35 @@ def test_equality(self): assert bc1 != bc3 assert hash(bc1) != hash(bc3) + assert bc1 != bc4 + assert hash(bc1) != hash(bc4) + + def test_can_reply_argument_property_deprecation(self, business_connection): + with pytest.warns(PTBDeprecationWarning, match=r"9\.0.*can\_reply") as record: + assert BusinessConnection( + id=self.id_, + user=self.user, + user_chat_id=self.user_chat_id, + date=self.date, + can_reply=True, + is_enabled=self.is_enabled, + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + + with pytest.warns(PTBDeprecationWarning, match=r"9\.0.*can\_reply") as record: + assert business_connection.can_reply is self.can_reply + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + + def test_is_enabled_remains_required(self): + with pytest.raises(TypeError): + BusinessConnection( + id=self.id_, user=self.user, user_chat_id=self.user_chat_id, date=self.date + ) + class TestBusinessMessagesDeleted(BusinessTestBase): def test_slots(self, business_messages_deleted): diff --git a/tests/test_business_methods.py b/tests/test_business_methods.py new file mode 100644 index 00000000000..13017eca8e6 --- /dev/null +++ b/tests/test_business_methods.py @@ -0,0 +1,600 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# 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 ( + BusinessConnection, + Chat, + InputProfilePhotoStatic, + InputStoryContentPhoto, + MessageEntity, + StarAmount, + Story, + StoryAreaTypeLink, + StoryAreaTypeUniqueGift, + User, +) +from telegram._files.sticker import Sticker +from telegram._gifts import AcceptedGiftTypes, Gift +from telegram._ownedgift import OwnedGiftRegular, OwnedGifts +from telegram._utils.datetime import UTC +from telegram._utils.defaultvalue import DEFAULT_NONE +from telegram.constants import InputProfilePhotoType, InputStoryContentType +from tests.auxil.files import data_file + + +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() + can_reply = True + is_enabled = True + bc = BusinessConnection( + self.bci, user, user_chat_id, date, can_reply, is_enabled + ).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_unique") 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_unique=bool_param, + sort_by_price=bool_param, + offset=offset, + limit=limit, + ) + assert isinstance(obj, OwnedGifts) + + 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) + + 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) + + 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) diff --git a/tests/test_chat.py b/tests/test_chat.py index 5e01ad526a4..8e901fb91bf 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -1330,15 +1330,7 @@ async def make_assertion_channel(*_, **kwargs): and kwargs["text_entities"] == "text_entities" ) - # TODO discuss if better way exists - # tags: deprecated 21.11 - with pytest.raises( - Exception, - match="Default for argument gift_id does not match the default of the Bot method.", - ): - assert check_shortcut_signature( - Chat.send_gift, Bot.send_gift, ["user_id", "chat_id"], [] - ) + 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"] ) @@ -1361,6 +1353,28 @@ async def make_assertion_channel(*_, **kwargs): 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 ( @@ -1392,6 +1406,27 @@ async def make_assertion(*_, **kwargs): 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" + ) + def test_mention_html(self): chat = Chat(id=1, type="foo") with pytest.raises(TypeError, match="Can not create a mention to a private group chat"): diff --git a/tests/test_chatfullinfo.py b/tests/test_chatfullinfo.py index 11373567c9f..dff26aa7398 100644 --- a/tests/test_chatfullinfo.py +++ b/tests/test_chatfullinfo.py @@ -34,8 +34,10 @@ ReactionTypeCustomEmoji, ReactionTypeEmoji, ) +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 @@ -46,6 +48,7 @@ def chat_full_info(bot): 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, @@ -140,6 +143,8 @@ class ChatFullInfoTestBase: first_name = "first_name" last_name = "last_name" can_send_paid_media = True + can_send_gift = True + accepted_gift_types = AcceptedGiftTypes(True, True, True, True) class TestChatFullInfoWithoutRequest(ChatFullInfoTestBase): @@ -158,6 +163,8 @@ def test_de_json(self, offline_bot): "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(), + "can_send_gift": self.can_send_gift, "sticker_set_name": self.sticker_set_name, "can_set_sticker_set": self.can_set_sticker_set, "permissions": self.permissions.to_dict(), @@ -195,10 +202,12 @@ def test_de_json(self, offline_bot): "can_send_paid_media": self.can_send_paid_media, } 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 @@ -245,6 +254,7 @@ def test_de_json_localization(self, offline_bot, raw_bot, tz_bot): "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) @@ -312,15 +322,46 @@ def test_to_dict(self, chat_full_info): 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 + def test_accepted_gift_types_is_required_argument(self): + with pytest.raises(TypeError, match="`accepted_gift_type` is a required argument"): + ChatFullInfo( + id=123, + type=Chat.PRIVATE, + accent_color_id=1, + max_reaction_count=2, + can_send_gift=True, + ) + + def test_can_send_gift_deprecation_warning(self): + with pytest.warns( + PTBDeprecationWarning, + match="'can_send_gift' was replaced by 'accepted_gift_types' in Bot API 9.0", + ): + chat_full_info = ChatFullInfo( + id=123, + type=Chat.PRIVATE, + accent_color_id=1, + max_reaction_count=2, + accepted_gift_types=self.accepted_gift_types, + can_send_gift=self.can_send_gift, + ) + with pytest.warns( + PTBDeprecationWarning, + match="Bot API 9.0 renamed the attribute 'can_send_gift' to 'accepted_gift_types'", + ): + chat_full_info.can_send_gift + 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_constants.py b/tests/test_constants.py index 3cd9e56e7ab..b97cc4f8eac 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -203,6 +203,7 @@ def is_type_attribute(name: str) -> bool: "via_bot", "is_from_offline", "show_caption_above_media", + "paid_star_count", } @pytest.mark.parametrize( diff --git a/tests/test_enum_types.py b/tests/test_enum_types.py index ef3729061bf..36a823eda46 100644 --- a/tests/test_enum_types.py +++ b/tests/test_enum_types.py @@ -32,6 +32,7 @@ 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")), } diff --git a/tests/test_gifts.py b/tests/test_gifts.py index 5af1dc58cf1..2b676a6ee89 100644 --- a/tests/test_gifts.py +++ b/tests/test_gifts.py @@ -20,7 +20,8 @@ import pytest -from telegram import BotCommand, Gift, Gifts, MessageEntity, Sticker +from telegram import BotCommand, Gift, GiftInfo, Gifts, MessageEntity, Sticker +from telegram._gifts import AcceptedGiftTypes from telegram._utils.defaultvalue import DEFAULT_NONE from telegram.request import RequestData from tests.auxil.slots import mro_slots @@ -135,40 +136,8 @@ def test_equality(self, gift): ], ids=["string", "Gift"], ) - async def test_send_gift(self, offline_bot, gift, monkeypatch): - # 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): - user_id = request_data.parameters["user_id"] == "user_id" - 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 user_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( - "user_id", - gift, - "text", - text_parse_mode="text_parse_mode", - text_entities=text_entities, - pay_for_upgrade=True, - ) - @pytest.mark.parametrize("id_name", ["user_id", "chat_id"]) - async def test_send_gift_user_chat_id(self, offline_bot, gift, monkeypatch, id_name): - # Only here because we have to temporarily mark gift_id as optional. - # tags: deprecated 21.11 - + 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"), @@ -177,7 +146,7 @@ async def test_send_gift_user_chat_id(self, offline_bot, gift, monkeypatch, id_n 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"] == "some_id" + 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"] == [ @@ -189,18 +158,14 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): monkeypatch.setattr(offline_bot.request, "post", make_assertion) assert await offline_bot.send_gift( - gift_id=gift, - text="text", + gift, + "text", text_parse_mode="text_parse_mode", text_entities=text_entities, pay_for_upgrade=True, **{id_name: id_name}, ) - async def test_send_gift_without_gift_id(self, offline_bot): - with pytest.raises(TypeError, match="Missing required argument `gift_id`."): - await offline_bot.send_gift() - @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) @pytest.mark.parametrize( ("passed_value", "expected_value"), @@ -327,3 +292,190 @@ 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, + ) + + +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 + + +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, + } + 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 + + 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 + + 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, + ) + + +class AcceptedGiftTypesTestBase: + unlimited_gifts = False + limited_gifts = True + unique_gifts = True + premium_subscription = True + + +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, + } + 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 + + 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 + + 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 + ) + c = AcceptedGiftTypes( + not self.unlimited_gifts, + self.limited_gifts, + self.unique_gifts, + self.premium_subscription, + ) + 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_message.py b/tests/test_message.py index 1bed96737d4..e145720d705 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -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/]. -import contextlib import datetime as dtm from copy import copy @@ -51,6 +50,7 @@ MessageOriginChat, PaidMediaInfo, PaidMediaPreview, + PaidMessagePriceChanged, PassportData, PhotoSize, Poll, @@ -76,6 +76,15 @@ Voice, WebAppData, ) +from telegram._gifts import Gift, GiftInfo +from telegram._uniquegift import ( + UniqueGift, + UniqueGiftBackdrop, + UniqueGiftBackdropColors, + UniqueGiftInfo, + UniqueGiftModel, + UniqueGiftSymbol, +) from telegram._utils.datetime import UTC from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import ODVInput @@ -233,6 +242,42 @@ def message(bot): {"message_thread_id": 123}, {"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( + "human_readable_name", + "unique_name", + 2, + UniqueGiftModel( + "model_name", + Sticker("file_id1", "file_unique_id1", 512, 512, False, False, "regular"), + 10, + ), + UniqueGiftSymbol( + "symbol_name", + Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + 20, + ), + 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)], @@ -284,6 +329,8 @@ def message(bot): {"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)}, ], ids=[ "reply", @@ -338,6 +385,8 @@ def message(bot): "message_thread_id", "users_shared", "chat_shared", + "gift", + "unique_gift", "giveaway", "giveaway_created", "giveaway_winners", @@ -357,6 +406,8 @@ def message(bot): "show_caption_above_media", "paid_media", "refunded_payment", + "paid_star_count", + "paid_message_price_changed", ], ) def message_params(bot, request): @@ -448,18 +499,7 @@ class TestMessageWithoutRequest(MessageTestBase): async def check_quote_parsing( message: Message, method, bot_method_name: str, args, monkeypatch ): - """Used in testing reply_* below. Makes sure that quote and do_quote are handled - correctly - """ - with contextlib.suppress(TypeError): - # for newer methods that don't have the deprecated argument - with pytest.raises(ValueError, match="`quote` and `do_quote` are mutually exclusive"): - await method(*args, quote=True, do_quote=True) - - # for newer methods that don't have the deprecated argument - with pytest.warns(PTBDeprecationWarning, match="`quote` parameter is deprecated"): - await method(*args, quote=True) - + """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.", @@ -471,17 +511,13 @@ async def make_assertion(*args, **kwargs): monkeypatch.setattr(message.get_bot(), bot_method_name, make_assertion) - for param in ("quote", "do_quote"): - with contextlib.suppress(TypeError): - # for newer methods that don't have the deprecated argument - chat_id, reply_parameters = await method(*args, **{param: True}) - 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 != message.message_id: - pytest.fail( - f"reply_parameters is {reply_parameters} " - "but should be {message.message_id}" - ) + for value in (True, False): + chat_id, reply_parameters = await method(*args, do_quote=value) + 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) if value else None + if reply_parameters != expected: + pytest.fail(f"reply_parameters is {reply_parameters} but should be {expected}") input_chat_id = object() input_reply_parameters = ReplyParameters(message_id=1, chat_id=42) @@ -1451,7 +1487,7 @@ async def make_assertion(*_, **kwargs): Message.reply_text, Bot.send_message, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1493,7 +1529,7 @@ async def make_assertion(*_, **kwargs): Message.reply_markdown, Bot.send_message, ["chat_id", "parse_mode", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1542,7 +1578,7 @@ async def make_assertion(*_, **kwargs): Message.reply_markdown_v2, Bot.send_message, ["chat_id", "parse_mode", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1594,7 +1630,7 @@ async def make_assertion(*_, **kwargs): Message.reply_html, Bot.send_message, ["chat_id", "parse_mode", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1631,7 +1667,7 @@ async def make_assertion(*_, **kwargs): Message.reply_media_group, Bot.send_media_group, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1673,7 +1709,7 @@ async def make_assertion(*_, **kwargs): Message.reply_photo, Bot.send_photo, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1707,7 +1743,7 @@ async def make_assertion(*_, **kwargs): Message.reply_audio, Bot.send_audio, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1741,7 +1777,7 @@ async def make_assertion(*_, **kwargs): Message.reply_document, Bot.send_document, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1775,7 +1811,7 @@ async def make_assertion(*_, **kwargs): Message.reply_animation, Bot.send_animation, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1809,7 +1845,7 @@ async def make_assertion(*_, **kwargs): Message.reply_sticker, Bot.send_sticker, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1843,7 +1879,7 @@ async def make_assertion(*_, **kwargs): Message.reply_video, Bot.send_video, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1877,7 +1913,7 @@ async def make_assertion(*_, **kwargs): Message.reply_video_note, Bot.send_video_note, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1911,7 +1947,7 @@ async def make_assertion(*_, **kwargs): Message.reply_voice, Bot.send_voice, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1945,7 +1981,7 @@ async def make_assertion(*_, **kwargs): Message.reply_location, Bot.send_location, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -1979,7 +2015,7 @@ async def make_assertion(*_, **kwargs): Message.reply_venue, Bot.send_venue, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -2013,7 +2049,7 @@ async def make_assertion(*_, **kwargs): Message.reply_contact, Bot.send_contact, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -2048,7 +2084,7 @@ async def make_assertion(*_, **kwargs): Message.reply_poll, Bot.send_poll, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -2082,7 +2118,7 @@ async def make_assertion(*_, **kwargs): Message.reply_dice, Bot.send_dice, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -2154,7 +2190,7 @@ async def make_assertion(*_, **kwargs): Message.reply_game, Bot.send_game, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -2197,7 +2233,7 @@ async def make_assertion(*_, **kwargs): Message.reply_invoice, Bot.send_invoice, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_id"], + ["do_quote", "reply_to_message_id"], annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, ) assert await check_shortcut_call( @@ -2326,7 +2362,7 @@ async def make_assertion(*_, **kwargs): Message.reply_copy, Bot.copy_message, ["chat_id", "reply_to_message_id", "business_connection_id"], - ["quote", "do_quote", "reply_to_message_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") @@ -2836,6 +2872,31 @@ 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. diff --git a/tests/test_modules.py b/tests/test_modules.py index 086e7fe5a8f..eead2b183f4 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -23,9 +23,11 @@ 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 +54,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/arg_type_checker.py b/tests/test_official/arg_type_checker.py index d686d3ff835..4b0e3630691 100644 --- a/tests/test_official/arg_type_checker.py +++ b/tests/test_official/arg_type_checker.py @@ -190,8 +190,6 @@ def check_param_type( or "Unix time" in tg_parameter.param_description ): log("Checking that `%s` is a datetime!\n", ptb_param.name) - if ptb_param.name in PTCE.DATETIME_EXCEPTIONS: - return True, mapped_type # If it's a class, we only accept datetime as the parameter mapped_type = dtm.datetime if is_class else mapped_type | dtm.datetime diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py index 9ada2569dca..40144f803d3 100644 --- a/tests/test_official/exceptions.py +++ b/tests/test_official/exceptions.py @@ -19,7 +19,7 @@ """This module contains exceptions to our API compared to the official API.""" import datetime as dtm -from telegram import Animation, Audio, Document, PhotoSize, Sticker, Video, VideoNote, Voice +from telegram import Animation, Audio, Document, Gift, PhotoSize, Sticker, Video, VideoNote, Voice from tests.test_official.helpers import _get_params_base IGNORED_OBJECTS = ("ResponseParameters",) @@ -47,8 +47,7 @@ class ParamTypeCheckingExceptions: "animation": Animation, "voice": Voice, "sticker": Sticker, - # TODO: Deprecated and will be corrected (and readded) in next major bot API release: - # "gift_id": Gift, + "gift_id": Gift, }, "(delete|set)_sticker.*": { "sticker$": Sticker, @@ -73,8 +72,6 @@ class ParamTypeCheckingExceptions: ("keyboard", True): "KeyboardButton", # + sequence[sequence[str]] ("reaction", False): "ReactionType", # + str ("options", False): "InputPollOption", # + str - # TODO: Deprecated and will be corrected (and removed) in next bot api release - ("file_hashes", True): "list[str]", } # Special cases for other parameters that accept more types than the official API, and are @@ -100,12 +97,25 @@ class ParamTypeCheckingExceptions: "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] + "main_frame_timestamp": float, # actual: Union[float, dtm.timedelta] + }, + "InputSticker": { + "sticker": str, # actual: Union[str, FileInput] + }, + "InputStoryContent.*": { + "photo": str, # actual: Union[str, FileInput] + "video": str, # actual: Union[str, FileInput] + "duration": float, # actual: dtm.timedelta + "cover_frame_timestamp": float, # actual: dtm.timedelta + }, "EncryptedPassportElement": { "data": str, # actual: Union[IdDocumentData, PersonalDetails, ResidentialAddress] }, - # TODO: Deprecated and will be corrected (and removed) in next major PTB - # version: - "send_gift": {"gift_id": str}, # actual: Non optional } # param names ignored in the param type checking in classes for the `tg.Defaults` case. @@ -117,11 +127,6 @@ class ParamTypeCheckingExceptions: # These classes' params are all ODVInput, so we ignore them in the defaults type checking. IGNORED_DEFAULTS_CLASSES = {"LinkPreviewOptions"} - # TODO: Remove this in v22 when it becomes a datetime (also remove from arg_type_checker.py) - DATETIME_EXCEPTIONS = { - "file_date", - } - # Arguments *added* to the official API PTB_EXTRA_PARAMS = { @@ -155,11 +160,19 @@ class ParamTypeCheckingExceptions: "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 + # backwards compatibility for api 9.0 changes + # tags: deprecated NEXT.VERSION, bot api 9.0 + "BusinessConnection": {"can_reply"}, + "ChatFullInfo": {"can_send_gift"}, + "InputProfilePhoto": {"type"}, # attributes common to all subclasses } @@ -188,6 +201,10 @@ def ptb_extra_params(object_name: str) -> set[str]: 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"}, } @@ -203,8 +220,11 @@ def ptb_ignored_params(object_name: str) -> set[str]: "send_venue": {"latitude", "longitude", "title", "address"}, "send_contact": {"phone_number", "first_name"}, # ----> - # here for backwards compatibility. Todo: remove on next bot api release - "send_gift": {"gift_id"}, + # backwards compatibility for api 9.0 changes + # tags: deprecated NEXT.VERSION, bot api 9.0 + "BusinessConnection": {"is_enabled"}, + "ChatFullInfo": {"accepted_gift_types"}, + "TransactionPartnerUser": {"transaction_type"}, } diff --git a/tests/test_ownedgift.py b/tests/test_ownedgift.py new file mode 100644 index 00000000000..b37794f3483 --- /dev/null +++ b/tests/test_ownedgift.py @@ -0,0 +1,461 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# 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 collections.abc import Sequence +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( + 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 + + +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, + } + 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, + ) + + +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, + } + 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.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 + + 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, + ) + + +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, + } + 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.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 + + 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: Sequence[OwnedGifts] = [ + 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( + 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_paidmessagepricechanged.py b/tests/test_paidmessagepricechanged.py new file mode 100644 index 00000000000..b97eafbab93 --- /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-2025 +# 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_storyarea.py b/tests/test_storyarea.py new file mode 100644 index 00000000000..dd9d043965e --- /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-2025 +# 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_uniquegift.py b/tests/test_uniquegift.py new file mode 100644 index 00000000000..051974b959b --- /dev/null +++ b/tests/test_uniquegift.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2025 +# 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, + Sticker, + UniqueGift, + UniqueGiftBackdrop, + UniqueGiftBackdropColors, + UniqueGiftInfo, + UniqueGiftModel, + UniqueGiftSymbol, +) +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def unique_gift(): + return UniqueGift( + base_name=UniqueGiftTestBase.base_name, + name=UniqueGiftTestBase.name, + number=UniqueGiftTestBase.number, + model=UniqueGiftTestBase.model, + symbol=UniqueGiftTestBase.symbol, + backdrop=UniqueGiftTestBase.backdrop, + ) + + +class UniqueGiftTestBase: + 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, + ) + + +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 = { + "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(), + } + 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 + + def test_to_dict(self, unique_gift): + gift_dict = unique_gift.to_dict() + + assert isinstance(gift_dict, dict) + 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() + + def test_equality(self, unique_gift): + a = unique_gift + b = UniqueGift( + self.base_name, + self.name, + self.number, + self.model, + self.symbol, + self.backdrop, + ) + c = UniqueGift( + "other_base_name", + self.name, + self.number, + self.model, + self.symbol, + self.backdrop, + ) + 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_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, + ) + + +class UniqueGiftInfoTestBase: + gift = UniqueGift( + "human_readable_name", + "unique_name", + 10, + UniqueGiftModel( + name="model_name", + sticker=Sticker("file_id1", "file_unique_id1", 512, 512, False, False, "regular"), + rarity_per_mille=10, + ), + UniqueGiftSymbol( + name="symbol_name", + sticker=Sticker("file_id2", "file_unique_id2", 512, 512, True, True, "mask"), + rarity_per_mille=20, + ), + 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 + + +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, + } + 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 + + 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 + + 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) diff --git a/tests/test_user.py b/tests/test_user.py index 815785bcb7f..490aa6052ec 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -731,15 +731,7 @@ async def make_assertion(*_, **kwargs): and kwargs["text_entities"] == "text_entities" ) - # TODO discuss if better way exists - # tags: deprecated 21.11 - with pytest.raises( - Exception, - match="Default for argument gift_id does not match the default of the Bot method.", - ): - assert check_shortcut_signature( - user.send_gift, Bot.send_gift, ["user_id", "chat_id"], [] - ) + 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"] ) @@ -753,6 +745,36 @@ async def make_assertion(*_, **kwargs): 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 ( diff --git a/tests/test_warnings.py b/tests/test_warnings.py index 18be85689ed..555d5dcb132 100644 --- a/tests/test_warnings.py +++ b/tests/test_warnings.py @@ -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 @@ -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