diff --git a/.github/renovate.json5 b/.github/renovate.json5 index c52377794c0..a1682ed6c40 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -26,6 +26,9 @@ // Enable automerge globally: "automerge": true, + // Only upgrade packages if 7 days have passed: + "minimumReleaseAge": "7 days", + // Group package updates together: "packageRules": [ // Linting dependencies in pyproject.toml in sync with the pre-commit-config hooks: diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index cba6d41dc3c..204b8d5c013 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -31,10 +31,10 @@ jobs: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: # Install a specific version of uv. - version: "0.10.10" + version: "0.11.16" # Install 3.13: python-version: 3.13 diff --git a/.github/workflows/docs-linkcheck.yml b/.github/workflows/docs-linkcheck.yml index da816bfb2d7..02df1034ebd 100644 --- a/.github/workflows/docs-linkcheck.yml +++ b/.github/workflows/docs-linkcheck.yml @@ -35,7 +35,7 @@ jobs: - name: Upload linkcheck output # Run also if the previous steps failed if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: linkcheck-output path: docs/build/html/output.* diff --git a/.github/workflows/gha_security.yml b/.github/workflows/gha_security.yml index 5807b28be53..ccbc9226473 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@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - name: Run zizmor run: uvx zizmor --persona=pedantic --format sarif . > results.sarif env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload SARIF file - uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5 + uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: sarif_file: results.sarif category: zizmor diff --git a/.github/workflows/release_pypi.yml b/.github/workflows/release_pypi.yml index d394e462866..402bcd42613 100644 --- a/.github/workflows/release_pypi.yml +++ b/.github/workflows/release_pypi.yml @@ -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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: python-package-distributions path: dist/ @@ -60,7 +60,7 @@ jobs: name: python-package-distributions path: dist/ - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 compute-signatures: name: Compute SHA1 Sums and Sign with Sigstore @@ -92,7 +92,7 @@ jobs: ./dist/*.tar.gz ./dist/*.whl - name: Store the distribution packages and signatures - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: python-package-distributions-and-signatures path: dist/ diff --git a/.github/workflows/release_test_pypi.yml b/.github/workflows/release_test_pypi.yml index d7376a50535..0d89a14c289 100644 --- a/.github/workflows/release_test_pypi.yml +++ b/.github/workflows/release_test_pypi.yml @@ -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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: python-package-distributions path: dist/ @@ -60,7 +60,7 @@ jobs: name: python-package-distributions path: dist/ - name: Publish to Test PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: repository-url: https://test.pypi.org/legacy/ @@ -94,7 +94,7 @@ jobs: ./dist/*.tar.gz ./dist/*.whl - name: Store the distribution packages and signatures - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: python-package-distributions-and-signatures path: dist/ diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 48a11efbd66..ca446f61835 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -12,7 +12,7 @@ jobs: # For adding labels and closing issues: write steps: - - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: # PRs never get stale days-before-stale: 3 diff --git a/.github/workflows/type_completeness_monthly.yml b/.github/workflows/type_completeness_monthly.yml index 30a8a1c8a3b..f8711035915 100644 --- a/.github/workflows/type_completeness_monthly.yml +++ b/.github/workflows/type_completeness_monthly.yml @@ -18,7 +18,7 @@ jobs: python-version: 3.12 pyright-version: ~=1.1.367 - name: Check Output - uses: jannekem/run-python-script-action@bbfca66c612a28f3eeca0ae40e1f810265e2ea68 # v1.7 + uses: jannekem/run-python-script-action@9d8e2e0878d575fb6073277f38ce3f10ebf4f059 # v1.8 env: TYPE_COMPLETENESS: ${{ steps.pyright-type-completeness.outputs.base-completeness-score }} with: diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 16965b643d8..c9bb791e1d0 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -19,7 +19,7 @@ jobs: runs-on: ${{matrix.os}} strategy: matrix: - python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14', '3.15.0-beta.2'] os: [ubuntu-latest, windows-latest, macos-latest] include: - python-version: '3.14t' @@ -87,15 +87,16 @@ jobs: .test_report_optionals_junit.xml - name: Submit coverage - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 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@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 if: ${{ !cancelled() }} with: files: .test_report_no_optionals_junit.xml,.test_report_optionals_junit.xml token: ${{ secrets.CODECOV_TOKEN }} + report_type: test_results diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 81aba867ac4..d8b04f15017 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.15.6' + rev: 'v0.15.15' hooks: # Run the linter: - id: ruff-check @@ -23,7 +23,7 @@ repos: - . # this basically does `pip install -e .` priority: 10 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.19.1 + rev: v1.20.2 hooks: - id: mypy name: mypy-ptb diff --git a/AUTHORS.rst b/AUTHORS.rst index 8851cee1668..848677d088e 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -75,10 +75,12 @@ The following wonderful people contributed directly or indirectly to this projec - `jossalgon `_ - `JRoot3D `_ - `Juan Cuevas ` +- `karin0 `_ - `kenjitagawa `_ - `kennethcheo `_ - `Kirill Vasin `_ - `Kjwon15 `_ +- `Krishna Chaitanya Balusu `_ - `Li-aung Yip `_ - `locobott `_ - `Loo Zheng Yuan `_ diff --git a/README.rst b/README.rst index bfdedc88a0b..44cbe11f12e 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-9.5-blue?logo=telegram +.. image:: https://img.shields.io/badge/Bot%20API-10.0-blue?logo=telegram :target: https://core.telegram.org/bots/api-changelog :alt: Supported Bot API version @@ -77,7 +77,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 **9.5** are natively supported by this library. +All types and methods of the Telegram Bot API **10.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.8_2026-06-12/5125.filter-fix.toml b/changes/22.8_2026-06-12/5125.filter-fix.toml new file mode 100644 index 00000000000..b76b3683e66 --- /dev/null +++ b/changes/22.8_2026-06-12/5125.filter-fix.toml @@ -0,0 +1,5 @@ +bugfixes = "Fixed incorrect isinstance check in class telegram.ext.filters._MergedFilter for or_filter." +[[pull_requests]] +uid = "5125" +author_uids = ["gistrec"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5177.fg9Y3ZSboi94mttv3eas2L.toml b/changes/22.8_2026-06-12/5177.fg9Y3ZSboi94mttv3eas2L.toml new file mode 100644 index 00000000000..c89b0dd3f90 --- /dev/null +++ b/changes/22.8_2026-06-12/5177.fg9Y3ZSboi94mttv3eas2L.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.15.7" +[[pull_requests]] +uid = "5177" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5183.Enoyw5yoT4S9uRxn7v5ud5.toml b/changes/22.8_2026-06-12/5183.Enoyw5yoT4S9uRxn7v5ud5.toml new file mode 100644 index 00000000000..f5eb4f4671c --- /dev/null +++ b/changes/22.8_2026-06-12/5183.Enoyw5yoT4S9uRxn7v5ud5.toml @@ -0,0 +1,5 @@ +internal = "Migrate from deprecated test-results-action to codecov-action" +[[pull_requests]] +uid = "5183" +author_uids = ["Krishnachaitanyakc"] +closes_threads = ["5158"] diff --git a/changes/22.8_2026-06-12/5184.YKdeCfWDDXk8v3DJx8nTeg.toml b/changes/22.8_2026-06-12/5184.YKdeCfWDDXk8v3DJx8nTeg.toml new file mode 100644 index 00000000000..fe9d3496f80 --- /dev/null +++ b/changes/22.8_2026-06-12/5184.YKdeCfWDDXk8v3DJx8nTeg.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.15.8" +[[pull_requests]] +uid = "5184" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5185.VTFw6hNJ4A9mGayu2rduUt.toml b/changes/22.8_2026-06-12/5185.VTFw6hNJ4A9mGayu2rduUt.toml new file mode 100644 index 00000000000..4b2a625b5e8 --- /dev/null +++ b/changes/22.8_2026-06-12/5185.VTFw6hNJ4A9mGayu2rduUt.toml @@ -0,0 +1,5 @@ +dependencies = "Update dependency cryptography to v46.0.6 [SECURITY]" +[[pull_requests]] +uid = "5185" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5190.5mL9K6trXT6Y4vLgqxy9jE.toml b/changes/22.8_2026-06-12/5190.5mL9K6trXT6Y4vLgqxy9jE.toml new file mode 100644 index 00000000000..f7f2aa88f99 --- /dev/null +++ b/changes/22.8_2026-06-12/5190.5mL9K6trXT6Y4vLgqxy9jE.toml @@ -0,0 +1,5 @@ +other = "Update codecov/codecov-action action to v5.5.4" +[[pull_requests]] +uid = "5190" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5191.B6JK8KhgoqqawRrrWaSCDH.toml b/changes/22.8_2026-06-12/5191.B6JK8KhgoqqawRrrWaSCDH.toml new file mode 100644 index 00000000000..af1b24d818d --- /dev/null +++ b/changes/22.8_2026-06-12/5191.B6JK8KhgoqqawRrrWaSCDH.toml @@ -0,0 +1,5 @@ +internal = "Update astral-sh/setup-uv action to v7.6.0" +[[pull_requests]] +uid = "5191" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5192.VYDBGMwXeTVyvXESmEnfFa.toml b/changes/22.8_2026-06-12/5192.VYDBGMwXeTVyvXESmEnfFa.toml new file mode 100644 index 00000000000..c8585ba7989 --- /dev/null +++ b/changes/22.8_2026-06-12/5192.VYDBGMwXeTVyvXESmEnfFa.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.11.2" +[[pull_requests]] +uid = "5192" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5193.GqEK23Erwr49GQ79uJho38.toml b/changes/22.8_2026-06-12/5193.GqEK23Erwr49GQ79uJho38.toml new file mode 100644 index 00000000000..26e3560e0a5 --- /dev/null +++ b/changes/22.8_2026-06-12/5193.GqEK23Erwr49GQ79uJho38.toml @@ -0,0 +1,5 @@ +internal = "Update dependency mypy to v1.20.0" +[[pull_requests]] +uid = "5193" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5194.bnCTjH5QVYuyFDp4iCwJoV.toml b/changes/22.8_2026-06-12/5194.bnCTjH5QVYuyFDp4iCwJoV.toml new file mode 100644 index 00000000000..928e99daa28 --- /dev/null +++ b/changes/22.8_2026-06-12/5194.bnCTjH5QVYuyFDp4iCwJoV.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v4.35.1" +[[pull_requests]] +uid = "5194" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5196.7keq7yJhXbMb9RyShLHz4D.toml b/changes/22.8_2026-06-12/5196.7keq7yJhXbMb9RyShLHz4D.toml new file mode 100644 index 00000000000..4d79925fa99 --- /dev/null +++ b/changes/22.8_2026-06-12/5196.7keq7yJhXbMb9RyShLHz4D.toml @@ -0,0 +1,24 @@ +features = """ +Full Support for Bot API 9.6 + +.. warning:: + + - Bot API 9.6 replaces the field ``correct_option_id`` of ``Poll`` with the new field ``correct_option_ids``. The field ``correct_option_id`` is still present in PTB for backward compatibility, but it will be removed in future releases. + + - Bot API 9.6 replaces the argument ``correct_option_id`` of ``Bot.send_poll`` with the new argument ``correct_option_ids``. The argument ``correct_option_id`` is still present in PTB for backward compatibility, but it will be removed in future releases. + + - Bot API 9.6 introduces a now required argument ``persistent_id`` to ``PollOption``. For backward compatibility, the argument is currently still marked as optional in the signature and its presence is enforced through a runtime check. In future versions, this argument will be made required in the signature as well. + + - Bot API 9.6 introduces a now required argument ``option_persistent_ids`` to ``PollAnswer``. For backward compatibility, the argument is currently still marked as optional in the signature and its presence is enforced through a runtime check. In future versions, this argument will be made required in the signature as well. + + - Bot API 9.6 introduces a now required argument ``allows_revoting`` to ``Poll``. For backward compatibility, the argument is currently still marked as optional in the signature and its presence is enforced through a runtime check. In future versions, this argument will be made required in the signature as well. + + + Please make sure to update your code accordingly to avoid potential issues in the future. We recommend using keyword arguments to ensure compatibility with future updates. +""" + +pull_requests = [ + { uid = "5196", author_uid = "harshil21" }, + { uid = "5202", author_uid = "ouyooung" }, + { uid = "5197", author_uid = "harshil21" }, +] diff --git a/changes/22.8_2026-06-12/5198.haofa4LoE6k2WQoAUSizM8.toml b/changes/22.8_2026-06-12/5198.haofa4LoE6k2WQoAUSizM8.toml new file mode 100644 index 00000000000..4d894b19706 --- /dev/null +++ b/changes/22.8_2026-06-12/5198.haofa4LoE6k2WQoAUSizM8.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.15.9" +[[pull_requests]] +uid = "5198" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5199.4qywU9Faav8YhWivBBykyJ.toml b/changes/22.8_2026-06-12/5199.4qywU9Faav8YhWivBBykyJ.toml new file mode 100644 index 00000000000..dc85c2b2c54 --- /dev/null +++ b/changes/22.8_2026-06-12/5199.4qywU9Faav8YhWivBBykyJ.toml @@ -0,0 +1,5 @@ +internal = "Update Mypy to v1.20.1" +[[pull_requests]] +uid = "5199" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5203.jxMZ7aSe7eDKXTvfEdZGzw.toml b/changes/22.8_2026-06-12/5203.jxMZ7aSe7eDKXTvfEdZGzw.toml new file mode 100644 index 00000000000..2534d84cd65 --- /dev/null +++ b/changes/22.8_2026-06-12/5203.jxMZ7aSe7eDKXTvfEdZGzw.toml @@ -0,0 +1,5 @@ +dependencies = "Update dependency cryptography to v46.0.7 [SECURITY]" +[[pull_requests]] +uid = "5203" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5205.KBqhJA9kouNAJmQYNwZHT2.toml b/changes/22.8_2026-06-12/5205.KBqhJA9kouNAJmQYNwZHT2.toml new file mode 100644 index 00000000000..544c91e6df5 --- /dev/null +++ b/changes/22.8_2026-06-12/5205.KBqhJA9kouNAJmQYNwZHT2.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.15.10" +[[pull_requests]] +uid = "5205" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5206.nXEjjZXrYZiWroBfBeQR3N.toml b/changes/22.8_2026-06-12/5206.nXEjjZXrYZiWroBfBeQR3N.toml new file mode 100644 index 00000000000..2cbdb4d446d --- /dev/null +++ b/changes/22.8_2026-06-12/5206.nXEjjZXrYZiWroBfBeQR3N.toml @@ -0,0 +1,5 @@ +internal = "Update dependency pytest to v9.0.3 [SECURITY]" +[[pull_requests]] +uid = "5206" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5209.msPrg8bYnmr89qXR4fr5bG.toml b/changes/22.8_2026-06-12/5209.msPrg8bYnmr89qXR4fr5bG.toml new file mode 100644 index 00000000000..ee623902d8f --- /dev/null +++ b/changes/22.8_2026-06-12/5209.msPrg8bYnmr89qXR4fr5bG.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.15.11" +[[pull_requests]] +uid = "5209" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5217.5AVeFbQnudaXWyZ3TM7E5B.toml b/changes/22.8_2026-06-12/5217.5AVeFbQnudaXWyZ3TM7E5B.toml new file mode 100644 index 00000000000..81f26445f4b --- /dev/null +++ b/changes/22.8_2026-06-12/5217.5AVeFbQnudaXWyZ3TM7E5B.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.15.12" +[[pull_requests]] +uid = "5217" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5220.iAgLEYgfuW46wtvCUx3dhM.toml b/changes/22.8_2026-06-12/5220.iAgLEYgfuW46wtvCUx3dhM.toml new file mode 100644 index 00000000000..81e1d3d1a1f --- /dev/null +++ b/changes/22.8_2026-06-12/5220.iAgLEYgfuW46wtvCUx3dhM.toml @@ -0,0 +1,5 @@ +internal = "Update actions/upload-artifact action to v7.0.1" +[[pull_requests]] +uid = "5220" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5221.UEBLb7KFNwZkZ5fC3CyDBD.toml b/changes/22.8_2026-06-12/5221.UEBLb7KFNwZkZ5fC3CyDBD.toml new file mode 100644 index 00000000000..10aa76f8085 --- /dev/null +++ b/changes/22.8_2026-06-12/5221.UEBLb7KFNwZkZ5fC3CyDBD.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.11.8" +[[pull_requests]] +uid = "5221" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5222.dGHpTKrbVb9VQV8pVv9Lii.toml b/changes/22.8_2026-06-12/5222.dGHpTKrbVb9VQV8pVv9Lii.toml new file mode 100644 index 00000000000..b6f8325899e --- /dev/null +++ b/changes/22.8_2026-06-12/5222.dGHpTKrbVb9VQV8pVv9Lii.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v4.35.2" +[[pull_requests]] +uid = "5222" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5223.6Ttu6UoyzkVQddzKrz947W.toml b/changes/22.8_2026-06-12/5223.6Ttu6UoyzkVQddzKrz947W.toml new file mode 100644 index 00000000000..e58e4ffd443 --- /dev/null +++ b/changes/22.8_2026-06-12/5223.6Ttu6UoyzkVQddzKrz947W.toml @@ -0,0 +1,5 @@ +internal = "Update Mypy to v1.20.2" +[[pull_requests]] +uid = "5223" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5224.YVKcje5xUdXTjJJurmAVVD.toml b/changes/22.8_2026-06-12/5224.YVKcje5xUdXTjJJurmAVVD.toml new file mode 100644 index 00000000000..9a5abdba578 --- /dev/null +++ b/changes/22.8_2026-06-12/5224.YVKcje5xUdXTjJJurmAVVD.toml @@ -0,0 +1,5 @@ +internal = "Update jannekem/run-python-script-action action to v1.8" +[[pull_requests]] +uid = "5224" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5229.87PBN4GFkuAaDhhgFwCYkY.toml b/changes/22.8_2026-06-12/5229.87PBN4GFkuAaDhhgFwCYkY.toml new file mode 100644 index 00000000000..d0435053de4 --- /dev/null +++ b/changes/22.8_2026-06-12/5229.87PBN4GFkuAaDhhgFwCYkY.toml @@ -0,0 +1,33 @@ +features = """ +Full Support for Bot API 10.0 + +.. warning:: + + - Bot API 10.0 introduces a now required argument ``members_only`` to ``Poll``. For backward compatibility, the argument is currently still marked as optional in the signature and its presence is enforced through a runtime check. In future versions, this argument will be made required in the signature as well. + + Please make sure to update your code accordingly to avoid potential issues in the future. We recommend using keyword arguments to ensure compatibility with future updates. +""" + +deprecations = """ +* Deprecated passing the ``filename`` parameter positionally to the classes: + + * ``InputMediaAnimation`` + * ``InputMediaAudio`` + * ``InputMediaPhoto`` + * ``InputMediaDocument`` + * ``InputMediaVideo`` + + Please pass ``filename`` as a keyword argument instead, as this parameter will become keyword-only in the future. + +* Deprecated ``InputPollOption.de_json``. The class ``InputPollOption`` is input only and its ``de_json`` method will be removed in future versions. The Bot API 10.0 ``media`` field of ``InputPollOption`` will not be included for deserialization. + +""" + +pull_requests = [ + { uid = "5229", author_uid = "aelkheir", closes_threads = ["5228"] }, + { uid = "5230", author_uid = "harshil21" }, + { uid = "5235", author_uid = "harshil21" }, + { uid = "5238", author_uid = "harshil21" }, + { uid = "5232", author_uid = "aelkheir" }, + { uid = "5232", author_uid = ["Poolitzer", "Phil9l", "harshil21", "aelkheir"] }, +] diff --git a/changes/22.8_2026-06-12/5233.YJVhiqtdZZ57TBRdNh2swZ.toml b/changes/22.8_2026-06-12/5233.YJVhiqtdZZ57TBRdNh2swZ.toml new file mode 100644 index 00000000000..59e75163c9a --- /dev/null +++ b/changes/22.8_2026-06-12/5233.YJVhiqtdZZ57TBRdNh2swZ.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.15.13" +[[pull_requests]] +uid = "5233" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5237.effective-user-fix.toml b/changes/22.8_2026-06-12/5237.effective-user-fix.toml new file mode 100644 index 00000000000..ae391c5d94d --- /dev/null +++ b/changes/22.8_2026-06-12/5237.effective-user-fix.toml @@ -0,0 +1,5 @@ +bugfixes = "`Update.effective_user` now checks for `channel_post` and `edited_channel_post`" +[[pull_requests]] +uid = "5237" +author_uids = ["karin0"] +closes_threads = ["5236"] diff --git a/changes/22.8_2026-06-12/5240.iJXyH8RNWNNQpC47SfNHzw.toml b/changes/22.8_2026-06-12/5240.iJXyH8RNWNNQpC47SfNHzw.toml new file mode 100644 index 00000000000..24424c4695a --- /dev/null +++ b/changes/22.8_2026-06-12/5240.iJXyH8RNWNNQpC47SfNHzw.toml @@ -0,0 +1,6 @@ +documentation = "Documentation Improvements" + +pull_requests = [ + { uid = "5240", author_uids = ["harshil21", "Poolitzer"] }, + { uid = "5241", author_uids = ["harshil21"] }, +] \ No newline at end of file diff --git a/changes/22.8_2026-06-12/5245.3Xu2uH53fGn822LB5Y7Pqk.toml b/changes/22.8_2026-06-12/5245.3Xu2uH53fGn822LB5Y7Pqk.toml new file mode 100644 index 00000000000..8e2c94aad11 --- /dev/null +++ b/changes/22.8_2026-06-12/5245.3Xu2uH53fGn822LB5Y7Pqk.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.15.14" +[[pull_requests]] +uid = "5245" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5247.3DSJ9C36s7HCYSNTEUnDpT.toml b/changes/22.8_2026-06-12/5247.3DSJ9C36s7HCYSNTEUnDpT.toml new file mode 100644 index 00000000000..ccecdcfd035 --- /dev/null +++ b/changes/22.8_2026-06-12/5247.3DSJ9C36s7HCYSNTEUnDpT.toml @@ -0,0 +1,5 @@ +security = "Disallow packages newer than last 7 days" +[[pull_requests]] +uid = "5247" +author_uids = ["harshil21"] +closes_threads = ["5195"] diff --git a/changes/22.8_2026-06-12/5250.dTuzQhYiw2T8gfvp6Skj89.toml b/changes/22.8_2026-06-12/5250.dTuzQhYiw2T8gfvp6Skj89.toml new file mode 100644 index 00000000000..d18efa8117f --- /dev/null +++ b/changes/22.8_2026-06-12/5250.dTuzQhYiw2T8gfvp6Skj89.toml @@ -0,0 +1,5 @@ +other = "Make instantiation of ``TelegramObject``'s 15% faster" +[[pull_requests]] +uid = "5250" +author_uids = ["harshil21"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5252.Qo4J766e8AEw35gf9gLaJE.toml b/changes/22.8_2026-06-12/5252.Qo4J766e8AEw35gf9gLaJE.toml new file mode 100644 index 00000000000..a5c6c67ea00 --- /dev/null +++ b/changes/22.8_2026-06-12/5252.Qo4J766e8AEw35gf9gLaJE.toml @@ -0,0 +1,5 @@ +internal = "Update Ruff to v0.15.15" +[[pull_requests]] +uid = "5252" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5253.A9BQTN98eeqDPBrVZ674qa.toml b/changes/22.8_2026-06-12/5253.A9BQTN98eeqDPBrVZ674qa.toml new file mode 100644 index 00000000000..3316f0ab3c3 --- /dev/null +++ b/changes/22.8_2026-06-12/5253.A9BQTN98eeqDPBrVZ674qa.toml @@ -0,0 +1,5 @@ +internal = "Update dependency astral-sh/uv to v0.11.16" +[[pull_requests]] +uid = "5253" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5254.kaanCYDk82Jg6P8S9pJG3C.toml b/changes/22.8_2026-06-12/5254.kaanCYDk82Jg6P8S9pJG3C.toml new file mode 100644 index 00000000000..f9e26dec81e --- /dev/null +++ b/changes/22.8_2026-06-12/5254.kaanCYDk82Jg6P8S9pJG3C.toml @@ -0,0 +1,5 @@ +internal = "Update dependency sphinxcontrib-mermaid to v2.0.2" +[[pull_requests]] +uid = "5254" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5255.Sx6a4Mf4dD3bUfNWSty78T.toml b/changes/22.8_2026-06-12/5255.Sx6a4Mf4dD3bUfNWSty78T.toml new file mode 100644 index 00000000000..1f0f113f0ff --- /dev/null +++ b/changes/22.8_2026-06-12/5255.Sx6a4Mf4dD3bUfNWSty78T.toml @@ -0,0 +1,5 @@ +internal = "Update actions/stale action to v10.3.0" +[[pull_requests]] +uid = "5255" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5256.VFGHhZz3SchcMmJJLETLzs.toml b/changes/22.8_2026-06-12/5256.VFGHhZz3SchcMmJJLETLzs.toml new file mode 100644 index 00000000000..60f0cb3dabd --- /dev/null +++ b/changes/22.8_2026-06-12/5256.VFGHhZz3SchcMmJJLETLzs.toml @@ -0,0 +1,5 @@ +internal = "Update github/codeql-action action to v4.36.0" +[[pull_requests]] +uid = "5256" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5257.c4g6KoZswRyGb9ypHwfKzB.toml b/changes/22.8_2026-06-12/5257.c4g6KoZswRyGb9ypHwfKzB.toml new file mode 100644 index 00000000000..a4e37e3e5b4 --- /dev/null +++ b/changes/22.8_2026-06-12/5257.c4g6KoZswRyGb9ypHwfKzB.toml @@ -0,0 +1,5 @@ +internal = "Update pypa/gh-action-pypi-publish action to v1.14.0" +[[pull_requests]] +uid = "5257" +author_uids = ["renovate[bot]"] +closes_threads = [] diff --git a/changes/22.8_2026-06-12/5259.iZALQbcHZUBRwuvxwMLpf4.toml b/changes/22.8_2026-06-12/5259.iZALQbcHZUBRwuvxwMLpf4.toml new file mode 100644 index 00000000000..370e72fa9e5 --- /dev/null +++ b/changes/22.8_2026-06-12/5259.iZALQbcHZUBRwuvxwMLpf4.toml @@ -0,0 +1,9 @@ +other = """Support Python 3.15 Beta + +* Python 3.15 free threading is not fully supported yet, as the optional dependency ``cryptography`` is not yet compatible with it. + +""" +[[pull_requests]] +uid = "5259" +author_uids = ["harshil21"] +closes_threads = ["5231"] diff --git a/changes/22.8_2026-06-12/5262.nZcWnWfJyHhUwG484KJx9q.toml b/changes/22.8_2026-06-12/5262.nZcWnWfJyHhUwG484KJx9q.toml new file mode 100644 index 00000000000..b24cd91e5b7 --- /dev/null +++ b/changes/22.8_2026-06-12/5262.nZcWnWfJyHhUwG484KJx9q.toml @@ -0,0 +1,5 @@ +other = "Bump Version to 22.8" +[[pull_requests]] +uid = "5262" +author_uids = ["Poolitzer"] +closes_threads = [] diff --git a/docs/auxil/kwargs_insertion.py b/docs/auxil/bot_insertion.py similarity index 90% rename from docs/auxil/kwargs_insertion.py rename to docs/auxil/bot_insertion.py index b2f05f8741b..008f5413856 100644 --- a/docs/auxil/kwargs_insertion.py +++ b/docs/auxil/bot_insertion.py @@ -83,6 +83,13 @@ " ``2``.", ] +RAISES_BLOCK = [ + "Raises:", + "", + " :class:`telegram.error.TelegramError`", + "", +] + def find_insert_pos_for_kwargs(lines: list[str]) -> int: """Finds the correct position to insert the keyword arguments and returns the index.""" @@ -92,6 +99,13 @@ def find_insert_pos_for_kwargs(lines: list[str]) -> int: return False +def find_insert_pos_for_raises(lines: list[str]) -> int: + """Finds the correct position to insert the Raises block and returns the index.""" + if "Raises:" in lines: + return -1 # Don't insert if there's already a Raises block + return len(lines) # Insert at the end if there's no Raises block + + def check_timeout_and_api_kwargs_presence(obj: object) -> int: """Checks if the method has timeout and api_kwargs keyword only parameters.""" sig = inspect.signature(obj) diff --git a/docs/auxil/sphinx_hooks.py b/docs/auxil/sphinx_hooks.py index 7183a0dbaf7..a8a3e5271e5 100644 --- a/docs/auxil/sphinx_hooks.py +++ b/docs/auxil/sphinx_hooks.py @@ -27,9 +27,11 @@ import telegram import telegram.ext from docs.auxil.admonition_inserter import AdmonitionInserter -from docs.auxil.kwargs_insertion import ( +from docs.auxil.bot_insertion import ( + RAISES_BLOCK, check_timeout_and_api_kwargs_presence, find_insert_pos_for_kwargs, + find_insert_pos_for_raises, get_updates_read_timeout_addition, keyword_args, media_write_timeout_change, @@ -52,6 +54,7 @@ "_BaseMedium": "TelegramObject", "_CredentialsBase": "TelegramObject", "_ChatBase": "TelegramObject", + "_BaseInputMedia": "TelegramObject", } @@ -84,8 +87,8 @@ def autodoc_process_docstring( app: Sphinx, what, name: str, obj: object, options, lines: list[str] ): """We do the following things: - 1) Use this method to automatically insert the Keyword Args and "Shortcuts" admonitions - for the Bot methods. + 1) Use this method to automatically insert the Keyword Args, "Shortcuts" admonitions, + and the Raises block, wherever applicable, for the Bot methods. 2) Use this method to automatically insert "Returned in" admonition into classes that are returned from the Bot methods @@ -101,13 +104,15 @@ def autodoc_process_docstring( """ # 1) Insert the Keyword Args and "Shortcuts" admonitions for the Bot methods - method_name = name.rsplit(".", maxsplit=1)[0] + method_name = name.rsplit(".", maxsplit=1)[-1] if ( name.startswith("telegram.Bot.") and what == "method" and method_name.islower() and check_timeout_and_api_kwargs_presence(obj) ): + # Logic for inserting keyword args into docstrings: + # ------------------------------------------------- insert_index = find_insert_pos_for_kwargs(lines) if not insert_index: raise ValueError( @@ -133,6 +138,16 @@ def autodoc_process_docstring( lines[insert_idx:insert_idx] = effective_insert insert_idx += len(effective_insert) + # Logic for inserting Raises: + # ------------------------------------------------- + # We will only insert the Raises block if there isn't already one. + + insert_index = find_insert_pos_for_raises(lines) + if insert_index != -1: + lines[insert_index:insert_index] = RAISES_BLOCK + + # Logic for inserting "Shortcuts" admonition: + # ------------------------------------------- ADMONITION_INSERTER.insert_admonitions( obj=typing.cast("collections.abc.Callable", obj), docstring_lines=lines, diff --git a/docs/source/inclusions/bot_methods.rst b/docs/source/inclusions/bot_methods.rst index c7b178dd4e9..0a0a8e0f9a6 100644 --- a/docs/source/inclusions/bot_methods.rst +++ b/docs/source/inclusions/bot_methods.rst @@ -41,6 +41,8 @@ - Used for sending paid media to channels * - :meth:`~telegram.Bot.send_photo` - Used for sending photos + * - :meth:`~telegram.Bot.send_live_photo` + - Used for sending live photos * - :meth:`~telegram.Bot.send_poll` - Used for sending polls * - :meth:`~telegram.Bot.send_sticker` @@ -80,6 +82,8 @@ - Used for answering the callback query * - :meth:`~telegram.Bot.answer_inline_query` - Used for answering the inline query + * - :meth:`~telegram.Bot.answer_guest_query` + - Used for replying to a received guest message * - :meth:`~telegram.Bot.answer_pre_checkout_query` - Used for answering a pre checkout query * - :meth:`~telegram.Bot.answer_shipping_query` @@ -104,6 +108,10 @@ - Used for stopping the running poll * - :meth:`~telegram.Bot.set_message_reaction` - Used for setting reactions on messages + * - :meth:`~telegram.Bot.delete_message_reaction` + - Used for deleting reactions on messages + * - :meth:`~telegram.Bot.delete_all_message_reactions` + - Used for deleting all reactions by a chat or user .. raw:: html @@ -167,6 +175,8 @@ - Used for unpinning a message * - :meth:`~telegram.Bot.unpin_all_chat_messages` - Used for unpinning all pinned chat messages + * - :meth:`~telegram.Bot.get_user_personal_chat_messages` + - Used for obtaining the personal chat messages of a user * - :meth:`~telegram.Bot.get_user_profile_audios` - Used for obtaining user's profile audios * - :meth:`~telegram.Bot.get_user_profile_photos` @@ -237,6 +247,10 @@ - Used for obtaining the menu button of a private chat or the default menu button * - :meth:`~telegram.Bot.set_chat_menu_button` - Used for setting the menu button of a private chat or the default menu button + * - :meth:`~telegram.Bot.set_managed_bot_access_settings` + - Used for changing the access settings of a managed bot + * - :meth:`~telegram.Bot.get_managed_bot_access_settings` + - Used for obtaining the access settings of a managed bot * - :meth:`~telegram.Bot.set_my_description` - Used for setting the description of the bot * - :meth:`~telegram.Bot.get_my_description` @@ -496,12 +510,19 @@ - Used for getting information about gifts available for sending * - :meth:`~telegram.Bot.get_chat_gifts` - Used for getting information about gifts owned and hosted by a chat + * - :meth:`~telegram.Bot.get_managed_bot_token` + - Used for getting the token of a managed bot + * - :meth:`~telegram.Bot.replace_managed_bot_token` + - Used for replacing the token of a managed bot * - :meth:`~telegram.Bot.get_me` - Used for getting basic information about the bot * - :meth:`~telegram.Bot.get_user_gifts` - Used for getting information about gifts owned and hosted by a user * - :meth:`~telegram.Bot.save_prepared_inline_message` - Used for storing a message to be sent by a user of a Mini App + * - :meth:`~telegram.Bot.save_prepared_keyboard_button` + - Used for saving a keyboard button to be used in a Mini App + .. raw:: html diff --git a/docs/source/telegram.at-tree.rst b/docs/source/telegram.at-tree.rst index fb960954fd3..4e638e33864 100644 --- a/docs/source/telegram.at-tree.rst +++ b/docs/source/telegram.at-tree.rst @@ -8,6 +8,7 @@ Available Types telegram.animation telegram.audio telegram.birthdate + telegram.botaccesssettings telegram.botcommand telegram.botcommandscope telegram.botcommandscopeallchatadministrators @@ -101,26 +102,37 @@ Available Types telegram.inputmediaanimation telegram.inputmediaaudio telegram.inputmediadocument + telegram.inputmedialivephoto + telegram.inputmedialocation telegram.inputmediaphoto + telegram.inputmediasticker + telegram.inputmediavenue telegram.inputmediavideo telegram.inputpaidmedia + telegram.inputpaidmedialivephoto telegram.inputpaidmediaphoto telegram.inputpaidmediavideo + telegram.inputpollmedia telegram.inputprofilephoto telegram.inputprofilephotoanimated telegram.inputprofilephotostatic telegram.inputpolloption + telegram.inputpolloptionmedia telegram.inputstorycontent telegram.inputstorycontentphoto telegram.inputstorycontentvideo telegram.keyboardbutton telegram.keyboardbuttonpolltype telegram.keyboardbuttonrequestchat + telegram.keyboardbuttonrequestmanagedbot telegram.keyboardbuttonrequestusers telegram.linkpreviewoptions + telegram.livephoto telegram.location telegram.locationaddress telegram.loginurl + telegram.managedbotcreated + telegram.managedbotupdated telegram.maybeinaccessiblemessage telegram.menubutton telegram.menubuttoncommands @@ -143,6 +155,7 @@ Available Types telegram.ownedgiftunique telegram.paidmedia telegram.paidmediainfo + telegram.paidmedialivephoto telegram.paidmediaphoto telegram.paidmediapreview telegram.paidmediapurchased @@ -151,7 +164,11 @@ Available Types telegram.photosize telegram.poll telegram.pollanswer + telegram.pollmedia telegram.polloption + telegram.polloptionadded + telegram.polloptiondeleted + telegram.preparedkeyboardbutton telegram.proximityalerttriggered telegram.reactioncount telegram.reactiontype @@ -161,6 +178,7 @@ Available Types telegram.replykeyboardmarkup telegram.replykeyboardremove telegram.replyparameters + telegram.sentguestmessage telegram.sentwebappmessage telegram.shareduser telegram.story diff --git a/docs/source/telegram.botaccesssettings.rst b/docs/source/telegram.botaccesssettings.rst new file mode 100644 index 00000000000..788689b9266 --- /dev/null +++ b/docs/source/telegram.botaccesssettings.rst @@ -0,0 +1,6 @@ +BotAccessSettings +================= + +.. autoclass:: telegram.BotAccessSettings + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.ext.handlers-tree.rst b/docs/source/telegram.ext.handlers-tree.rst index 72e0d824c53..690aca1c537 100644 --- a/docs/source/telegram.ext.handlers-tree.rst +++ b/docs/source/telegram.ext.handlers-tree.rst @@ -16,6 +16,7 @@ Handlers telegram.ext.conversationhandler telegram.ext.filters telegram.ext.inlinequeryhandler + telegram.ext.managedbotupdatedhandler telegram.ext.messagehandler telegram.ext.messagereactionhandler telegram.ext.paidmediapurchasedhandler diff --git a/docs/source/telegram.ext.managedbotupdatedhandler.rst b/docs/source/telegram.ext.managedbotupdatedhandler.rst new file mode 100644 index 00000000000..c59d8080389 --- /dev/null +++ b/docs/source/telegram.ext.managedbotupdatedhandler.rst @@ -0,0 +1,6 @@ +ManagedBotUpdatedHandler +======================== + +.. autoclass:: telegram.ext.ManagedBotUpdatedHandler + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputmedialivephoto.rst b/docs/source/telegram.inputmedialivephoto.rst new file mode 100644 index 00000000000..975614554a1 --- /dev/null +++ b/docs/source/telegram.inputmedialivephoto.rst @@ -0,0 +1,6 @@ +InputMediaLivePhoto +=================== + +.. autoclass:: telegram.InputMediaLivePhoto + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputmedialocation.rst b/docs/source/telegram.inputmedialocation.rst new file mode 100644 index 00000000000..aa20d631ea4 --- /dev/null +++ b/docs/source/telegram.inputmedialocation.rst @@ -0,0 +1,6 @@ +InputMediaLocation +================== + +.. autoclass:: telegram.InputMediaLocation + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputmediasticker.rst b/docs/source/telegram.inputmediasticker.rst new file mode 100644 index 00000000000..7f2b6d7778e --- /dev/null +++ b/docs/source/telegram.inputmediasticker.rst @@ -0,0 +1,6 @@ +InputMediaSticker +================= + +.. autoclass:: telegram.InputMediaSticker + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputmediavenue.rst b/docs/source/telegram.inputmediavenue.rst new file mode 100644 index 00000000000..e5e221e2f73 --- /dev/null +++ b/docs/source/telegram.inputmediavenue.rst @@ -0,0 +1,6 @@ +InputMediaVenue +=============== + +.. autoclass:: telegram.InputMediaVenue + :members: + :show-inheritance: diff --git a/docs/source/telegram.inputpaidmedialivephoto.rst b/docs/source/telegram.inputpaidmedialivephoto.rst new file mode 100644 index 00000000000..62bf1a2dac6 --- /dev/null +++ b/docs/source/telegram.inputpaidmedialivephoto.rst @@ -0,0 +1,6 @@ +InputPaidMediaLivePhoto +====================== + +.. autoclass:: telegram.InputPaidMediaLivePhoto + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.inputpollmedia.rst b/docs/source/telegram.inputpollmedia.rst new file mode 100644 index 00000000000..6178a00d4a4 --- /dev/null +++ b/docs/source/telegram.inputpollmedia.rst @@ -0,0 +1,6 @@ +InputPollMedia +============== + +.. versionadded:: NEXT.VERSION + +.. autoclass:: telegram.InputPollMedia diff --git a/docs/source/telegram.inputpolloptionmedia.rst b/docs/source/telegram.inputpolloptionmedia.rst new file mode 100644 index 00000000000..2af6693ad19 --- /dev/null +++ b/docs/source/telegram.inputpolloptionmedia.rst @@ -0,0 +1,6 @@ +InputPollOptionMedia +==================== + +.. versionadded:: NEXT.VERSION + +.. autoclass:: telegram.InputPollOptionMedia diff --git a/docs/source/telegram.keyboardbuttonrequestmanagedbot.rst b/docs/source/telegram.keyboardbuttonrequestmanagedbot.rst new file mode 100644 index 00000000000..2840249e15d --- /dev/null +++ b/docs/source/telegram.keyboardbuttonrequestmanagedbot.rst @@ -0,0 +1,6 @@ +KeyboardButtonRequestManagedBot +=============================== + +.. autoclass:: telegram.KeyboardButtonRequestManagedBot + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.livephoto.rst b/docs/source/telegram.livephoto.rst new file mode 100644 index 00000000000..969e4b4db4c --- /dev/null +++ b/docs/source/telegram.livephoto.rst @@ -0,0 +1,6 @@ +LivePhoto +========= + +.. autoclass:: telegram.LivePhoto + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.managedbotcreated.rst b/docs/source/telegram.managedbotcreated.rst new file mode 100644 index 00000000000..e137de86830 --- /dev/null +++ b/docs/source/telegram.managedbotcreated.rst @@ -0,0 +1,6 @@ +ManagedBotCreated +================= + +.. autoclass:: telegram.ManagedBotCreated + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.managedbotupdated.rst b/docs/source/telegram.managedbotupdated.rst new file mode 100644 index 00000000000..f47bd6b60cc --- /dev/null +++ b/docs/source/telegram.managedbotupdated.rst @@ -0,0 +1,6 @@ +ManagedBotUpdated +================= + +.. autoclass:: telegram.ManagedBotUpdated + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.paidmedialivephoto.rst b/docs/source/telegram.paidmedialivephoto.rst new file mode 100644 index 00000000000..12fe8058ce9 --- /dev/null +++ b/docs/source/telegram.paidmedialivephoto.rst @@ -0,0 +1,6 @@ +PaidMediaLivePhoto +================== + +.. autoclass:: telegram.PaidMediaLivePhoto + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.pollmedia.rst b/docs/source/telegram.pollmedia.rst new file mode 100644 index 00000000000..8e7b38871b4 --- /dev/null +++ b/docs/source/telegram.pollmedia.rst @@ -0,0 +1,6 @@ +PollMedia +========= + +.. autoclass:: telegram.PollMedia + :members: + :show-inheritance: diff --git a/docs/source/telegram.polloptionadded.rst b/docs/source/telegram.polloptionadded.rst new file mode 100644 index 00000000000..fa4638c38f0 --- /dev/null +++ b/docs/source/telegram.polloptionadded.rst @@ -0,0 +1,6 @@ +PollOptionAdded +=============== + +.. autoclass:: telegram.PollOptionAdded + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.polloptiondeleted.rst b/docs/source/telegram.polloptiondeleted.rst new file mode 100644 index 00000000000..74f317f5604 --- /dev/null +++ b/docs/source/telegram.polloptiondeleted.rst @@ -0,0 +1,6 @@ +PollOptionDeleted +================= + +.. autoclass:: telegram.PollOptionDeleted + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.preparedkeyboardbutton.rst b/docs/source/telegram.preparedkeyboardbutton.rst new file mode 100644 index 00000000000..7a1f40481b5 --- /dev/null +++ b/docs/source/telegram.preparedkeyboardbutton.rst @@ -0,0 +1,6 @@ +PreparedKeyboardButton +====================== + +.. autoclass:: telegram.PreparedKeyboardButton + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/source/telegram.sentguestmessage.rst b/docs/source/telegram.sentguestmessage.rst new file mode 100644 index 00000000000..b63e0735029 --- /dev/null +++ b/docs/source/telegram.sentguestmessage.rst @@ -0,0 +1,6 @@ +SentGuestMessage +================ + +.. autoclass:: telegram.SentGuestMessage + :members: + :show-inheritance: diff --git a/pyproject.toml b/pyproject.toml index 2c9909e04ec..9a47b954eb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", ] dependencies = [ "httpx >=0.27,<0.29", @@ -98,7 +99,7 @@ tests = [ # required for building the wheels for releases "build", # For the test suite - "pytest==9.0.2", + "pytest==9.0.3", # needed because pytest doesn't come with native support for coroutines as tests "pytest-asyncio==0.21.2", # xdist runs tests in parallel @@ -120,7 +121,7 @@ docs = [ "sphinx==9.1.0; python_version >= '3.12'", "furo==2025.12.19", "sphinx-paramlinks==0.6.0", - "sphinxcontrib-mermaid==2.0.1", + "sphinxcontrib-mermaid==2.0.2", "sphinx-copybutton==0.5.2", "sphinx-inline-tabs==2025.12.21.14", # Temporary. See #4387 @@ -133,8 +134,8 @@ docs = [ ] linting = [ "prek", - "ruff==0.15.6", - "mypy==1.19.1", + "ruff==0.15.15", + "mypy==1.20.2", "pylint==4.0.5" ] all = [{ include-group = "tests" }, { include-group = "docs" }, { include-group = "linting"}] @@ -153,6 +154,10 @@ exclude = [".venv*", "venv*", ".github", "uv.lock"] [tool.hatch.build.targets.wheel] packages = ["src/telegram"] +# uv +[tool.uv] +exclude-newer = "7 days" + # CHANGO [tool.chango] sys_path = "changes" diff --git a/src/telegram/__init__.py b/src/telegram/__init__.py index fad336e0549..0050a640227 100644 --- a/src/telegram/__init__.py +++ b/src/telegram/__init__.py @@ -35,6 +35,7 @@ "BackgroundTypeWallpaper", "Birthdate", "Bot", + "BotAccessSettings", "BotCommand", "BotCommandScope", "BotCommandScopeAllChatAdministrators", @@ -157,13 +158,20 @@ "InputMediaAnimation", "InputMediaAudio", "InputMediaDocument", + "InputMediaLivePhoto", + "InputMediaLocation", "InputMediaPhoto", + "InputMediaSticker", + "InputMediaVenue", "InputMediaVideo", "InputMessageContent", "InputPaidMedia", + "InputPaidMediaLivePhoto", "InputPaidMediaPhoto", "InputPaidMediaVideo", + "InputPollMedia", "InputPollOption", + "InputPollOptionMedia", "InputProfilePhoto", "InputProfilePhotoAnimated", "InputProfilePhotoStatic", @@ -177,12 +185,16 @@ "KeyboardButton", "KeyboardButtonPollType", "KeyboardButtonRequestChat", + "KeyboardButtonRequestManagedBot", "KeyboardButtonRequestUsers", "LabeledPrice", "LinkPreviewOptions", + "LivePhoto", "Location", "LocationAddress", "LoginUrl", + "ManagedBotCreated", + "ManagedBotUpdated", "MaskPosition", "MaybeInaccessibleMessage", "MenuButton", @@ -207,6 +219,7 @@ "OwnedGifts", "PaidMedia", "PaidMediaInfo", + "PaidMediaLivePhoto", "PaidMediaPhoto", "PaidMediaPreview", "PaidMediaPurchased", @@ -228,9 +241,13 @@ "PhotoSize", "Poll", "PollAnswer", + "PollMedia", "PollOption", + "PollOptionAdded", + "PollOptionDeleted", "PreCheckoutQuery", "PreparedInlineMessage", + "PreparedKeyboardButton", "ProximityAlertTriggered", "ReactionCount", "ReactionType", @@ -248,6 +265,7 @@ "RevenueWithdrawalStateSucceeded", "SecureData", "SecureValue", + "SentGuestMessage", "SentWebAppMessage", "SharedUser", "ShippingAddress", @@ -325,23 +343,12 @@ "warnings", ) -from telegram._inputchecklist import InputChecklist, InputChecklistTask -from telegram._payment.stars.staramount import StarAmount -from telegram._payment.stars.startransactions import StarTransaction, StarTransactions -from telegram._payment.stars.transactionpartner import ( - TransactionPartner, - TransactionPartnerAffiliateProgram, - TransactionPartnerChat, - TransactionPartnerFragment, - TransactionPartnerOther, - TransactionPartnerTelegramAds, - TransactionPartnerTelegramApi, - TransactionPartnerUser, -) +__lazy_modules__: list[str] = ["constants", "error", "helpers", "request", "warnings"] from . import _version, constants, error, helpers, request, warnings from ._birthdate import Birthdate from ._bot import Bot +from ._botaccesssettings import BotAccessSettings from ._botcommand import BotCommand from ._botcommandscope import ( BotCommandScope, @@ -412,11 +419,6 @@ from ._dice import Dice from ._directmessagepricechanged import DirectMessagePriceChanged from ._directmessagestopic import DirectMessagesTopic -from ._files._inputstorycontent import ( - InputStoryContent, - InputStoryContentPhoto, - InputStoryContentVideo, -) from ._files.animation import Animation from ._files.audio import Audio from ._files.chatphoto import ChatPhoto @@ -429,11 +431,18 @@ InputMediaAnimation, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, + InputMediaLocation, InputMediaPhoto, + InputMediaSticker, + InputMediaVenue, InputMediaVideo, InputPaidMedia, + InputPaidMediaLivePhoto, InputPaidMediaPhoto, InputPaidMediaVideo, + InputPollMedia, + InputPollOptionMedia, ) from ._files.inputprofilephoto import ( InputProfilePhoto, @@ -441,6 +450,12 @@ InputProfilePhotoStatic, ) from ._files.inputsticker import InputSticker +from ._files.inputstorycontent import ( + InputStoryContent, + InputStoryContentPhoto, + InputStoryContentVideo, +) +from ._files.livephoto import LivePhoto from ._files.location import Location from ._files.photosize import PhotoSize from ._files.sticker import MaskPosition, Sticker, StickerSet @@ -496,11 +511,17 @@ from ._inline.inputtextmessagecontent import InputTextMessageContent from ._inline.inputvenuemessagecontent import InputVenueMessageContent from ._inline.preparedinlinemessage import PreparedInlineMessage +from ._inputchecklist import InputChecklist, InputChecklistTask from ._keyboardbutton import KeyboardButton from ._keyboardbuttonpolltype import KeyboardButtonPollType -from ._keyboardbuttonrequest import KeyboardButtonRequestChat, KeyboardButtonRequestUsers +from ._keyboardbuttonrequest import ( + KeyboardButtonRequestChat, + KeyboardButtonRequestManagedBot, + KeyboardButtonRequestUsers, +) from ._linkpreviewoptions import LinkPreviewOptions from ._loginurl import LoginUrl +from ._managedbot import ManagedBotCreated, ManagedBotUpdated from ._menubutton import MenuButton, MenuButtonCommands, MenuButtonDefault, MenuButtonWebApp from ._message import InaccessibleMessage, MaybeInaccessibleMessage, Message from ._messageautodeletetimerchanged import MessageAutoDeleteTimerChanged @@ -518,6 +539,7 @@ from ._paidmedia import ( PaidMedia, PaidMediaInfo, + PaidMediaLivePhoto, PaidMediaPhoto, PaidMediaPreview, PaidMediaPurchased, @@ -563,8 +585,29 @@ RevenueWithdrawalStatePending, RevenueWithdrawalStateSucceeded, ) +from ._payment.stars.staramount import StarAmount +from ._payment.stars.startransactions import StarTransaction, StarTransactions +from ._payment.stars.transactionpartner import ( + TransactionPartner, + TransactionPartnerAffiliateProgram, + TransactionPartnerChat, + TransactionPartnerFragment, + TransactionPartnerOther, + TransactionPartnerTelegramAds, + TransactionPartnerTelegramApi, + TransactionPartnerUser, +) from ._payment.successfulpayment import SuccessfulPayment -from ._poll import InputPollOption, Poll, PollAnswer, PollOption +from ._poll import ( + InputPollOption, + Poll, + PollAnswer, + PollMedia, + PollOption, + PollOptionAdded, + PollOptionDeleted, +) +from ._preparedkeyboardbutton import PreparedKeyboardButton from ._proximityalerttriggered import ProximityAlertTriggered from ._reaction import ( ReactionCount, @@ -576,6 +619,7 @@ from ._reply import ExternalReplyInfo, ReplyParameters, TextQuote from ._replykeyboardmarkup import ReplyKeyboardMarkup from ._replykeyboardremove import ReplyKeyboardRemove +from ._sentguestmessage import SentGuestMessage from ._sentwebappmessage import SentWebAppMessage from ._shared import ChatShared, SharedUser, UsersShared from ._story import Story diff --git a/src/telegram/_bot.py b/src/telegram/_bot.py index 1050f05baf7..3cb94e3ad07 100644 --- a/src/telegram/_bot.py +++ b/src/telegram/_bot.py @@ -47,7 +47,8 @@ serialization = None # type: ignore[assignment] CRYPTO_INSTALLED = False -from telegram._botcommand import BotCommand # pylint: disable=ungrouped-imports +from telegram._botaccesssettings import BotAccessSettings # pylint: disable=ungrouped-imports +from telegram._botcommand import BotCommand from telegram._botcommandscope import BotCommandScope from telegram._botdescription import BotDescription, BotShortDescription from telegram._botname import BotName @@ -65,6 +66,7 @@ from telegram._files.document import Document from telegram._files.file import File from telegram._files.inputmedia import InputMedia, InputPaidMedia +from telegram._files.livephoto import LivePhoto from telegram._files.location import Location from telegram._files.photosize import PhotoSize from telegram._files.sticker import MaskPosition, Sticker, StickerSet @@ -78,6 +80,7 @@ from telegram._inline.inlinequeryresultsbutton import InlineQueryResultsButton from telegram._inline.preparedinlinemessage import PreparedInlineMessage from telegram._inputchecklist import InputChecklist +from telegram._keyboardbutton import KeyboardButton from telegram._menubutton import MenuButton from telegram._message import Message from telegram._messageid import MessageId @@ -85,8 +88,10 @@ from telegram._payment.stars.staramount import StarAmount from telegram._payment.stars.startransactions import StarTransactions from telegram._poll import InputPollOption, Poll +from telegram._preparedkeyboardbutton import PreparedKeyboardButton from telegram._reaction import ReactionType, ReactionTypeCustomEmoji, ReactionTypeEmoji from telegram._reply import ReplyParameters +from telegram._sentguestmessage import SentGuestMessage from telegram._sentwebappmessage import SentWebAppMessage from telegram._story import Story from telegram._telegramobject import TelegramObject @@ -102,6 +107,7 @@ from telegram._utils.types import ( BaseUrl, CorrectOptionID, + CorrectOptionIds, FileInput, JSONDict, ODVInput, @@ -114,7 +120,7 @@ from telegram.request import BaseRequest, RequestData from telegram.request._httpxrequest import HTTPXRequest from telegram.request._requestparameter import RequestParameter -from telegram.warnings import PTBUserWarning +from telegram.warnings import PTBDeprecationWarning, PTBUserWarning if TYPE_CHECKING: from telegram import ( @@ -123,8 +129,10 @@ InputFile, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, + InputPollMedia, InputProfilePhoto, InputSticker, InputStoryContent, @@ -918,13 +926,24 @@ async def do_api_request( instances of this class. If not specified, the raw result of the API call will be returned. + Keyword Args: + read_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to + :paramref:`telegram.request.BaseRequest.post.read_timeout`. Defaults to + :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. + write_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to + :paramref:`telegram.request.BaseRequest.post.write_timeout`. Defaults to + :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. + connect_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to + :paramref:`telegram.request.BaseRequest.post.connect_timeout`. Defaults to + :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. + pool_timeout (:obj:`float` | :obj:`None`, optional): Value to pass to + :paramref:`telegram.request.BaseRequest.post.pool_timeout`. Defaults to + :attr:`~telegram.request.BaseRequest.DEFAULT_NONE`. + Returns: The result of the API call. If :paramref:`return_type` is not specified, this is a :obj:`dict` or :obj:`bool`, otherwise an instance of :paramref:`return_type` or a tuple of :paramref:`return_type`. - - Raises: - :class:`telegram.error.TelegramError` """ if hasattr(self, endpoint): self._warn( @@ -982,10 +1001,6 @@ async def get_me( Returns: :class:`telegram.User`: A :class:`telegram.User` instance representing that bot if the credentials are valid, :obj:`None` otherwise. - - Raises: - :class:`telegram.error.TelegramError` - """ result = await self._post( "getMe", @@ -1186,10 +1201,6 @@ async def delete_message( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id, "message_id": message_id} return await self._post( @@ -1206,7 +1217,7 @@ async def send_message_draft( self, chat_id: int, draft_id: int, - text: str, + text: str | None = None, message_thread_id: int | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, entities: Sequence["MessageEntity"] | None = None, @@ -1218,7 +1229,9 @@ async def send_message_draft( api_kwargs: JSONDict | None = None, ) -> bool: """Use this method to stream a partial message to a user while the message is being - generated. + generated. Note that the streamed draft is ephemeral and acts as a temporary 30-second + preview - once the output is finalized, you must call :meth:`~Bot.send_message` with + the complete message to persist it in the user's chat. .. versionadded:: 22.6 @@ -1230,26 +1243,24 @@ async def send_message_draft( chat_id (:obj:`int`): Unique identifier for the target private chat. draft_id (:obj:`int`): Unique identifier of the message draft; must be non-zero. Changes of drafts with the same identifier are animated. - text (:obj:`str`): Text of the message to be sent, - :tg-const:`telegram.constants.MessageLimit.MIN_TEXT_LENGTH`- - :tg-const:`telegram.constants.MessageLimit.MAX_TEXT_LENGTH` characters after - entities parsing. + text (:obj:`str`, optional): Text of the message to be sent, + 0-:tg-const:`telegram.constants.MessageLimit.MAX_TEXT_LENGTH` characters after + entities parsing. Pass an empty text to show a "Thinking..." placeholder. + + .. versionchanged:: 22.8 + Bot API 10.0 now makes this an optional parameter. + + message_thread_id (:obj:`int`, optional): Unique identifier for the target + message thread. parse_mode (:obj:`str`): |parse_mode| entities (Sequence[:class:`telegram.MessageEntity`], optional): Sequence of special entities that appear in message text, which can be specified instead of :paramref:`parse_mode`. |sequenceargs| - message_thread_id (:obj:`int`, optional): Unique identifier for the target - message thread. - Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -1296,9 +1307,6 @@ async def delete_messages( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = {"chat_id": chat_id, "message_ids": message_ids} return await self._post( @@ -1375,9 +1383,6 @@ async def forward_message( Returns: :class:`telegram.Message`: On success, the sent Message is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "chat_id": chat_id, @@ -1446,9 +1451,6 @@ async def forward_messages( Returns: tuple[:class:`telegram.Message`]: On success, a tuple of ``MessageId`` of sent messages is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "chat_id": chat_id, @@ -1598,9 +1600,6 @@ async def send_photo( Returns: :class:`telegram.Message`: On success, the sent Message is returned. - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -1773,9 +1772,6 @@ async def send_audio( Returns: :class:`telegram.Message`: On success, the sent Message is returned. - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -1941,9 +1937,6 @@ async def send_document( Returns: :class:`telegram.Message`: On success, the sent Message is returned. - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -2079,10 +2072,6 @@ async def send_sticker( Returns: :class:`telegram.Message`: On success, the sent Message is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -2272,10 +2261,6 @@ async def send_video( Returns: :class:`telegram.Message`: On success, the sent Message is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -2444,10 +2429,6 @@ async def send_video_note( Returns: :class:`telegram.Message`: On success, the sent Message is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -2624,10 +2605,6 @@ async def send_animation( Returns: :class:`telegram.Message`: On success, the sent Message is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -2796,10 +2773,6 @@ async def send_voice( Returns: :class:`telegram.Message`: On success, the sent Message is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -2836,7 +2809,7 @@ async def send_media_group( self, chat_id: int | str, media: Sequence[ - "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | InputMediaLivePhoto" # noqa: E501 # pylint: disable=line-too-long ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, @@ -2875,8 +2848,8 @@ async def send_media_group( chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| media (Sequence[:class:`telegram.InputMediaAudio`,\ :class:`telegram.InputMediaDocument`, :class:`telegram.InputMediaPhoto`,\ - :class:`telegram.InputMediaVideo`]): An array - describing messages to be sent, must include + :class:`telegram.InputMediaVideo`, :class:`telegram.InputMediaLivePhoto`]): An + array describing messages to be sent, must include :tg-const:`telegram.constants.MediaGroupLimit.MIN_MEDIA_LENGTH`- :tg-const:`telegram.constants.MediaGroupLimit.MAX_MEDIA_LENGTH` items. @@ -2949,9 +2922,6 @@ async def send_media_group( Returns: tuple[:class:`telegram.Message`]: An array of the sent Messages. - - Raises: - :class:`telegram.error.TelegramError` """ if caption and any( [ @@ -3134,10 +3104,6 @@ async def send_location( Returns: :class:`telegram.Message`: On success, the sent Message is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ if not ((latitude is not None and longitude is not None) or location): raise ValueError( @@ -3461,10 +3427,6 @@ async def send_venue( Returns: :class:`telegram.Message`: On success, the sent Message is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ # The venue parameter is a convenience functionality added by us, so enforcing the # mutual exclusivity here is nothing that Telegram would handle anyway @@ -3619,10 +3581,6 @@ async def send_contact( Returns: :class:`telegram.Message`: On success, the sent Message is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ # The contact parameter is a convenience functionality added by us, so enforcing the # mutual exclusivity here is nothing that Telegram would handle anyway @@ -3674,7 +3632,7 @@ async def send_contact( async def send_game( self, - chat_id: int, + chat_id: int | str, game_short_name: str, disable_notification: ODVInput[bool] = DEFAULT_NONE, reply_markup: "InlineKeyboardMarkup | None" = None, @@ -3696,7 +3654,9 @@ async def send_game( """Use this method to send a game. Args: - chat_id (:obj:`int`): Unique identifier for the target chat. + chat_id (:obj:`int`): Unique identifier for the target chat or username of the + target bot in the format ``@username``. Games can't be sent to channel direct + messages chats and channel chats. game_short_name (:obj:`str`): Short name of the game, serves as the unique identifier for the game. Set up your games via `@BotFather `_. disable_notification (:obj:`bool`, optional): |disable_notification| @@ -3745,10 +3705,6 @@ async def send_game( Returns: :class:`telegram.Message`: On success, the sent Message is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id, "game_short_name": game_short_name} @@ -3805,10 +3761,6 @@ async def send_chat_action( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -3985,10 +3937,6 @@ async def answer_inline_query( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ effective_results, next_offset = self._effective_inline_results( results=results, next_offset=next_offset, current_offset=current_offset @@ -4052,9 +4000,6 @@ async def save_prepared_inline_message( Returns: :class:`telegram.PreparedInlineMessage`: On success, the prepared message is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "user_id": user_id, @@ -4102,10 +4047,6 @@ async def get_user_profile_photos( Returns: :class:`telegram.UserProfilePhotos` - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"user_id": user_id, "offset": offset, "limit": limit} @@ -4168,10 +4109,6 @@ async def get_file( Returns: :class:`telegram.File` - - Raises: - :class:`telegram.error.TelegramError` - """ # Try to get the file_id from the object, if it fails, assume it's a string with contextlib.suppress(AttributeError): @@ -4234,10 +4171,6 @@ async def ban_chat_member( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -4282,10 +4215,6 @@ async def ban_chat_sender_chat( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id, "sender_chat_id": sender_chat_id} @@ -4326,10 +4255,6 @@ async def unban_chat_member( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id, "user_id": user_id, "only_if_banned": only_if_banned} @@ -4366,10 +4291,6 @@ async def unban_chat_sender_chat( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id, "sender_chat_id": sender_chat_id} @@ -4429,10 +4350,6 @@ async def answer_callback_query( Returns: :obj:`bool` On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "callback_query_id": callback_query_id, @@ -4612,10 +4529,6 @@ async def edit_message_caption( Returns: :class:`telegram.Message`: On success, if edited message is not an inline message, the edited message is returned, otherwise :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -4655,12 +4568,12 @@ async def edit_message_media( api_kwargs: JSONDict | None = None, ) -> "Message | bool": """ - Use this method to edit animation, audio, document, photo, or video messages, or to add - media to text messages. If a message + Use this method to edit animation, audio, document, live photo, photo, or video messages, + or to add media to text messages. If a message is part of a message album, then it can be edited only to an audio for audio albums, only - to a document for document albums and to a photo or a video otherwise. When an inline - message is edited, a new file can't be uploaded; use a previously uploaded file via its - :attr:`~telegram.File.file_id` or specify a URL. + to a document for document albums and to a photo, live photo, or a video otherwise. + When an inline message is edited, a new file can't be uploaded; use a previously + uploaded file via its :attr:`~telegram.File.file_id` or specify a URL. Note: * |editreplymarkup| @@ -4686,9 +4599,6 @@ async def edit_message_media( Returns: :class:`telegram.Message`: On success, if edited message is not an inline message, the edited Message is returned, otherwise :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "media": media, @@ -4747,10 +4657,6 @@ async def edit_message_reply_markup( Returns: :class:`telegram.Message`: On success, if edited message is not an inline message, the edited message is returned, otherwise :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -4831,10 +4737,6 @@ async def get_updates( Returns: tuple[:class:`telegram.Update`] - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "timeout": timeout, @@ -4981,8 +4883,7 @@ async def set_webhook( Returns: :obj:`bool` On success, :obj:`True` is returned. - Raises: - :class:`telegram.error.TelegramError` + .. _`guide to Webhooks`: https://core.telegram.org/bots/webhooks @@ -5027,10 +4928,6 @@ async def delete_webhook( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data = {"drop_pending_updates": drop_pending_updates} @@ -5061,10 +4958,6 @@ async def leave_chat( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id} @@ -5100,10 +4993,6 @@ async def get_chat( Returns: :class:`telegram.ChatFullInfo` - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id} @@ -5122,6 +5011,7 @@ async def get_chat( async def get_chat_administrators( self, chat_id: str | int, + return_bots: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -5137,18 +5027,19 @@ async def get_chat_administrators( Args: chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + return_bots (:obj:`bool`, optional): Pass :obj:`True` to additionally receive all bots + that are administrators of the chat. By default, bots other than the current bot + are omitted. + + .. versionadded:: 22.8 Returns: tuple[:class:`telegram.ChatMember`]: On success, returns a tuple of ``ChatMember`` objects that contains information about all chat administrators except other bots. If the chat is a group or a supergroup and no administrators were appointed, only the creator will be returned. - - Raises: - :class:`telegram.error.TelegramError` - """ - data: JSONDict = {"chat_id": chat_id} + data: JSONDict = {"chat_id": chat_id, "return_bots": return_bots} result = await self._post( "getChatAdministrators", data, @@ -5179,10 +5070,6 @@ async def get_chat_member_count( Returns: :obj:`int`: Number of members in the chat. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id} return await self._post( @@ -5215,10 +5102,6 @@ async def get_chat_member( Returns: :class:`telegram.ChatMember` - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id, "user_id": user_id} result = await self._post( @@ -5427,10 +5310,6 @@ async def get_game_high_scores( Returns: tuple[:class:`telegram.GameHighScore`] - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "user_id": user_id, @@ -5634,10 +5513,6 @@ async def send_invoice( Returns: :class:`telegram.Message`: On success, the sent Message is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -5721,10 +5596,6 @@ async def answer_shipping_query( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "shipping_query_id": shipping_query_id, @@ -5778,10 +5649,6 @@ async def answer_pre_checkout_query( Returns: :obj:`bool`: On success, :obj:`True` is returned - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "pre_checkout_query_id": pre_checkout_query_id, @@ -5823,10 +5690,6 @@ async def answer_web_app_query( Returns: :class:`telegram.SentWebAppMessage`: On success, a sent :class:`telegram.SentWebAppMessage` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "web_app_query_id": web_app_query_id, @@ -5845,6 +5708,47 @@ async def answer_web_app_query( return SentWebAppMessage.de_json(api_result, self) + async def answer_guest_query( + self, + guest_query_id: str, + result: "InlineQueryResult", + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> SentGuestMessage: + """Use this method to reply to a received guest message. + + .. versionadded:: 22.8 + + Args: + guest_query_id (:obj:`str`): Unique identifier for the query to be answered. + result (:class:`telegram.InlineQueryResult`): An object describing the message to be + sent. + + Returns: + :class:`telegram.SentGuestMessage`: On success, a + :class:`telegram.SentGuestMessage` is returned. + """ + data: JSONDict = { + "guest_query_id": guest_query_id, + "result": self._insert_defaults_for_ilq_results(result), + } + + api_result = await self._post( + "answerGuestQuery", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + return SentGuestMessage.de_json(api_result, self) + async def restrict_chat_member( self, chat_id: str | int, @@ -5894,9 +5798,6 @@ async def restrict_chat_member( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "chat_id": chat_id, @@ -6018,10 +5919,6 @@ async def promote_chat_member( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -6093,10 +5990,6 @@ async def set_chat_permissions( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -6138,10 +6031,6 @@ async def set_chat_administrator_custom_title( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id, "user_id": user_id, "custom_title": custom_title} @@ -6182,10 +6071,6 @@ async def export_chat_invite_link( Returns: :obj:`str`: New invite link on success. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id} return await self._post( @@ -6248,10 +6133,6 @@ async def create_chat_invite_link( Returns: :class:`telegram.ChatInviteLink` - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -6325,10 +6206,6 @@ async def edit_chat_invite_link( Returns: :class:`telegram.ChatInviteLink` - - Raises: - :class:`telegram.error.TelegramError` - """ link = invite_link.invite_link if isinstance(invite_link, ChatInviteLink) else invite_link data: JSONDict = { @@ -6379,10 +6256,6 @@ async def revoke_chat_invite_link( Returns: :class:`telegram.ChatInviteLink` - - Raises: - :class:`telegram.error.TelegramError` - """ link = invite_link.invite_link if isinstance(invite_link, ChatInviteLink) else invite_link data: JSONDict = {"chat_id": chat_id, "invite_link": link} @@ -6423,9 +6296,6 @@ async def approve_chat_join_request( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = {"chat_id": chat_id, "user_id": user_id} @@ -6463,9 +6333,6 @@ async def decline_chat_join_request( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = {"chat_id": chat_id, "user_id": user_id} @@ -6509,10 +6376,6 @@ async def set_chat_photo( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id, "photo": self._parse_file_input(photo)} return await self._post( @@ -6545,10 +6408,6 @@ async def delete_chat_photo( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id} return await self._post( @@ -6585,10 +6444,6 @@ async def set_chat_title( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id, "title": title} return await self._post( @@ -6625,10 +6480,6 @@ async def set_chat_description( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id, "description": description} @@ -6672,10 +6523,6 @@ async def set_user_emoji_status( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "user_id": user_id, @@ -6725,10 +6572,6 @@ async def pin_chat_message( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -6778,10 +6621,6 @@ async def unpin_chat_message( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -6821,10 +6660,6 @@ async def unpin_all_chat_messages( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id} return await self._post( @@ -6854,10 +6689,6 @@ async def get_sticker_set( Returns: :class:`telegram.StickerSet` - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"name": name} result = await self._post( @@ -6897,10 +6728,6 @@ async def get_custom_emoji_stickers( Returns: tuple[:class:`telegram.Sticker`] - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"custom_emoji_ids": custom_emoji_ids} result = await self._post( @@ -6953,10 +6780,6 @@ async def upload_sticker_file( Returns: :class:`telegram.File`: On success, the uploaded File is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "user_id": user_id, @@ -7012,10 +6835,6 @@ async def add_sticker_to_set( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "user_id": user_id, @@ -7056,10 +6875,6 @@ async def set_sticker_position_in_set( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "sticker": sticker if isinstance(sticker, str) else sticker.file_id, @@ -7144,9 +6959,6 @@ async def create_new_sticker_set( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "user_id": user_id, @@ -7188,10 +7000,6 @@ async def delete_sticker_from_set( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"sticker": sticker if isinstance(sticker, str) else sticker.file_id} return await self._post( @@ -7224,10 +7032,6 @@ async def delete_sticker_set( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"name": name} return await self._post( @@ -7297,10 +7101,6 @@ async def set_sticker_set_thumbnail( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "name": name, @@ -7343,10 +7143,6 @@ async def set_sticker_set_title( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"name": name, "title": title} return await self._post( @@ -7389,9 +7185,6 @@ async def set_sticker_emoji_list( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "sticker": sticker if isinstance(sticker, str) else sticker.file_id, @@ -7437,9 +7230,6 @@ async def set_sticker_keywords( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "sticker": sticker if isinstance(sticker, str) else sticker.file_id, @@ -7484,9 +7274,6 @@ async def set_sticker_mask_position( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "sticker": sticker if isinstance(sticker, str) else sticker.file_id, @@ -7526,10 +7313,6 @@ async def set_custom_emoji_sticker_set_thumbnail( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"name": name, "custom_emoji_id": custom_emoji_id} @@ -7573,10 +7356,6 @@ async def set_passport_data_errors( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"user_id": user_id, "errors": errors} return await self._post( @@ -7597,6 +7376,8 @@ async def send_poll( is_anonymous: bool | None = None, type: str | None = None, # pylint: disable=redefined-builtin allows_multiple_answers: bool | None = None, + # tags: deprecated in 22.8, to be removed + # replaced by `correct_option_ids` correct_option_id: CorrectOptionID | None = None, is_closed: bool | None = None, disable_notification: ODVInput[bool] = DEFAULT_NONE, @@ -7614,6 +7395,18 @@ async def send_poll( question_entities: Sequence["MessageEntity"] | None = None, message_effect_id: str | None = None, allow_paid_broadcast: bool | None = None, + allows_revoting: bool | None = None, + allow_adding_options: bool | None = None, + hide_results_until_closes: bool | None = None, + correct_option_ids: CorrectOptionIds | None = None, + description: str | None = None, + description_parse_mode: ODVInput[str] | None = None, + description_entities: Sequence["MessageEntity"] | None = None, + shuffle_options: bool | None = None, + members_only: bool | None = None, + country_codes: Sequence[str] | None = None, + explanation_media: "InputPollMedia | None" = None, + media: "InputPollMedia | None" = None, *, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, reply_to_message_id: int | None = None, @@ -7649,9 +7442,13 @@ async def send_poll( type (:obj:`str`, optional): Poll type, :tg-const:`telegram.Poll.QUIZ` or :tg-const:`telegram.Poll.REGULAR`, defaults to :tg-const:`telegram.Poll.REGULAR`. allows_multiple_answers (:obj:`bool`, optional): :obj:`True`, if the poll allows - multiple answers, ignored for polls in quiz mode, defaults to :obj:`False`. + multiple answers, defaults to :obj:`False`. correct_option_id (:obj:`int`, optional): 0-based identifier of the correct answer option, required for polls in quiz mode. + + .. deprecated:: 22.8 + Bot API 9.6 replaces this with :paramref:`correct_option_ids` instead. + explanation (:obj:`str`, optional): Text that is shown when a user chooses an incorrect answer or taps on the lamp icon in a quiz-style poll, 0-:tg-const:`telegram.Poll.MAX_EXPLANATION_LENGTH` characters with at most @@ -7716,6 +7513,64 @@ async def send_poll( allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| .. versionadded:: 21.7 + allows_revoting (:obj:`bool`, optional): :obj:`True`, if the poll allows to + change the chosen answer options, defaults to :obj:`False` + for quizzes and to :obj:`True` for regular polls + + .. versionadded:: 22.8 + allow_adding_options (:obj:`bool`, optional): :obj:`True`, if answer options can be + added to the poll after creation; not supported for anonymous polls and quizzes + + .. versionadded:: 22.8 + hide_results_until_closes (:obj:`bool`, optional): :obj:`True`, if poll results + must be shown only after the poll closes + + .. versionadded:: 22.8 + correct_option_ids (Sequence[:class:`int`], optional): A list of monotonically + increasing 0-based identifiers of the correct answer options, + required for polls in quiz mode. + + .. versionadded:: 22.8 + description (:obj:`str`, optional): Description of the poll to be sent, + 0-:tg-const:`telegram.Poll.MAX_DESCRIPTION_CHARACTERS` characters + after entities parsing. + + .. versionadded:: 22.8 + description_parse_mode (:obj:`str`, optional): Mode for parsing entities + in the poll description. See the constants + in :class:`telegram.constants.ParseMode` + + .. versionadded:: 22.8 + description_entities (Sequence[:class:`telegram.MessageEntity`], optional): A + JSON-serialized list of special entities that appear in the poll description, + which can be specified instead of :paramref:`description_parse_mode` + + .. versionadded:: 22.8 + shuffle_options (:obj:`bool`, optional): :obj:`True`, if the poll options must be + shown in random order + + .. versionadded:: 22.8 + members_only (:obj:`bool`, optional): :obj:`True`, if voting is limited to users who + have been members of the chat where the poll is being sent for more than + :tg-const:`telegram.Poll.MIN_MEMBERSHIP_HOURS` hours; for channel chats only + + .. versionadded:: 22.8 + country_codes (Sequence[:obj:`str`], optional): A list of + 0-:tg-const:`telegram.constants.PollLimit.MAX_COUNTRY_CODES` two-letter + ``ISO 3166-1 alpha-2`` country codes indicating the countries from which users can + vote in the poll; for channel chats only. Use ``"FT"`` as a country code to allow + users with anonymous numbers to vote. If omitted or empty, then users from any + country can participate in the poll. + + .. versionadded:: 22.8 + explanation_media (:class:`telegram.InputPollMedia`, optional): Media added to the quiz + explanation + + .. versionadded:: 22.8 + media (:class:`telegram.InputPollMedia`, optional): Media added to the poll + description. + + .. versionadded:: 22.8 Keyword Args: allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| @@ -7739,11 +7594,20 @@ async def send_poll( Returns: :class:`telegram.Message`: On success, the sent Message is returned. + """ - Raises: - :class:`telegram.error.TelegramError` + if correct_option_id is not None: + warn( + PTBDeprecationWarning( + version="22.8", + message="Bot API 9.6 deprecated `correct_option_id` in favour of " + "`correct_option_ids`, please use that.", + ), + stacklevel=2, + ) + if correct_option_ids is None: + correct_option_ids = [correct_option_id] - """ data: JSONDict = { "chat_id": chat_id, "question": question, @@ -7755,14 +7619,25 @@ async def send_poll( "is_anonymous": is_anonymous, "type": type, "allows_multiple_answers": allows_multiple_answers, - "correct_option_id": correct_option_id, + "allow_adding_options": allow_adding_options, + "allows_revoting": allows_revoting, + "shuffle_options": shuffle_options, + "hide_results_until_closes": hide_results_until_closes, + "correct_option_ids": correct_option_ids, "is_closed": is_closed, + "description": description, + "description_parse_mode": description_parse_mode, + "description_entities": description_entities, "explanation": explanation, "explanation_entities": explanation_entities, "open_period": open_period, "close_date": close_date, "question_parse_mode": question_parse_mode, "question_entities": question_entities, + "members_only": members_only, + "country_codes": country_codes, + "explanation_media": explanation_media, + "media": media, } return await self._send_message( @@ -7812,10 +7687,6 @@ async def stop_poll( Returns: :class:`telegram.Poll`: On success, the stopped Poll is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -7838,7 +7709,7 @@ async def stop_poll( async def send_checklist( self, business_connection_id: str, - chat_id: int, + chat_id: int | str, checklist: InputChecklist, disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, @@ -7860,20 +7731,14 @@ async def send_checklist( .. versionadded:: 22.3 Args: - business_connection_id (:obj:`str`): - |business_id_str| - chat_id (:obj:`int`): - Unique identifier for the target chat. - checklist (:class:`telegram.InputChecklist`): - The checklist to send. - disable_notification (:obj:`bool`, optional): - |disable_notification| - protect_content (:obj:`bool`, optional): - |protect_content| - message_effect_id (:obj:`str`, optional): - |message_effect_id| - reply_parameters (:class:`telegram.ReplyParameters`, optional): - |reply_parameters| + business_connection_id (:obj:`str`): |business_id_str| + chat_id (:obj:`int`): Unique identifier for the target chat or username of the target + bot in the format ``@username``. + checklist (:class:`telegram.InputChecklist`): The checklist to send. + disable_notification (:obj:`bool`, optional): |disable_notification| + protect_content (:obj:`bool`, optional): |protect_content| + message_effect_id (:obj:`str`, optional): |message_effect_id| + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for an inline keyboard @@ -7887,10 +7752,6 @@ async def send_checklist( Returns: :class:`telegram.Message`: On success, the sent Message is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -7918,7 +7779,7 @@ async def send_checklist( async def edit_message_checklist( self, business_connection_id: str, - chat_id: int, + chat_id: int | str, message_id: int, checklist: InputChecklist, reply_markup: "InlineKeyboardMarkup | None" = None, @@ -7935,23 +7796,16 @@ async def edit_message_checklist( .. versionadded:: 22.3 Args: - business_connection_id (:obj:`str`): - |business_id_str| - chat_id (:obj:`int`): - Unique identifier for the target chat. - message_id (:obj:`int`): - Unique identifier for the target message. - checklist (:class:`telegram.InputChecklist`): - The new checklist. + business_connection_id (:obj:`str`): |business_id_str| + chat_id (:obj:`int`): Unique identifier for the target chat or username of the target + bot in the format ``@username``. + message_id (:obj:`int`): Unique identifier for the target message. + checklist (:class:`telegram.InputChecklist`): The new checklist. reply_markup (:class:`telegram.InlineKeyboardMarkup`, optional): An object for the new inline keyboard for the message. Returns: :class:`telegram.Message`: On success, the sent Message is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -8068,10 +7922,6 @@ async def send_dice( Returns: :class:`telegram.Message`: On success, the sent Message is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id, "emoji": emoji} @@ -8120,9 +7970,6 @@ async def get_my_default_administrator_rights( Returns: :class:`telegram.ChatAdministratorRights`: On success. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = {"for_channels": for_channels} @@ -8168,9 +8015,6 @@ async def set_my_default_administrator_rights( Returns: :obj:`bool`: Returns :obj:`True` on success. - - Raises: - :exc:`telegram.error.TelegramError` """ data: JSONDict = {"rights": rights, "for_channels": for_channels} @@ -8218,10 +8062,6 @@ async def get_my_commands( Returns: tuple[:class:`telegram.BotCommand`]: On success, the commands set for the bot. An empty tuple is returned if commands are not set. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"scope": scope, "language_code": language_code} @@ -8283,10 +8123,6 @@ async def set_my_commands( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ cmds = [c if isinstance(c, BotCommand) else BotCommand(c[0], c[1]) for c in commands] data: JSONDict = {"commands": cmds, "scope": scope, "language_code": language_code} @@ -8332,9 +8168,6 @@ async def delete_my_commands( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = {"scope": scope, "language_code": language_code} @@ -8366,10 +8199,6 @@ async def log_out( Returns: :obj:`True`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ return await self._post( "logOut", @@ -8397,10 +8226,6 @@ async def close( Returns: :obj:`True`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ return await self._post( "close", @@ -8519,10 +8344,6 @@ async def copy_message( Returns: :class:`telegram.MessageId`: On success, the :class:`telegram.MessageId` of the sent message is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ if allow_sending_without_reply is not DEFAULT_NONE and reply_parameters is not None: raise ValueError( @@ -8623,10 +8444,6 @@ async def copy_messages( Returns: tuple[:class:`telegram.MessageId`]: On success, a tuple of :class:`~telegram.MessageId` of the sent messages is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -8671,7 +8488,7 @@ async def set_chat_menu_button( Args: chat_id (:obj:`int`, optional): Unique identifier for the target private chat. If not - specified, default bot's menu button will be changed + specified, the bot's default menu button will be changed menu_button (:class:`telegram.MenuButton`, optional): An object for the new bot's menu button. Defaults to :class:`telegram.MenuButtonDefault`. @@ -8711,7 +8528,7 @@ async def get_chat_menu_button( Args: chat_id (:obj:`int`, optional): Unique identifier for the target private chat. If not - specified, default bot's menu button will be returned. + specified, the bot's default menu button will be returned. Returns: :class:`telegram.MenuButton`: On success, the current menu button is returned. @@ -8903,10 +8720,6 @@ async def get_forum_topic_icon_stickers( Returns: tuple[:class:`telegram.Sticker`] - - Raises: - :class:`telegram.error.TelegramError` - """ result = await self._post( "getForumTopicIconStickers", @@ -8956,9 +8769,6 @@ async def create_forum_topic( Returns: :class:`telegram.ForumTopic` - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "chat_id": chat_id, @@ -9013,10 +8823,6 @@ async def edit_forum_topic( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -9059,10 +8865,6 @@ async def close_forum_topic( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -9103,10 +8905,6 @@ async def reopen_forum_topic( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -9147,10 +8945,6 @@ async def delete_forum_topic( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -9192,10 +8986,6 @@ async def unpin_all_forum_topic_messages( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -9234,9 +9024,6 @@ async def unpin_all_general_forum_topic_messages( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = {"chat_id": chat_id} @@ -9276,10 +9063,6 @@ async def edit_general_forum_topic( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id, "name": name} @@ -9315,10 +9098,6 @@ async def close_general_forum_topic( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id} @@ -9355,10 +9134,6 @@ async def reopen_general_forum_topic( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id} @@ -9395,10 +9170,6 @@ async def hide_general_forum_topic( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id} @@ -9434,10 +9205,6 @@ async def unhide_general_forum_topic( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"chat_id": chat_id} @@ -9479,10 +9246,6 @@ async def set_my_description( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"description": description, "language_code": language_code} @@ -9524,10 +9287,6 @@ async def set_my_short_description( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"short_description": short_description, "language_code": language_code} @@ -9560,10 +9319,6 @@ async def get_my_description( Returns: :class:`telegram.BotDescription`: On success, the bot description is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data = {"language_code": language_code} return BotDescription.de_json( @@ -9599,10 +9354,6 @@ async def get_my_short_description( Returns: :class:`telegram.BotShortDescription`: On success, the bot short description is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data = {"language_code": language_code} return BotShortDescription.de_json( @@ -9649,10 +9400,6 @@ async def set_my_name( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = {"name": name, "language_code": language_code} @@ -9685,10 +9432,6 @@ async def get_my_name( Returns: :class:`telegram.BotName`: On success, the bot name is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data = {"language_code": language_code} return BotName.de_json( @@ -9728,9 +9471,6 @@ async def get_user_chat_boosts( Returns: :class:`telegram.UserChatBoosts`: On success, the object containing the list of boosts is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = {"chat_id": chat_id, "user_id": user_id} return UserChatBoosts.de_json( @@ -9791,9 +9531,6 @@ async def set_message_reaction( Returns: :obj:`bool` On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ allowed_reactions: set[str] = set(ReactionEmoji) parsed_reaction = ( @@ -9874,20 +9611,19 @@ async def gift_premium_subscription( `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. + :attr:`~MessageEntity.STRIKETHROUGH`, :attr:`~MessageEntity.SPOILER` + :attr:`~MessageEntity.CUSTOM_EMOJI`, and :attr:`~MessageEntity.DATE_TIME` 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. + :attr:`~MessageEntity.STRIKETHROUGH`, :attr:`~MessageEntity.SPOILER`, + :attr:`~MessageEntity.CUSTOM_EMOJI`, and :attr:`~MessageEntity.DATE_TIME` are + ignored. Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "user_id": user_id, @@ -9928,9 +9664,6 @@ async def get_business_connection( Returns: :class:`telegram.BusinessConnection`: On success, the object containing the business connection information is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = {"business_connection_id": business_connection_id} return BusinessConnection.de_json( @@ -10011,9 +9744,6 @@ async def get_business_account_gifts( Returns: :class:`telegram.OwnedGifts` - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -10062,9 +9792,6 @@ async def get_business_account_star_balance( Returns: :class:`telegram.StarAmount` - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = {"business_connection_id": business_connection_id} return StarAmount.de_json( @@ -10109,9 +9836,6 @@ async def read_business_message( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -10159,9 +9883,6 @@ async def delete_business_messages( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -10241,9 +9962,6 @@ async def post_story( Returns: :class:`Story` - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -10321,9 +10039,6 @@ async def edit_story( Returns: :class:`Story` - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -10369,9 +10084,6 @@ async def delete_story( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -10416,9 +10128,6 @@ async def set_business_account_name( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -10459,9 +10168,6 @@ async def set_business_account_username( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -10501,9 +10207,6 @@ async def set_business_account_bio( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -10548,9 +10251,6 @@ async def set_business_account_gift_settings( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -10595,10 +10295,6 @@ async def set_business_account_profile_photo( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -10642,9 +10338,6 @@ async def remove_business_account_profile_photo( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -10685,9 +10378,6 @@ async def convert_gift_to_stars( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -10740,9 +10430,6 @@ async def upgrade_gift( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -10797,9 +10484,6 @@ async def transfer_gift( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -10843,9 +10527,6 @@ async def transfer_business_account_stars( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -10894,9 +10575,6 @@ async def replace_sticker_in_set( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "user_id": user_id, @@ -10936,10 +10614,6 @@ async def refund_star_payment( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "user_id": user_id, @@ -10980,9 +10654,6 @@ async def get_star_transactions( Returns: :class:`telegram.StarTransactions`: On success. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = {"offset": offset, "limit": limit} @@ -11028,9 +10699,6 @@ async def edit_user_star_subscription( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "user_id": user_id, @@ -11047,100 +10715,227 @@ async def edit_user_star_subscription( api_kwargs=api_kwargs, ) - async def send_paid_media( + async def get_managed_bot_access_settings( self, - chat_id: str | int, - star_count: int, - media: Sequence["InputPaidMedia"], - caption: str | None = None, - parse_mode: ODVInput[str] = DEFAULT_NONE, - caption_entities: Sequence["MessageEntity"] | None = None, - show_caption_above_media: bool | None = None, - disable_notification: ODVInput[bool] = DEFAULT_NONE, - protect_content: ODVInput[bool] = DEFAULT_NONE, - reply_parameters: "ReplyParameters | None" = None, - reply_markup: "ReplyMarkup | None" = None, - business_connection_id: str | None = None, - payload: str | None = None, - allow_paid_broadcast: bool | None = None, - direct_messages_topic_id: int | None = None, - suggested_post_parameters: "SuggestedPostParameters | None" = None, - message_thread_id: int | None = None, + user_id: int, *, - allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, - reply_to_message_id: int | None = None, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, connect_timeout: ODVInput[float] = DEFAULT_NONE, pool_timeout: ODVInput[float] = DEFAULT_NONE, api_kwargs: JSONDict | None = None, - ) -> Message: - """Use this method to send paid media. + ) -> BotAccessSettings: + """ + Use this method to get the access settings of a managed bot. - .. versionadded:: 21.4 + .. versionadded:: 22.8 Args: - chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| If the chat is a channel, all - Telegram Star proceeds from this media will be credited to the chat's balance. - Otherwise, they will be credited to the bot's balance. - star_count (:obj:`int`): The number of Telegram Stars that must be paid to buy access - to the media; :tg-const:`telegram.constants.InvoiceLimit.MIN_STAR_COUNT` - - :tg-const:`telegram.constants.InvoiceLimit.MAX_STAR_COUNT`. - media (Sequence[:class:`telegram.InputPaidMedia`]): A list describing the media to be - sent; up to :tg-const:`telegram.constants.MediaGroupLimit.MAX_MEDIA_LENGTH` items. - payload (:obj:`str`, optional): Bot-defined paid media payload, - 0-:tg-const:`telegram.constants.InvoiceLimit.MAX_PAYLOAD_LENGTH` bytes. This will - not be displayed to the user, use it for your internal processes. - - .. versionadded:: 21.6 - caption (:obj:`str`, optional): Caption of the media to be sent, - 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters. - parse_mode (:obj:`str`, optional): |parse_mode| - caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): - |caption_entities| - show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| - disable_notification (:obj:`bool`, optional): |disable_notification| - protect_content (:obj:`bool`, optional): |protect_content| - reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| - reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ - :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): - Additional interface options. An object for an inline keyboard, custom reply - keyboard, instructions to remove reply keyboard or to force a reply from the user. - business_connection_id (:obj:`str`, optional): |business_id_str| - - .. versionadded:: 21.5 - allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| - - .. versionadded:: 21.7 - suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): - |suggested_post_parameters| - - .. versionadded:: 22.4 - direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| - - .. versionadded:: 22.4 - message_thread_id (:obj:`int`, optional): |message_thread_id_arg| - - .. versionadded:: 22.4 - - Keyword Args: - allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| - Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience - parameter for - - reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| - Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience - parameter for + user_id (:obj:`int`): User identifier of the managed bot whose access settings will be + returned. Returns: - :class:`telegram.Message`: On success, the sent message is returned. - - Raises: - :class:`telegram.error.TelegramError` + :class:`telegram.BotAccessSettings`: The access settings of the managed bot. """ data: JSONDict = { - "chat_id": chat_id, + "user_id": user_id, + } + + return BotAccessSettings.de_json( + await self._post( + "getManagedBotAccessSettings", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ), + self, + ) + + async def set_managed_bot_access_settings( + self, + user_id: int, + is_access_restricted: bool, + added_user_ids: Sequence[int] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Use this method to change the access settings of a managed bot. + + .. versionadded:: 22.8 + + Args: + user_id (:obj:`int`): User identifier of the managed bot whose access settings will be + changed. + is_access_restricted (:obj:`bool`): Pass :obj:`True`, if only selected users can access + the bot. The bot's owner can always access it. + added_user_ids (Sequence[:obj:`int`], optional): A list of up to + :tg-const:`telegram.constants.ManagedBotAccessLimit.MAX_ALLOWED_USERS` + identifiers of users who will have access to the bot in addition to its owner. + Ignored if :paramref:`is_access_restricted` is :obj:`False`. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + + data: JSONDict = { + "user_id": user_id, + "is_access_restricted": is_access_restricted, + "added_user_ids": added_user_ids, + } + + return await self._post( + "setManagedBotAccessSettings", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def get_user_personal_chat_messages( + self, + user_id: int, + limit: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> tuple[Message, ...]: + """ + Use this method to get the last messages from the personal chat (i.e., the chat currently + added to their profile) of a given user. + + .. versionadded:: 22.8 + + Args: + user_id (:obj:`int`): Unique identifier of the target user. + limit (:obj:`int`): The maximum number of messages to return; + :tg-const:`telegram.constants.PersonalChatMessagesLimit.MIN_LIMIT`- + :tg-const:`telegram.constants.PersonalChatMessagesLimit.MAX_LIMIT`. + + Returns: + tuple[:class:`telegram.Message`, ...]: On success, a tuple of + :class:`telegram.Message` objects is returned. + """ + + data: JSONDict = {"user_id": user_id, "limit": limit} + + return Message.de_list( + await self._post( + "getUserPersonalChatMessages", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ), + self, + ) + + async def send_paid_media( + self, + chat_id: str | int, + star_count: int, + media: Sequence["InputPaidMedia"], + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + business_connection_id: str | None = None, + payload: str | None = None, + allow_paid_broadcast: bool | None = None, + direct_messages_topic_id: int | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + message_thread_id: int | None = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> Message: + """Use this method to send paid media. + + .. versionadded:: 21.4 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| If the chat is a channel, all + Telegram Star proceeds from this media will be credited to the chat's balance. + Otherwise, they will be credited to the bot's balance. + star_count (:obj:`int`): The number of Telegram Stars that must be paid to buy access + to the media; :tg-const:`telegram.constants.InvoiceLimit.MIN_STAR_COUNT` - + :tg-const:`telegram.constants.InvoiceLimit.MAX_STAR_COUNT`. + media (Sequence[:class:`telegram.InputPaidMedia`]): A list describing the media to be + sent; up to :tg-const:`telegram.constants.MediaGroupLimit.MAX_MEDIA_LENGTH` items. + payload (:obj:`str`, optional): Bot-defined paid media payload, + 0-:tg-const:`telegram.constants.InvoiceLimit.MAX_PAYLOAD_LENGTH` bytes. This will + not be displayed to the user, use it for your internal processes. + + .. versionadded:: 21.6 + caption (:obj:`str`, optional): Caption of the media to be sent, + 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters. + parse_mode (:obj:`str`, optional): |parse_mode| + caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): + |caption_entities| + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + disable_notification (:obj:`bool`, optional): |disable_notification| + protect_content (:obj:`bool`, optional): |protect_content| + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ + :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): + Additional interface options. An object for an inline keyboard, custom reply + keyboard, instructions to remove reply keyboard or to force a reply from the user. + business_connection_id (:obj:`str`, optional): |business_id_str| + + .. versionadded:: 21.5 + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + + .. versionadded:: 21.7 + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + + .. versionadded:: 22.4 + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + + .. versionadded:: 22.4 + message_thread_id (:obj:`int`, optional): |message_thread_id_arg| + + .. versionadded:: 22.4 + + Keyword Args: + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + Returns: + :class:`telegram.Message`: On success, the sent message is returned. + """ + + data: JSONDict = { + "chat_id": chat_id, "star_count": star_count, "media": media, "show_caption_above_media": show_caption_above_media, @@ -11211,10 +11006,6 @@ async def create_chat_subscription_invite_link( Returns: :class:`telegram.ChatInviteLink` - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "chat_id": chat_id, @@ -11264,10 +11055,6 @@ async def edit_chat_subscription_invite_link( Returns: :class:`telegram.ChatInviteLink` - - Raises: - :class:`telegram.error.TelegramError` - """ link = invite_link.invite_link if isinstance(invite_link, ChatInviteLink) else invite_link data: JSONDict = { @@ -11304,9 +11091,6 @@ async def get_available_gifts( Returns: :class:`telegram.Gifts` - - Raises: - :class:`telegram.error.TelegramError` """ return Gifts.de_json( await self._post( @@ -11362,14 +11146,16 @@ async def send_gift( `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. + :attr:`~MessageEntity.STRIKETHROUGH`, :attr:`~MessageEntity.SPOILER`, + :attr:`~MessageEntity.CUSTOM_EMOJI`, and :attr:`~MessageEntity.DATE_TIME` 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. + :attr:`~MessageEntity.STRIKETHROUGH`, :attr:`~MessageEntity.SPOILER`, + :attr:`~MessageEntity.CUSTOM_EMOJI`, and :attr:`~MessageEntity.DATE_TIME` are + ignored. pay_for_upgrade (:obj:`bool`, optional): Pass :obj:`True` to pay for the gift upgrade from the bot's balance, thereby making the upgrade free for the receiver. @@ -11377,9 +11163,6 @@ async def send_gift( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "user_id": user_id, @@ -11424,9 +11207,6 @@ async def verify_chat( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "chat_id": chat_id, @@ -11466,9 +11246,6 @@ async def verify_user( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "user_id": user_id, @@ -11504,9 +11281,6 @@ async def remove_chat_verification( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "chat_id": chat_id, @@ -11541,9 +11315,6 @@ async def remove_user_verification( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "user_id": user_id, @@ -11573,9 +11344,6 @@ async def get_my_star_balance( Returns: :class:`telegram.StarAmount` - - Raises: - :class:`telegram.error.TelegramError` """ return StarAmount.de_json( await self._post( @@ -11620,9 +11388,6 @@ async def approve_suggested_post( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "chat_id": chat_id, @@ -11667,9 +11432,6 @@ async def decline_suggested_post( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "chat_id": chat_id, @@ -11729,9 +11491,6 @@ async def repost_story( Returns: :class:`telegram.Story` - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "business_connection_id": business_connection_id, @@ -11800,9 +11559,6 @@ async def get_user_gifts( Returns: :class:`telegram.OwnedGifts`: The owned gifts for the user. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "user_id": user_id, @@ -11883,9 +11639,6 @@ async def get_chat_gifts( Returns: :class:`telegram.OwnedGifts`: The owned gifts for the chat. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { @@ -11933,10 +11686,6 @@ async def set_my_profile_photo( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ data: JSONDict = { "photo": photo, @@ -11967,10 +11716,6 @@ async def remove_my_profile_photo( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` - """ return await self._post( @@ -12010,9 +11755,6 @@ async def get_user_profile_audios( Returns: :class:`telegram.UserProfileAudios` - - Raises: - :class:`telegram.error.TelegramError` """ data = { @@ -12061,9 +11803,6 @@ async def set_chat_member_tag( Returns: :obj:`bool`: On success, :obj:`True` is returned. - - Raises: - :class:`telegram.error.TelegramError` """ data: JSONDict = { "chat_id": chat_id, @@ -12081,6 +11820,346 @@ async def set_chat_member_tag( api_kwargs=api_kwargs, ) + async def get_managed_bot_token( + self, + user_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> str: + """ + Use this method to get the token of a managed bot. + + .. versionadded:: 22.8 + + Args: + user_id (:obj:`int`): User identifier of the managed bot whose token will be returned. + + Returns: + :obj:`str`: The token of the managed bot. + """ + data: JSONDict = { + "user_id": user_id, + } + + return await self._post( + "getManagedBotToken", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def replace_managed_bot_token( + self, + user_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> str: + """ + Use this method to revoke the current token of a managed bot and generate a new one. + + .. versionadded:: 22.8 + + Args: + user_id (:obj:`int`): User identifier of the managed bot whose token will be replaced. + + Returns: + :obj:`str`: The new token of the managed bot. + """ + data: JSONDict = { + "user_id": user_id, + } + + return await self._post( + "replaceManagedBotToken", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def save_prepared_keyboard_button( + self, + user_id: int, + button: KeyboardButton, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> PreparedKeyboardButton: + """ + Stores a keyboard button that can be used by a user within a Mini App. + + .. versionadded:: 22.8 + + Args: + user_id (:obj:`int`): Unique identifier of the target user that can use the button. + button (:obj:`telegram.KeyboardButton`): An object describing the + button to be saved. The button must be of the type + :attr:`~telegram.KeyboardButton.request_users`, + :attr:`~telegram.KeyboardButton.request_chat`, or + :attr:`~telegram.KeyboardButton.request_managed_bot`. + + Returns: + :class:`telegram.PreparedKeyboardButton` + """ + data: JSONDict = { + "user_id": user_id, + "button": button, + } + + return PreparedKeyboardButton.de_json( + await self._post( + "savePreparedKeyboardButton", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ), + self, + ) + + async def send_live_photo( + self, + chat_id: int | str, + live_photo: "FileInput | LivePhoto", + photo: "FileInput | PhotoSize", + business_connection_id: str | None = None, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + has_spoiler: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + allow_paid_broadcast: bool | None = None, + message_effect_id: str | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + filename: str | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> Message: + """ + Use this method to send live photos. + + .. seealso:: :wiki:`Working with Files and Media ` + + .. versionadded:: 22.8 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_channel| + live_photo (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | \ + :obj:`bytes` | :class:`pathlib.Path` | :class:`telegram.LivePhoto`): Live photo + video to send. Pass a ``file_id`` to send a file that exists on the Telegram + servers (recommended). |uploadinputnopath| Sending live photos by a URL is + currently unsupported. Lastly you can pass an existing + :class:`telegram.LivePhoto` object to send. + + Caution: + * The video must be at most 10MB in size. + * The video duration must not exceed 10 seconds. + * If you pass a :class:`telegram.LivePhoto`, its + :attr:`~telegram.LivePhoto.photo` field will not be considered, use + :paramref:`photo` to specify the photo to send. + + photo (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` \ + | :class:`pathlib.Path` | :class:`telegram.PhotoSize`): The static photo to send. + Pass a ``file_id`` to send a file that exists on the Telegram servers (recommended) + . |uploadinputnopath| Sending live photos by a URL is currently unsupported. + Lastly you can pass an existing :class:`telegram.PhotoSize` object to send. + business_connection_id (:obj:`str`, optional): |business_id_str| + message_thread_id (:obj:`int`, optional): |message_thread_id_arg| + direct_messages_topic_id (:obj:`int`, optional): |direct_messages_topic_id| + caption (:obj:`str`, optional): Video caption (may also be used when resending videos + by file_id), 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` + characters after entities parsing. + parse_mode (:obj:`str`, optional): |parse_mode| + caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): + |caption_entities| + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + has_spoiler (:obj:`bool`, optional): Pass :obj:`True` if the video needs to be covered + with a spoiler animation. + disable_notification (:obj:`bool`, optional): |disable_notification| + protect_content (:obj:`bool`, optional): |protect_content| + allow_paid_broadcast (:obj:`bool`, optional): |allow_paid_broadcast| + message_effect_id (:obj:`str`, optional): |message_effect_id| + suggested_post_parameters (:class:`telegram.SuggestedPostParameters`, optional): + |suggested_post_parameters| + reply_parameters (:class:`telegram.ReplyParameters`, optional): |reply_parameters| + reply_markup (:class:`InlineKeyboardMarkup` | :class:`ReplyKeyboardMarkup` | \ + :class:`ReplyKeyboardRemove` | :class:`ForceReply`, optional): + Additional interface options. An object for an inline keyboard, custom reply + keyboard, instructions to remove reply keyboard or to force a reply from the user. + + Keyword Args: + allow_sending_without_reply (:obj:`bool`, optional): |allow_sending_without_reply| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for + + reply_to_message_id (:obj:`int`, optional): |reply_to_msg_id| + Mutually exclusive with :paramref:`reply_parameters`, which this is a convenience + parameter for. + + filename (:obj:`str`, optional): Custom file name for :paramref:`photo`, when + uploading a new file. Convenience parameter, useful e.g. when sending files + generated by the :obj:`tempfile` module. + + Returns: + :class:`telegram.Message`: On success, the sent Message is returned. + + """ + data: JSONDict = { + "chat_id": chat_id, + "live_photo": self._parse_file_input(live_photo, LivePhoto), + "photo": self._parse_file_input(photo, PhotoSize, filename=filename), + "has_spoiler": has_spoiler, + "show_caption_above_media": show_caption_above_media, + } + + return await self._send_message( + "sendLivePhoto", + data, + disable_notification=disable_notification, + reply_markup=reply_markup, + protect_content=protect_content, + message_thread_id=message_thread_id, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + reply_parameters=reply_parameters, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + business_connection_id=business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=direct_messages_topic_id, + suggested_post_parameters=suggested_post_parameters, + allow_sending_without_reply=allow_sending_without_reply, + reply_to_message_id=reply_to_message_id, + ) + + async def delete_message_reaction( + self, + chat_id: int | str, + message_id: int, + user_id: int | None = None, + actor_chat_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Use this method to remove a reaction from a message in a group or a supergroup chat. + The bot must have the :attr:`~telegram.ChatMemberAdministrator.can_delete_messages` + administrator right in the chat. + + .. versionadded:: 22.8 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_group| + message_id (:obj:`int`): Identifier of the target message. + user_id (:obj:`int`, optional): Identifier of the user whose reaction will be removed, + if the reaction were added by a user. + actor_chat_id (:obj:`int`, optional): Identifier of the chat whose reaction will be + removed, if the reaction were added by a chat. + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + data: JSONDict = { + "chat_id": chat_id, + "message_id": message_id, + "user_id": user_id, + "actor_chat_id": actor_chat_id, + } + + return await self._post( + "deleteMessageReaction", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_all_message_reactions( + self, + chat_id: int | str, + user_id: int | None = None, + actor_chat_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Use this method to remove up to ``10000`` recent reactions in a group or a supergroup chat + added by a given user or chat. The bot must have the + :attr:`~telegram.ChatMemberAdministrator.can_delete_messages` administrator right in the + chat. + + .. versionadded:: 22.8 + + Args: + chat_id (:obj:`int` | :obj:`str`): |chat_id_group| + user_id (:obj:`int`, optional): Identifier of the user whose reactions will be removed, + if the reactions were added by a user. + actor_chat_id (:obj:`int`, optional): Identifier of the chat whose reactions will be + removed, if the reactions were added by a chat. + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + data: JSONDict = { + "chat_id": chat_id, + "user_id": user_id, + "actor_chat_id": actor_chat_id, + } + + return await self._post( + "deleteAllMessageReactions", + data, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """See :meth:`telegram.TelegramObject.to_dict`.""" data: JSONDict = {"id": self.id, "username": self.username, "first_name": self.first_name} @@ -12197,6 +12276,8 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`answer_pre_checkout_query`""" answerWebAppQuery = answer_web_app_query """Alias for :meth:`answer_web_app_query`""" + answerGuestQuery = answer_guest_query + """Alias for :meth:`answer_guest_query`""" restrictChatMember = restrict_chat_member """Alias for :meth:`restrict_chat_member`""" promoteChatMember = promote_chat_member @@ -12421,3 +12502,21 @@ def to_dict(self, recursive: bool = True) -> JSONDict: # noqa: ARG002 """Alias for :meth:`get_user_profile_audios`""" setChatMemberTag = set_chat_member_tag """Alias for :meth:`set_chat_member_tag`""" + getManagedBotToken = get_managed_bot_token + """Alias for :meth:`get_managed_bot_token`""" + replaceManagedBotToken = replace_managed_bot_token + """Alias for :meth:`replace_managed_bot_token`""" + savePreparedKeyboardButton = save_prepared_keyboard_button + """Alias for :meth:`save_prepared_keyboard_button`""" + sendLivePhoto = send_live_photo + """Alias for :meth:`send_live_photo`""" + getManagedBotAccessSettings = get_managed_bot_access_settings + """Alias for :meth:`get_managed_bot_access_settings`""" + setManagedBotAccessSettings = set_managed_bot_access_settings + """Alias for :meth:`set_managed_bot_access_settings`""" + getUserPersonalChatMessages = get_user_personal_chat_messages + """Alias for :meth:`get_user_personal_chat_messages`""" + deleteMessageReaction = delete_message_reaction + """Alias for :meth:`delete_message_reaction`""" + deleteAllMessageReactions = delete_all_message_reactions + """Alias for :meth:`delete_all_message_reactions`""" diff --git a/src/telegram/_botaccesssettings.py b/src/telegram/_botaccesssettings.py new file mode 100644 index 00000000000..07158a77527 --- /dev/null +++ b/src/telegram/_botaccesssettings.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that represents a Telegram Bot Access Settings.""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg +from telegram._utils.types import JSONDict + +if TYPE_CHECKING: + from telegram import Bot + + +class BotAccessSettings(TelegramObject): + """ + This object describes the access settings of a bot. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`is_access_restricted` and :attr:`added_users` are equal. + + .. versionadded:: 22.8 + + Args: + is_access_restricted (:obj:`bool`): :obj:`True`, if only selected users can access the bot. + The bot's owner can always access it. + added_users (Sequence[:class:`telegram.User`], optional): The list of other users who + have access to the bot if the access is restricted. + + Attributes: + is_access_restricted (:obj:`bool`): :obj:`True`, if only selected users can access the bot. + The bot's owner can always access it. + added_users (Sequence[:class:`telegram.User`]): Optional. The list of other users who + have access to the bot if the access is restricted. + """ + + __slots__ = ("added_users", "is_access_restricted") + + def __init__( + self, + is_access_restricted: bool, + added_users: Sequence[User] | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.is_access_restricted: bool = is_access_restricted + self.added_users: tuple[User, ...] = parse_sequence_arg(added_users) + + self._id_attrs = (self.is_access_restricted, self.added_users) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "BotAccessSettings": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + data["added_users"] = de_list_optional(data.get("added_users"), User, bot) + + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_chat.py b/src/telegram/_chat.py index 1230bf48277..fcdec9a6d71 100644 --- a/src/telegram/_chat.py +++ b/src/telegram/_chat.py @@ -34,6 +34,7 @@ from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import ( CorrectOptionID, + CorrectOptionIds, FileInput, JSONDict, ODVInput, @@ -57,12 +58,15 @@ InputChecklist, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, InputPaidMedia, + InputPollMedia, InputPollOption, LabeledPrice, LinkPreviewOptions, + LivePhoto, Location, Message, MessageEntity, @@ -310,6 +314,7 @@ async def leave( async def get_administrators( self, + return_bots: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -333,6 +338,7 @@ async def get_administrators( """ return await self.get_bot().get_chat_administrators( chat_id=self.id, + return_bots=return_bots, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -1087,7 +1093,7 @@ async def send_message( async def send_message_draft( self, draft_id: int, - text: str, + text: str | None = None, message_thread_id: int | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, entities: Sequence["MessageEntity"] | None = None, @@ -1104,6 +1110,9 @@ async def send_message_draft( For the documentation of the arguments, please see :meth:`telegram.Bot.send_message_draft`. + .. versionchanged:: 22.8 + Bot API 10.0 makes the ``text`` argument optional. + Returns: :obj:`bool`: On success, :obj:`True` is returned. @@ -1189,7 +1198,7 @@ async def delete_messages( async def send_media_group( self, media: Sequence[ - "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | InputMediaLivePhoto" # noqa: E501 # pylint: disable=line-too-long ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, @@ -1348,6 +1357,76 @@ async def send_photo( suggested_post_parameters=suggested_post_parameters, ) + async def send_live_photo( + self, + live_photo: "FileInput | LivePhoto", + photo: "FileInput | PhotoSize", + business_connection_id: str | None = None, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + has_spoiler: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + allow_paid_broadcast: bool | None = None, + message_effect_id: str | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + filename: str | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_live_photo(update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_live_photo`. + + .. versionadded:: 22.8 + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_live_photo( + chat_id=self.id, + live_photo=live_photo, + photo=photo, + business_connection_id=business_connection_id, + message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + show_caption_above_media=show_caption_above_media, + has_spoiler=has_spoiler, + disable_notification=disable_notification, + protect_content=protect_content, + allow_paid_broadcast=allow_paid_broadcast, + message_effect_id=message_effect_id, + suggested_post_parameters=suggested_post_parameters, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + allow_sending_without_reply=allow_sending_without_reply, + reply_to_message_id=reply_to_message_id, + filename=filename, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def send_contact( self, phone_number: str | None = None, @@ -2296,6 +2375,18 @@ async def send_poll( question_entities: Sequence["MessageEntity"] | None = None, message_effect_id: str | None = None, allow_paid_broadcast: bool | None = None, + shuffle_options: bool | None = None, + allows_revoting: bool | None = None, + correct_option_ids: CorrectOptionIds | None = None, + allow_adding_options: bool | None = None, + hide_results_until_closes: bool | None = None, + description: str | None = None, + description_parse_mode: ODVInput[str] | None = None, + description_entities: Sequence["MessageEntity"] | None = None, + members_only: bool | None = None, + country_codes: Sequence[str] | None = None, + explanation_media: "InputPollMedia | None" = None, + media: "InputPollMedia | None" = None, *, reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, @@ -2323,6 +2414,9 @@ async def send_poll( type=type, # pylint=pylint, allows_multiple_answers=allows_multiple_answers, correct_option_id=correct_option_id, + allows_revoting=allows_revoting, + shuffle_options=shuffle_options, + correct_option_ids=correct_option_ids, is_closed=is_closed, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, @@ -2346,6 +2440,15 @@ async def send_poll( business_connection_id=business_connection_id, question_parse_mode=question_parse_mode, question_entities=question_entities, + description=description, + description_parse_mode=description_parse_mode, + description_entities=description_entities, + hide_results_until_closes=hide_results_until_closes, + allow_adding_options=allow_adding_options, + members_only=members_only, + country_codes=country_codes, + explanation_media=explanation_media, + media=media, ) async def send_copy( @@ -4032,6 +4135,82 @@ async def set_chat_member_tag( api_kwargs=api_kwargs, ) + async def delete_reaction( + self, + message_id: int, + user_id: int | None = None, + actor_chat_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Shortcut for:: + + await bot.delete_message_reaction(chat_id=update.effective_chat.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.delete_message_reaction`. + + .. versionadded:: 22.8 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().delete_message_reaction( + chat_id=self.id, + message_id=message_id, + user_id=user_id, + actor_chat_id=actor_chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_all_reactions( + self, + user_id: int | None = None, + actor_chat_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Shortcut for:: + + await bot.delete_all_message_reactions( + chat_id=update.effective_chat.id, + *args, + **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.delete_all_message_reactions`. + + .. versionadded:: 22.8 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().delete_all_message_reactions( + chat_id=self.id, + user_id=user_id, + actor_chat_id=actor_chat_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/src/telegram/_chatfullinfo.py b/src/telegram/_chatfullinfo.py index 7142b5feb6a..3e240b08879 100644 --- a/src/telegram/_chatfullinfo.py +++ b/src/telegram/_chatfullinfo.py @@ -245,7 +245,7 @@ class ChatFullInfo(_ChatBase): .. versionadded:: 22.6 paid_message_star_count (:obj:`int`, optional): The number of Telegram Stars a general user - have to pay to send a message to the chat + has to pay to send a message to the chat .. versionadded:: 22.6 first_profile_audio (:obj:`telegram.Audio`, optional): For private chats, the first audio diff --git a/src/telegram/_chatmember.py b/src/telegram/_chatmember.py index be55c8469e8..eafacdfedba 100644 --- a/src/telegram/_chatmember.py +++ b/src/telegram/_chatmember.py @@ -515,8 +515,13 @@ class ChatMemberRestricted(ChatMember): .. versionadded:: 20.1 can_edit_tag (:obj:`bool`): :obj:`True`, if the user is allowed to edit their own tag. + If omitted, defaults to the value of :attr:`can_pin_messages`. .. versionadded:: 22.7 + can_react_to_messages (:obj:`bool`): :obj:`True`, if the user is allowed to react to + messages. + + .. versionadded:: 22.8 tag (:obj:`str`, optional): Tag of the member. .. versionadded:: 22.7 @@ -571,8 +576,13 @@ class ChatMemberRestricted(ChatMember): .. versionadded:: 20.1 can_edit_tag (:obj:`bool`): :obj:`True`, if the user is allowed to edit their own tag. + If omitted, defaults to the value of :attr:`can_pin_messages`. .. versionadded:: 22.7 + can_react_to_messages (:obj:`bool`): :obj:`True`, if the user is allowed to react to + messages. + + .. versionadded:: 22.8 tag (:obj:`str`): Optional. Tag of the member. .. versionadded:: 22.7 @@ -586,6 +596,7 @@ class ChatMemberRestricted(ChatMember): "can_invite_users", "can_manage_topics", "can_pin_messages", + "can_react_to_messages", "can_send_audios", "can_send_documents", "can_send_messages", @@ -621,10 +632,17 @@ def __init__( can_send_voice_notes: bool, can_edit_tag: bool, tag: str | None = None, + # tags: 22.8 + # temporarily optional to make it not breaking + can_react_to_messages: bool | None = None, *, api_kwargs: JSONDict | None = None, ): super().__init__(status=ChatMember.RESTRICTED, user=user, api_kwargs=api_kwargs) + + if can_react_to_messages is None: + raise TypeError("`can_react_to_messages` is required and cannot be None") + with self._unfrozen(): self.is_member: bool = is_member self.can_change_info: bool = can_change_info @@ -643,6 +661,7 @@ def __init__( self.can_send_video_notes: bool = can_send_video_notes self.can_send_voice_notes: bool = can_send_voice_notes self.can_edit_tag: bool = can_edit_tag + self.can_react_to_messages: bool = can_react_to_messages self.tag: str | None = tag diff --git a/src/telegram/_chatowner.py b/src/telegram/_chatowner.py index 1d8ce6619bd..17aaaff295d 100644 --- a/src/telegram/_chatowner.py +++ b/src/telegram/_chatowner.py @@ -79,11 +79,11 @@ class ChatOwnerLeft(TelegramObject): .. versionadded:: 22.7 Args: - new_owner (:class:`telegram.User`, optional): The user which will be the new owner of the + new_owner (:class:`telegram.User`, optional): The user who will become the new owner of the chat if the previous owner does not return to the chat Attributes: - new_owner (:class:`telegram.User`): Optional. The user which will be the new owner of the + new_owner (:class:`telegram.User`): Optional. The user who will become the new owner of the chat if the previous owner does not return to the chat """ diff --git a/src/telegram/_chatpermissions.py b/src/telegram/_chatpermissions.py index 2116f4f4c33..8b93b7fe123 100644 --- a/src/telegram/_chatpermissions.py +++ b/src/telegram/_chatpermissions.py @@ -36,7 +36,7 @@ class ChatPermissions(TelegramObject): :attr:`can_change_info`, :attr:`can_invite_users`, :attr:`can_pin_messages`, :attr:`can_send_audios`, :attr:`can_send_documents`, :attr:`can_send_photos`, :attr:`can_send_videos`, :attr:`can_send_video_notes`, :attr:`can_send_voice_notes`, - :attr:`can_manage_topics` and :attr:`can_edit_tag` are equal. + :attr:`can_manage_topics`, :attr:`can_edit_tag`, and :attr:`can_react_to_messages` are equal. .. versionchanged:: 20.0 :attr:`can_manage_topics` is considered as well when comparing objects of @@ -50,6 +50,9 @@ class ChatPermissions(TelegramObject): .. versionchanged:: 22.7 :attr:`can_edit_tag` is considered as well when comparing objects of this type in terms of equality. + .. versionchanged:: 22.8 + :attr:`can_react_to_messages` is considered as well when comparing objects of + this type in terms of equality. Note: @@ -100,6 +103,10 @@ class ChatPermissions(TelegramObject): tag. .. versionadded:: 22.7 + can_react_to_messages (:obj:`bool`, optional): :obj:`True`, if the user is allowed to react + to messages. If omitted, defaults to the value of :attr:`can_send_messages`. + + .. versionadded:: 22.8 Attributes: can_send_messages (:obj:`bool`): Optional. :obj:`True`, if the user is allowed to send text @@ -145,6 +152,10 @@ class ChatPermissions(TelegramObject): tag. .. versionadded:: 22.7 + can_react_to_messages (:obj:`bool`): Optional. :obj:`True`, if the user is allowed to react + to messages. If omitted, defaults to the value of :attr:`can_send_messages`. + + .. versionadded:: 22.8 """ @@ -155,6 +166,7 @@ class ChatPermissions(TelegramObject): "can_invite_users", "can_manage_topics", "can_pin_messages", + "can_react_to_messages", "can_send_audios", "can_send_documents", "can_send_messages", @@ -183,6 +195,7 @@ def __init__( can_send_video_notes: bool | None = None, can_send_voice_notes: bool | None = None, can_edit_tag: bool | None = None, + can_react_to_messages: bool | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -203,6 +216,7 @@ def __init__( self.can_send_video_notes: bool | None = can_send_video_notes self.can_send_voice_notes: bool | None = can_send_voice_notes self.can_edit_tag: bool | None = can_edit_tag + self.can_react_to_messages: bool | None = can_react_to_messages self._id_attrs = ( self.can_send_messages, @@ -220,6 +234,7 @@ def __init__( self.can_send_video_notes, self.can_send_voice_notes, self.can_edit_tag, + self.can_react_to_messages, ) self._freeze() diff --git a/src/telegram/_files/inputmedia.py b/src/telegram/_files/inputmedia.py index 23b6620985a..f33301ee5e6 100644 --- a/src/telegram/_files/inputmedia.py +++ b/src/telegram/_files/inputmedia.py @@ -16,7 +16,8 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -"""Base class for Telegram InputMedia Objects.""" +"""Base classes for Telegram InputMedia, InputPaidMedia, InputPollMedia +and InputPollOptionMedia Objects.""" import datetime as dtm from collections.abc import Sequence @@ -28,6 +29,7 @@ from telegram._files.document import Document from telegram._files.inputfile import InputFile from telegram._files.photosize import PhotoSize +from telegram._files.sticker import Sticker from telegram._files.video import Video from telegram._messageentity import MessageEntity from telegram._telegramobject import TelegramObject @@ -37,17 +39,47 @@ from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.files import parse_file_input from telegram._utils.types import JSONDict, ODVInput, TimePeriod -from telegram.constants import InputMediaType +from telegram._utils.warnings import warn +from telegram.constants import BaseInputMediaType +from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: from telegram._utils.types import FileInput -MediaType: TypeAlias = Animation | Audio | Document | PhotoSize | Video +class _BaseInputMedia(TelegramObject): + """ + Base class for objects representing the various input media types. + + Args: + media_type (:obj:`str`): Type of media that the instance represents. + + Attributes: + type (:obj:`str`): Type of media that the instance represents. + """ + + __slots__ = ("type",) + + def __init__( + self, + media_type: str, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.type: str = enum.get_member(constants.BaseInputMediaType, media_type, media_type) -class InputMedia(TelegramObject): + +class InputMedia(_BaseInputMedia): """ - Base class for Telegram InputMedia Objects. + This object represents the content of a media message to be sent. It should be one of: + + * :class:`telegram.InputMediaAnimation` + * :class:`telegram.InputMediaAudio` + * :class:`telegram.InputMediaDocument` + * :class:`telegram.InputMediaLivePhoto` + * :class:`telegram.InputMediaPhoto` + * :class:`telegram.InputMediaVideo` .. versionchanged:: 20.0 Added arguments and attributes :attr:`type`, :attr:`media`, :attr:`caption`, @@ -85,7 +117,7 @@ class InputMedia(TelegramObject): """ - __slots__ = ("caption", "caption_entities", "media", "parse_mode", "type") + __slots__ = ("caption", "caption_entities", "media", "parse_mode") def __init__( self, @@ -97,14 +129,12 @@ def __init__( *, api_kwargs: JSONDict | None = None, ): - super().__init__(api_kwargs=api_kwargs) - self.type: str = enum.get_member(constants.InputMediaType, media_type, media_type) - self.media: str | InputFile = media - self.caption: str | None = caption - self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) - self.parse_mode: ODVInput[str] = parse_mode - - self._freeze() + super().__init__(media_type=media_type, api_kwargs=api_kwargs) + with self._unfrozen(): + self.media: str | InputFile = media + self.caption: str | None = caption + self.caption_entities: tuple[MessageEntity, ...] = parse_sequence_arg(caption_entities) + self.parse_mode: ODVInput[str] = parse_mode @staticmethod def _parse_thumbnail_input(thumbnail: "FileInput | None") -> str | InputFile | None: @@ -123,6 +153,7 @@ class InputPaidMedia(TelegramObject): * :class:`telegram.InputPaidMediaPhoto` * :class:`telegram.InputPaidMediaVideo` + * :class:`telegram.InputPaidMediaLivePhoto` .. seealso:: :wiki:`Working with Files and Media ` @@ -142,6 +173,11 @@ class InputPaidMedia(TelegramObject): """:const:`telegram.constants.InputPaidMediaType.PHOTO`""" VIDEO: Final[str] = constants.InputPaidMediaType.VIDEO """:const:`telegram.constants.InputPaidMediaType.VIDEO`""" + LIVE_PHOTO: Final[str] = constants.InputPaidMediaType.LIVE_PHOTO + """:const:`telegram.constants.InputPaidMediaType.LIVE_PHOTO` + + .. versionadded:: 22.8 + """ __slots__ = ("media", "type") @@ -300,6 +336,51 @@ def duration(self) -> int | dtm.timedelta | None: return get_timedelta_value(self._duration, attribute="duration") +class InputPaidMediaLivePhoto(InputPaidMedia): + """ + The paid media to send is a live photo. + + .. seealso:: :wiki:`Working with Files and Media ` + + .. versionadded:: 22.8 + + Args: + media (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` \ + | :class:`pathlib.Path` | :class:`~telegram.Video`): Video of the live photo to send. + Pass a ``file_id`` to send a file that exists on the Telegram servers (recommended). + |uploadinputnopath| Sending live photos by a URL is currently unsupported. Lastly you + can pass an existing :class:`telegram.Video` object to send. + photo (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ + :class:`pathlib.Path` | :class:`~telegram.PhotoSize`): Photo of the live photo to send. + Pass a ``file_id`` to send a file that exists on the Telegram servers (recommended). + |uploadinputnopath| Sending live photos by a URL is currently unsupported. + Lastly you can pass an existing :class:`telegram.PhotoSize` object to send. + + Attributes: + type (:obj:`str`): Type of the media, always + :tg-const:`telegram.constants.InputPaidMediaType.LIVE_PHOTO`. + media (:obj:`str` | :class:`telegram.InputFile`): Video of the live photo to send. + |fileinputnopath| + photo (:obj:`str` | :class:`telegram.InputFile`): Photo of the live photo to send. + |fileinputnopath| + """ + + __slots__ = ("photo",) + + def __init__( + self, + media: "FileInput | Video", + photo: "FileInput | PhotoSize", + *, + api_kwargs: JSONDict | None = None, + ): + media = parse_file_input(media, tg_type=Video, attach=True, local_mode=True) + photo = parse_file_input(photo, tg_type=PhotoSize, attach=True, local_mode=True) + super().__init__(type=InputPaidMedia.LIVE_PHOTO, media=media, api_kwargs=api_kwargs) + with self._unfrozen(): + self.photo: str | InputFile = photo + + class InputMediaAnimation(InputMedia): """Represents an animation file (GIF or H.264/MPEG-4 AVC video without sound) to be sent. @@ -320,11 +401,12 @@ class InputMediaAnimation(InputMedia): .. versionchanged:: 13.2 Accept :obj:`bytes` as input. - filename (:obj:`str`, optional): Custom file name for the animation, when uploading a - new file. Convenience parameter, useful e.g. when sending files generated by the - :obj:`tempfile` module. + filename_depr (:obj:`str`, optional): Positional placeholder for keyword only parameter + :paramref:`filename`. For backward compatibility. - .. versionadded:: 13.1 + .. versionadded:: 22.8 + .. deprecated:: 22.8 + This parameter is deprecated, use :paramref:`filename` instead. caption (:obj:`str`, optional): Caption of the animation to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -353,8 +435,17 @@ class InputMediaAnimation(InputMedia): .. versionadded:: 21.3 + Keyword Args: + filename (:obj:`str`, optional): Custom file name for the animation, when uploading a + new file. Convenience parameter, useful e.g. when sending files generated by the + :obj:`tempfile` module. + + .. versionadded:: 13.1 + .. versionchanged:: 22.8 + This parameter is now keyword-only. + Attributes: - type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.ANIMATION`. + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.ANIMATION`. media (:obj:`str` | :class:`telegram.InputFile`): Animation to send. caption (:obj:`str`): Optional. Caption of the animation to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters @@ -403,13 +494,28 @@ def __init__( height: int | None = None, duration: TimePeriod | None = None, caption_entities: Sequence[MessageEntity] | None = None, - filename: str | None = None, + # tag: deprecated 22.8 + filename_depr: str | None = None, + # - has_spoiler: bool | None = None, thumbnail: "FileInput | None" = None, show_caption_above_media: bool | None = None, *, + filename: str | None = None, api_kwargs: JSONDict | None = None, ): + if filename_depr is not None and filename is not None: + raise ValueError("`filename_depr` and `filename` are mutually exclusive.") + if filename_depr is not None: + warn( + PTBDeprecationWarning( + "22.8", + "Positional passing of `filename` or keyword usage of `filename_depr`" + " is deprecated. `filename` will become a keyword-only argument.", + ), + stacklevel=2, + ) + if isinstance(media, Animation): width = media.width if width is None else width height = media.height if height is None else height @@ -418,10 +524,13 @@ def __init__( else: # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. - media = parse_file_input(media, filename=filename, attach=True, local_mode=True) + effective_filename = filename_depr or filename + media = parse_file_input( + media, filename=effective_filename, attach=True, local_mode=True + ) super().__init__( - InputMediaType.ANIMATION, + BaseInputMediaType.ANIMATION, media, caption, caption_entities, @@ -453,11 +562,12 @@ class InputMediaPhoto(InputMedia): .. versionchanged:: 13.2 Accept :obj:`bytes` as input. - filename (:obj:`str`, optional): Custom file name for the photo, when uploading a - new file. Convenience parameter, useful e.g. when sending files generated by the - :obj:`tempfile` module. + filename_depr (:obj:`str`, optional): Positional placeholder for keyword only parameter + :paramref:`filename`. For backward compatibility. - .. versionadded:: 13.1 + .. versionadded:: 22.8 + .. deprecated:: 22.8 + This parameter is deprecated, use :paramref:`filename` instead. caption (:obj:`str`, optional ): Caption of the photo to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -474,8 +584,17 @@ class InputMediaPhoto(InputMedia): .. versionadded:: 21.3 + Keyword Args: + filename (:obj:`str`, optional): Custom file name for the photo, when uploading a + new file. Convenience parameter, useful e.g. when sending files generated by the + :obj:`tempfile` module. + + .. versionadded:: 13.1 + .. versionchanged:: 22.8 + This parameter is now keyword-only. + Attributes: - type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.PHOTO`. + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.PHOTO`. media (:obj:`str` | :class:`telegram.InputFile`): Photo to send. caption (:obj:`str`): Optional. Caption of the photo to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters @@ -507,17 +626,35 @@ def __init__( caption: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, caption_entities: Sequence[MessageEntity] | None = None, - filename: str | None = None, + # tag: deprecated 22.8 + filename_depr: str | None = None, + # - has_spoiler: bool | None = None, show_caption_above_media: bool | None = None, *, + filename: str | None = None, api_kwargs: JSONDict | None = None, ): + if filename_depr is not None and filename is not None: + raise ValueError("`filename_depr` and `filename` are mutually exclusive.") + if filename_depr is not None: + warn( + PTBDeprecationWarning( + "22.8", + "Positional passing of `filename` or keyword usage of `filename_depr`" + " is deprecated. `filename` will become a keyword-only argument.", + ), + stacklevel=2, + ) + # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. - media = parse_file_input(media, PhotoSize, filename=filename, attach=True, local_mode=True) + effective_filename = filename_depr or filename + media = parse_file_input( + media, PhotoSize, filename=effective_filename, attach=True, local_mode=True + ) super().__init__( - InputMediaType.PHOTO, + BaseInputMediaType.PHOTO, media, caption, caption_entities, @@ -553,11 +690,12 @@ class InputMediaVideo(InputMedia): .. versionchanged:: 13.2 Accept :obj:`bytes` as input. - filename (:obj:`str`, optional): Custom file name for the video, when uploading a - new file. Convenience parameter, useful e.g. when sending files generated by the - :obj:`tempfile` module. + filename_depr (:obj:`str`, optional): Positional placeholder for keyword only parameter + :paramref:`filename`. For backward compatibility. - .. versionadded:: 13.1 + .. versionadded:: 22.8 + .. deprecated:: 22.8 + This parameter is deprecated, use :paramref:`filename` instead. caption (:obj:`str`, optional): Caption of the video to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -594,8 +732,17 @@ class InputMediaVideo(InputMedia): .. versionadded:: 21.3 + Keyword Args: + filename (:obj:`str`, optional): Custom file name for the video, when uploading a + new file. Convenience parameter, useful e.g. when sending files generated by the + :obj:`tempfile` module. + + .. versionadded:: 13.1 + .. versionchanged:: 22.8 + This parameter is now keyword-only. + Attributes: - type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.VIDEO`. + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.VIDEO`. media (:obj:`str` | :class:`telegram.InputFile`): Video file to send. caption (:obj:`str`): Optional. Caption of the video to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters @@ -656,15 +803,30 @@ def __init__( supports_streaming: bool | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, caption_entities: Sequence[MessageEntity] | None = None, - filename: str | None = None, + # tag: deprecated 22.8 + filename_depr: str | None = None, + # - has_spoiler: bool | None = None, thumbnail: "FileInput | None" = None, show_caption_above_media: bool | None = None, cover: "FileInput | None" = None, start_timestamp: int | None = None, *, + filename: str | None = None, api_kwargs: JSONDict | None = None, ): + if filename_depr is not None and filename is not None: + raise ValueError("`filename_depr` and `filename` are mutually exclusive.") + if filename_depr is not None: + warn( + PTBDeprecationWarning( + "22.8", + "Positional passing of `filename` or keyword usage of `filename_depr`" + " is deprecated. `filename` will become a keyword-only argument.", + ), + stacklevel=2, + ) + if isinstance(media, Video): width = width if width is not None else media.width height = height if height is not None else media.height @@ -673,10 +835,13 @@ def __init__( else: # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. - media = parse_file_input(media, filename=filename, attach=True, local_mode=True) + effective_filename = filename_depr or filename + media = parse_file_input( + media, filename=effective_filename, attach=True, local_mode=True + ) super().__init__( - InputMediaType.VIDEO, + BaseInputMediaType.VIDEO, media, caption, caption_entities, @@ -701,6 +866,156 @@ def duration(self) -> int | dtm.timedelta | None: return get_timedelta_value(self._duration, attribute="duration") +class InputMediaLocation(_BaseInputMedia): + """Represents a location to be sent. + + .. versionadded:: 22.8 + + Args: + latitude (:obj:`float`): Latitude of the location. + longitude (:obj:`float`): Longitude of the location. + horizontal_accuracy (:obj:`float`, optional): The radius of uncertainty for the location, + measured in meters; 0-:tg-const:`telegram.Location.HORIZONTAL_ACCURACY`. + + Attributes: + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.LOCATION`. + latitude (:obj:`float`): Latitude of the location. + longitude (:obj:`float`): Longitude of the location. + horizontal_accuracy (:obj:`float`): Optional. The radius of uncertainty for the location, + measured in meters; 0-:tg-const:`telegram.Location.HORIZONTAL_ACCURACY`. + """ + + __slots__ = ("horizontal_accuracy", "latitude", "longitude") + + def __init__( + self, + latitude: float, + longitude: float, + horizontal_accuracy: float | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(media_type=BaseInputMediaType.LOCATION, api_kwargs=api_kwargs) + with self._unfrozen(): + self.latitude: float = latitude + self.longitude: float = longitude + self.horizontal_accuracy: float | None = horizontal_accuracy + + +class InputMediaVenue(_BaseInputMedia): + """Represents a venue to be sent. + + .. versionadded:: 22.8 + + Args: + latitude (:obj:`float`): Latitude of the location. + longitude (:obj:`float`): Longitude of the location. + title (:obj:`str`): Name of the venue. + address (:obj:`str`): Address of the venue. + foursquare_id (:obj:`str`, optional): Foursquare identifier of the venue. + foursquare_type (:obj:`str`, optional): Foursquare type of the venue, if known. (For + example, ``“arts_entertainment/default”``, ``“arts_entertainment/aquarium”`` + or ``“food/icecream”``). + google_place_id (:obj:`str`, optional): Google Places identifier of the venue. + google_place_type (:obj:`str`, optional): Google Places type of the venue. (See\ + `supported types `__) + + Attributes: + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.VENUE`. + latitude (:obj:`float`): Latitude of the location. + longitude (:obj:`float`): Longitude of the location. + title (:obj:`str`): Name of the venue. + address (:obj:`str`): Address of the venue. + foursquare_id (:obj:`str`): Optional. Foursquare identifier of the venue. + foursquare_type (:obj:`str`): Optional. Foursquare type of the venue, if known. (For + example, ``“arts_entertainment/default”``, ``“arts_entertainment/aquarium”`` + or ``“food/icecream”``). + google_place_id (:obj:`str`): Optional. Google Places identifier of the venue. + google_place_type (:obj:`str`): Optional. Google Places type of the venue. (See\ + `supported types `__) + """ + + __slots__ = ( + "address", + "foursquare_id", + "foursquare_type", + "google_place_id", + "google_place_type", + "latitude", + "longitude", + "title", + ) + + def __init__( + self, + latitude: float, + longitude: float, + title: str, + address: str, + foursquare_id: str | None = None, + foursquare_type: str | None = None, + google_place_id: str | None = None, + google_place_type: str | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(media_type=BaseInputMediaType.VENUE, api_kwargs=api_kwargs) + with self._unfrozen(): + self.latitude: float = latitude + self.longitude: float = longitude + self.title: str = title + self.address: str = address + self.foursquare_id: str | None = foursquare_id + self.foursquare_type: str | None = foursquare_type + self.google_place_id: str | None = google_place_id + self.google_place_type: str | None = google_place_type + + +class InputMediaSticker(_BaseInputMedia): + """Represents a sticker file to be sent. + + .. seealso:: :wiki:`Working with Files and Media ` + + .. versionadded:: 22.8 + + Args: + media (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` | \ + :class:`pathlib.Path` | :class:`telegram.Sticker`): File to send. |fileinputnopath| + + Lastly you can pass an existing :class:`telegram.Sticker` object to send. + emoji (:obj:`str`, optional): Emoji associated with the sticker; only for just uploaded + stickers. + + Keyword Args: + filename (:obj:`str`, optional): Custom file name for the sticker, when uploading a + new file. Convenience parameter, useful e.g. when sending files generated by the + :obj:`tempfile` module. + + Attributes: + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.STICKER`. + media (:obj:`str` | :class:`telegram.InputFile`): Sticker file to send. + emoji (:obj:`str`): Optional. Emoji associated with the sticker; only for just uploaded + stickers. + """ + + __slots__ = ("emoji", "media") + + def __init__( + self, + media: "FileInput | Sticker", + emoji: str | None = None, + *, + filename: str | None = None, + api_kwargs: JSONDict | None = None, + ): + media = parse_file_input(media, Sticker, filename=filename, attach=True, local_mode=True) + + super().__init__(media_type=BaseInputMediaType.STICKER, api_kwargs=api_kwargs) + with self._unfrozen(): + self.media: str | InputFile = media + self.emoji: str | None = emoji + + class InputMediaAudio(InputMedia): """Represents an audio file to be treated as music to be sent. @@ -721,11 +1036,12 @@ class InputMediaAudio(InputMedia): .. versionchanged:: 13.2 Accept :obj:`bytes` as input. - filename (:obj:`str`, optional): Custom file name for the audio, when uploading a - new file. Convenience parameter, useful e.g. when sending files generated by the - :obj:`tempfile` module. + filename_depr (:obj:`str`, optional): Positional placeholder for keyword only parameter + :paramref:`filename`. For backward compatibility. - .. versionadded:: 13.1 + .. versionadded:: 22.8 + .. deprecated:: 22.8 + This parameter is deprecated, use :paramref:`filename` instead. caption (:obj:`str`, optional): Caption of the audio to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -748,8 +1064,17 @@ class InputMediaAudio(InputMedia): .. versionadded:: 20.2 + Keyword Args: + filename (:obj:`str`, optional): Custom file name for the audio, when uploading a + new file. Convenience parameter, useful e.g. when sending files generated by the + :obj:`tempfile` module. + + .. versionadded:: 13.1 + .. versionchanged:: 22.8 + This parameter is now keyword-only. + Attributes: - type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.AUDIO`. + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.AUDIO`. media (:obj:`str` | :class:`telegram.InputFile`): Audio file to send. caption (:obj:`str`): Optional. Caption of the audio to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters @@ -786,11 +1111,26 @@ def __init__( performer: str | None = None, title: str | None = None, caption_entities: Sequence[MessageEntity] | None = None, - filename: str | None = None, + # tag: deprecated 22.8 + filename_depr: str | None = None, + # - thumbnail: "FileInput | None" = None, *, + filename: str | None = None, api_kwargs: JSONDict | None = None, ): + if filename_depr is not None and filename is not None: + raise ValueError("`filename_depr` and `filename` are mutually exclusive.") + if filename_depr is not None: + warn( + PTBDeprecationWarning( + "22.8", + "Positional passing of `filename` or keyword usage of `filename_depr`" + " is deprecated. `filename` will become a keyword-only argument.", + ), + stacklevel=2, + ) + if isinstance(media, Audio): duration = duration if duration is not None else media._duration performer = media.performer if performer is None else performer @@ -799,10 +1139,13 @@ def __init__( else: # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. - media = parse_file_input(media, filename=filename, attach=True, local_mode=True) + effective_filename = filename_depr or filename + media = parse_file_input( + media, filename=effective_filename, attach=True, local_mode=True + ) super().__init__( - InputMediaType.AUDIO, + BaseInputMediaType.AUDIO, media, caption, caption_entities, @@ -835,11 +1178,12 @@ class InputMediaDocument(InputMedia): .. versionchanged:: 13.2 Accept :obj:`bytes` as input. - filename (:obj:`str`, optional): Custom file name for the document, when uploading a - new file. Convenience parameter, useful e.g. when sending files generated by the - :obj:`tempfile` module. + filename_depr (:obj:`str`, optional): Positional placeholder for keyword only parameter + :paramref:`filename`. For backward compatibility. - .. versionadded:: 13.1 + .. versionadded:: 22.8 + .. deprecated:: 22.8 + This parameter is deprecated, use :paramref:`filename` instead. caption (:obj:`str`, optional): Caption of the document to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after entities parsing. @@ -857,8 +1201,17 @@ class InputMediaDocument(InputMedia): .. versionadded:: 20.2 + Keyword Args: + filename (:obj:`str`, optional): Custom file name for the document, when uploading a + new file. Convenience parameter, useful e.g. when sending files generated by the + :obj:`tempfile` module. + + .. versionadded:: 13.1 + .. versionchanged:: 22.8 + This parameter is now keyword-only. + Attributes: - type (:obj:`str`): :tg-const:`telegram.constants.InputMediaType.DOCUMENT`. + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.DOCUMENT`. media (:obj:`str` | :class:`telegram.InputFile`): File to send. caption (:obj:`str`): Optional. Caption of the document to be sent, 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters @@ -887,17 +1240,35 @@ def __init__( parse_mode: ODVInput[str] = DEFAULT_NONE, disable_content_type_detection: bool | None = None, caption_entities: Sequence[MessageEntity] | None = None, - filename: str | None = None, + # tag: deprecated 22.8 + filename_depr: str | None = None, + # - thumbnail: "FileInput | None" = None, *, + filename: str | None = None, api_kwargs: JSONDict | None = None, ): + if filename_depr is not None and filename is not None: + raise ValueError("`filename_depr` and `filename` are mutually exclusive.") + if filename_depr is not None: + warn( + PTBDeprecationWarning( + "22.8", + "Positional passing of `filename` or keyword usage of `filename_depr`" + " is deprecated. `filename` will become a keyword-only argument.", + ), + stacklevel=2, + ) + # We use local_mode=True because we don't have access to the actual setting and want # things to work in local mode. - media = parse_file_input(media, Document, filename=filename, attach=True, local_mode=True) + effective_filename = filename_depr or filename + media = parse_file_input( + media, Document, filename=effective_filename, attach=True, local_mode=True + ) super().__init__( - InputMediaType.DOCUMENT, + BaseInputMediaType.DOCUMENT, media, caption, caption_entities, @@ -907,3 +1278,105 @@ def __init__( with self._unfrozen(): self.thumbnail: str | InputFile | None = self._parse_thumbnail_input(thumbnail) self.disable_content_type_detection: bool | None = disable_content_type_detection + + +class InputMediaLivePhoto(InputMedia): + """Represents a live photo to be sent. + + .. seealso:: :wiki:`Working with Files and Media ` + + .. versionadded:: 22.8 + + Args: + media (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` \ + | :class:`pathlib.Path` | :class:`~telegram.Video`): Video of the live photo to send. + Pass a ``file_id`` to send a file that exists on the Telegram servers (recommended). + |uploadinputnopath| Sending live photos by a URL is currently unsupported. Lastly + you can pass an existing :class:`telegram.Video` object to send. + photo (:obj:`str` | :term:`file object` | :class:`~telegram.InputFile` | :obj:`bytes` \ + | :class:`pathlib.Path` | :class:`~telegram.PhotoSize`): The static photo to send. + Pass a ``file_id`` to send a file that exists on the Telegram servers (recommended). + |uploadinputnopath| Sending live photos by a URL is currently unsupported. Lastly + you can pass an existing :class:`telegram.PhotoSize` object to send. + caption (:obj:`str`, optional): Caption of the live photo to be sent, + 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters after + entities parsing. + parse_mode (:obj:`str`, optional): |parse_mode| + caption_entities (Sequence[:class:`telegram.MessageEntity`], optional): |caption_entities| + show_caption_above_media (:obj:`bool`, optional): Pass |show_cap_above_med| + has_spoiler (:obj:`bool`, optional): Pass :obj:`True`, if the video needs to be covered + with a spoiler animation. + + Attributes: + type (:obj:`str`): :tg-const:`telegram.constants.BaseInputMediaType.LIVE_PHOTO`. + media (:obj:`str` | :class:`telegram.InputFile`): Video of the live photo to send. + photo (:obj:`str` | :class:`telegram.InputFile`): The static photo to send. + caption (:obj:`str`): Optional. Caption of the live photo to be sent, + 0-:tg-const:`telegram.constants.MessageLimit.CAPTION_LENGTH` characters + after entities parsing. + parse_mode (:obj:`str`): Optional. |parse_mode| + caption_entities (tuple[:class:`telegram.MessageEntity`]): Optional. |captionentitiesattr| + show_caption_above_media (:obj:`bool`): Optional. |show_cap_above_med| + has_spoiler (:obj:`bool`): Optional. :obj:`True`, if the video is covered with a + spoiler animation. + """ + + __slots__ = ("has_spoiler", "photo", "show_caption_above_media") + + def __init__( + self, + media: "FileInput | Video", + photo: "FileInput | PhotoSize", + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence[MessageEntity] | None = None, + show_caption_above_media: bool | None = None, + has_spoiler: bool | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + media = parse_file_input(media, tg_type=Video, attach=True, local_mode=True) + photo = parse_file_input(photo, tg_type=PhotoSize, attach=True, local_mode=True) + + super().__init__( + BaseInputMediaType.LIVE_PHOTO, + media, + caption, + caption_entities, + parse_mode, + api_kwargs=api_kwargs, + ) + with self._unfrozen(): + self.photo: str | InputFile = photo + self.show_caption_above_media: bool | None = show_caption_above_media + self.has_spoiler: bool | None = has_spoiler + + +InputPollMedia: TypeAlias = ( + InputMediaAnimation + | InputMediaAudio + | InputMediaDocument + | InputMediaLivePhoto + | InputMediaLocation + | InputMediaPhoto + | InputMediaVenue + | InputMediaVideo +) +"""Type alias for InputPollMedia objects. + +versionadded:: 22.8 +""" + +InputPollOptionMedia: TypeAlias = ( + InputMediaAnimation + | InputMediaLivePhoto + | InputMediaLocation + | InputMediaPhoto + | InputMediaSticker + | InputMediaVenue + | InputMediaVideo +) +"""Type alias for InputPollOptionMedia objects. + +.. versionadded:: 22.8 +""" diff --git a/src/telegram/_files/_inputstorycontent.py b/src/telegram/_files/inputstorycontent.py similarity index 100% rename from src/telegram/_files/_inputstorycontent.py rename to src/telegram/_files/inputstorycontent.py diff --git a/src/telegram/_files/livephoto.py b/src/telegram/_files/livephoto.py new file mode 100644 index 00000000000..46edb118869 --- /dev/null +++ b/src/telegram/_files/livephoto.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that represents a Telegram LivePhoto.""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from telegram._files._basemedium import _BaseMedium +from telegram._files.photosize import PhotoSize +from telegram._utils.argumentparsing import de_list_optional, parse_sequence_arg, to_timedelta +from telegram._utils.types import JSONDict, TimePeriod + +if TYPE_CHECKING: + import datetime as dtm + + from telegram import Bot + + +class LivePhoto(_BaseMedium): + """ + This object represents a live photo. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`file_unique_id` is equal. + + .. versionadded:: 22.8 + + Args: + file_id (:obj:`str`): Identifier for the video file which can be used to download or reuse + the file. + file_unique_id (:obj:`str`): Unique identifier for this file, which + is supposed to be the same over time and for different bots. + Can't be used to download or reuse the file. + width (:obj:`int`): Video width as defined by the sender. + height (:obj:`int`): Video height as defined by the sender. + duration (:obj:`int` | :class:`datetime.timedelta`): Duration of the video + in seconds as defined by the sender. + photo (Sequence[:obj:`telegram.PhotoSize`], optional): Available sizes of the corresponding + static photo. + mime_type (:obj:`str`, optional): MIME type of a file as defined by the sender. + file_size (:obj:`int`, optional): File size in bytes. + + Attributes: + file_id (:obj:`str`): Identifier for the video file which can be used to download or reuse + the file. + file_unique_id (:obj:`str`): Unique identifier for this file, which + is supposed to be the same over time and for different bots. + Can't be used to download or reuse the file. + width (:obj:`int`): Video width as defined by the sender. + height (:obj:`int`): Video height as defined by the sender. + duration (:class:`datetime.timedelta`): Duration of the video + in seconds as defined by the sender. + photo (tuple[:obj:`telegram.PhotoSize`]): Optional. Available sizes of the corresponding + static photo. + mime_type (:obj:`str`): Optional. MIME type of a file as defined by the sender. + file_size (:obj:`int`): Optional. File size in bytes. + + """ + + __slots__ = ( + "duration", + "height", + "mime_type", + "photo", + "width", + ) + + def __init__( + self, + file_id: str, + file_unique_id: str, + width: int, + height: int, + duration: TimePeriod, + photo: Sequence[PhotoSize] | None = None, + mime_type: str | None = None, + file_size: int | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__( + file_id=file_id, + file_unique_id=file_unique_id, + file_size=file_size, + api_kwargs=api_kwargs, + ) + with self._unfrozen(): + # Required + self.width: int = width + self.height: int = height + self.duration: dtm.timedelta = to_timedelta(duration) + # Optional + self.photo: Sequence[PhotoSize] | None = parse_sequence_arg(photo) + self.mime_type: str | None = mime_type + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "LivePhoto": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) + + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_files/sticker.py b/src/telegram/_files/sticker.py index e7854b53ab9..9ab6bd09aa5 100644 --- a/src/telegram/_files/sticker.py +++ b/src/telegram/_files/sticker.py @@ -311,7 +311,7 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "StickerSet": data["thumbnail"] = de_json_optional(data.get("thumbnail"), PhotoSize, bot) data["stickers"] = de_list_optional(data.get("stickers"), Sticker, bot) - api_kwargs = {} + api_kwargs: JSONDict = {} # These are deprecated fields that TG still returns for backwards compatibility # Let's filter them out to speed up the de-json process for deprecated_field in ("contains_masks", "thumb", "is_animated", "is_video"): diff --git a/src/telegram/_files/video.py b/src/telegram/_files/video.py index d197e053cd2..eeb2fb584b8 100644 --- a/src/telegram/_files/video.py +++ b/src/telegram/_files/video.py @@ -105,7 +105,7 @@ class Video(_BaseThumbedMedium): .. deprecated:: v22.2 |time-period-int-deprecated| - qualities (Sequence[:class:`telegram.VideoQuality`]): Optional. List of available qualities + qualities (tuple[:class:`telegram.VideoQuality`]): Optional. List of available qualities of the video .. versionadded:: 22.7 diff --git a/src/telegram/_forcereply.py b/src/telegram/_forcereply.py index bf69c93aa43..228aef19aa5 100644 --- a/src/telegram/_forcereply.py +++ b/src/telegram/_forcereply.py @@ -31,7 +31,7 @@ class ForceReply(TelegramObject): the user (act as if the user has selected the bot's message and tapped 'Reply'). This can be extremely useful if you want to create user-friendly step-by-step interfaces without having to sacrifice `privacy mode `_. Not - supported in channels and for messages sent on behalf of a Telegram Business account. + supported in channels and for messages sent on behalf of a user account. Objects of this class are comparable in terms of equality. Two objects of this class are considered equal, if their :attr:`selective` is equal. diff --git a/src/telegram/_inputchecklist.py b/src/telegram/_inputchecklist.py index d8fd06258ca..21ae2f491f6 100644 --- a/src/telegram/_inputchecklist.py +++ b/src/telegram/_inputchecklist.py @@ -49,8 +49,8 @@ class InputChecklistTask(TelegramObject): |parse_mode| text_entities (Sequence[:class:`telegram.MessageEntity`], optional): List of special entities that appear in the text, which can be specified instead of - parse_mode. Currently, only bold, italic, underline, strikethrough, spoiler, and - custom_emoji entities are allowed. + parse_mode. Currently, only bold, italic, underline, strikethrough, spoiler, + custom_emoji, and date_time entities are allowed. Attributes: id (:obj:`int`): @@ -66,7 +66,7 @@ class InputChecklistTask(TelegramObject): text_entities (Sequence[:class:`telegram.MessageEntity`]): Optional. List of special entities that appear in the text, which can be specified instead of parse_mode. Currently, only bold, italic, underline, strikethrough, spoiler, - and custom_emoji entities are allowed. + custom_emoji, and date_time entities are allowed. """ @@ -117,7 +117,7 @@ class InputChecklist(TelegramObject): title_entities (Sequence[:class:`telegram.MessageEntity`], optional): List of special entities that appear in the title, which can be specified instead of :paramref:`parse_mode`. Currently, only bold, italic, - underline, strikethrough, spoiler, and custom_emoji entities are allowed. + underline, strikethrough, spoiler, and custom_emoji, and date_time entities are allowed tasks (Sequence[:class:`telegram.InputChecklistTask`]): List of :tg-const:`telegram.constants.InputChecklistLimit.MIN_TASK_NUMBER`\ @@ -139,7 +139,7 @@ class InputChecklist(TelegramObject): title_entities (Sequence[:class:`telegram.MessageEntity`]): Optional. List of special entities that appear in the title, which can be specified instead of :paramref:`parse_mode`. Currently, only bold, italic, - underline, strikethrough, spoiler, and custom_emoji entities are allowed. + underline, strikethrough, spoiler, and custom_emoji, and date_time entities are allowed tasks (Sequence[:class:`telegram.InputChecklistTask`]): List of :tg-const:`telegram.constants.InputChecklistLimit.MIN_TASK_NUMBER`\ diff --git a/src/telegram/_keyboardbutton.py b/src/telegram/_keyboardbutton.py index 750062bf516..27d6e6ae955 100644 --- a/src/telegram/_keyboardbutton.py +++ b/src/telegram/_keyboardbutton.py @@ -21,7 +21,11 @@ from typing import TYPE_CHECKING from telegram._keyboardbuttonpolltype import KeyboardButtonPollType -from telegram._keyboardbuttonrequest import KeyboardButtonRequestChat, KeyboardButtonRequestUsers +from telegram._keyboardbuttonrequest import ( + KeyboardButtonRequestChat, + KeyboardButtonRequestManagedBot, + KeyboardButtonRequestUsers, +) from telegram._telegramobject import TelegramObject from telegram._utils.argumentparsing import de_json_optional from telegram._utils.types import JSONDict @@ -120,6 +124,14 @@ class KeyboardButton(TelegramObject): Premium subscription. .. versionadded:: 22.7 + request_managed_bot (:obj:`telegram.KeyboardButtonRequestManagedBot`, optional): If + specified, pressing the button will ask the user to create and share a bot that will + be managed by the current bot. Available for bots that enabled management of other bots + in the `@BotFather ` Mini App. Available in private + chats only. + + .. versionadded:: 22.8 + Attributes: text (:obj:`str`): Text of the button. If none of the fields other than :attr:`text`, @@ -167,6 +179,13 @@ class KeyboardButton(TelegramObject): Premium subscription. .. versionadded:: 22.7 + request_managed_bot (:obj:`telegram.KeyboardButtonRequestManagedBot`): Optional. If + specified, pressing the button will ask the user to create and share a bot that will + be managed by the current bot. Available for bots that enabled management of other bots + in the `@BotFather ` Mini App. Available in private + chats only. + + .. versionadded:: 22.8 """ __slots__ = ( @@ -174,6 +193,7 @@ class KeyboardButton(TelegramObject): "request_chat", "request_contact", "request_location", + "request_managed_bot", "request_poll", "request_users", "style", @@ -192,6 +212,7 @@ def __init__( request_users: KeyboardButtonRequestUsers | None = None, style: str | None = None, icon_custom_emoji_id: str | None = None, + request_managed_bot: KeyboardButtonRequestManagedBot | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -208,6 +229,7 @@ def __init__( self.request_chat: KeyboardButtonRequestChat | None = request_chat self.style: str | None = style self.icon_custom_emoji_id: str | None = icon_custom_emoji_id + self.request_managed_bot: KeyboardButtonRequestManagedBot | None = request_managed_bot self._id_attrs = ( self.text, @@ -238,6 +260,9 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "KeyboardButton": data.get("request_chat"), KeyboardButtonRequestChat, bot ) data["web_app"] = de_json_optional(data.get("web_app"), WebAppInfo, bot) + data["request_managed_bot"] = de_json_optional( + data.get("request_managed_bot"), KeyboardButtonRequestManagedBot, bot + ) api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility diff --git a/src/telegram/_keyboardbuttonrequest.py b/src/telegram/_keyboardbuttonrequest.py index b5e0b050436..780ec4850d5 100644 --- a/src/telegram/_keyboardbuttonrequest.py +++ b/src/telegram/_keyboardbuttonrequest.py @@ -268,3 +268,53 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "KeyboardButtonReq ) return super().de_json(data=data, bot=bot) + + +class KeyboardButtonRequestManagedBot(TelegramObject): + """ + This object defines the parameters for the creation of a managed bot. + Information about the created bot will be shared with the bot using the update + managed_bot and a :obj:`telegram.Message` with the field + :attr:`telegram.Message.managed_bot_created`. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`request_id` is equal. + + .. versionadded:: 22.8 + + Args: + request_id (:obj:`int`): Signed 32-bit identifier of the request. Must be unique + within the message. + suggested_name (:obj:`str`, optional): Suggested name for the bot. + suggested_username (:obj:`str`, optional): Suggested username for the bot. + + Attributes: + request_id (:obj:`int`): Signed 32-bit identifier of the request. Must be unique + within the message. + suggested_name (:obj:`str`): Optional. Suggested name for the bot. + suggested_username (:obj:`str`): Optional. Suggested username for the bot. + """ + + __slots__ = ( + "request_id", + "suggested_name", + "suggested_username", + ) + + def __init__( + self, + request_id: int, + suggested_name: str | None = None, + suggested_username: str | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.request_id: int = request_id + # Optional + self.suggested_name: str | None = suggested_name + self.suggested_username: str | None = suggested_username + + self._id_attrs = (self.request_id,) + self._freeze() diff --git a/src/telegram/_managedbot.py b/src/telegram/_managedbot.py new file mode 100644 index 00000000000..2ead362d78b --- /dev/null +++ b/src/telegram/_managedbot.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains objects that represent managed bots in the Telegram Bot API.""" + +from typing import TYPE_CHECKING + +from telegram._telegramobject import TelegramObject +from telegram._user import User +from telegram._utils.types import ( + JSONDict, +) + +if TYPE_CHECKING: + from telegram import Bot + + +class ManagedBotCreated(TelegramObject): + """ + This object contains information about the bot that was created to be managed by the current + bot. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`bot` is equal. + + .. versionadded:: 22.8 + + Args: + bot (:class:`telegram.User`): Information about the bot. The bot's token can be fetched + using the method :meth:`~telegram.Bot.get_managed_bot_token`. + Attributes: + bot (:class:`telegram.User`): Information about the bot. The bot's token can be fetched + using the method :meth:`~telegram.Bot.get_managed_bot_token`. + """ + + __slots__ = ("bot",) + + def __init__( + self, + bot: User, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.bot: User = bot + self._id_attrs = (self.bot,) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ManagedBotCreated": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data=data) + + data["bot"] = User.de_json(data=data["bot"], bot=bot) + + return super().de_json(data=data, bot=bot) + + +class ManagedBotUpdated(TelegramObject): + """ + This object contains information about the creation, token update, or owner update of a bot + that is managed by the current bot. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`user` and :attr:`bot` are equal. + + .. versionadded:: 22.8 + + Args: + user (:class:`telegram.User`): User that created the bot. + bot (:class:`telegram.User`): Information about the bot. Token of the bot can be fetched + using the method :meth:`~telegram.Bot.get_managed_bot_token`. + + Attributes: + user (:class:`telegram.User`): User that created the bot. + bot (:class:`telegram.User`): Information about the bot. Token of the bot can be fetched + using the method :meth:`~telegram.Bot.get_managed_bot_token`. + """ + + __slots__ = ("bot", "user") + + def __init__( + self, + user: User, + bot: User, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.user: User = user + self.bot: User = bot + + self._id_attrs = ( + self.user, + self.bot, + ) + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ManagedBotUpdated": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data=data) + + data["user"] = User.de_json(data=data["user"], bot=bot) + data["bot"] = User.de_json(data=data["bot"], bot=bot) + + return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_message.py b/src/telegram/_message.py index c0d018ec406..c52351df67d 100644 --- a/src/telegram/_message.py +++ b/src/telegram/_message.py @@ -37,6 +37,7 @@ from telegram._files.audio import Audio from telegram._files.contact import Contact from telegram._files.document import Document +from telegram._files.livephoto import LivePhoto from telegram._files.location import Location from telegram._files.photosize import PhotoSize from telegram._files.sticker import Sticker @@ -57,6 +58,7 @@ from telegram._inline.inlinekeyboardmarkup import InlineKeyboardMarkup from telegram._inputchecklist import InputChecklist from telegram._linkpreviewoptions import LinkPreviewOptions +from telegram._managedbot import ManagedBotCreated from telegram._messageautodeletetimerchanged import MessageAutoDeleteTimerChanged from telegram._messageentity import MessageEntity from telegram._paidmedia import PaidMediaInfo @@ -65,7 +67,7 @@ from telegram._payment.invoice import Invoice from telegram._payment.refundedpayment import RefundedPayment from telegram._payment.successfulpayment import SuccessfulPayment -from telegram._poll import Poll +from telegram._poll import Poll, PollOptionAdded, PollOptionDeleted from telegram._proximityalerttriggered import ProximityAlertTriggered from telegram._reply import ReplyParameters from telegram._shared import ChatShared, UsersShared @@ -80,6 +82,7 @@ from telegram._utils.strings import TextEncoding from telegram._utils.types import ( CorrectOptionID, + CorrectOptionIds, JSONDict, MarkdownVersion, ODVInput, @@ -107,17 +110,21 @@ GiveawayCompleted, GiveawayCreated, GiveawayWinners, + InlineQueryResult, InputMedia, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, InputPaidMedia, + InputPollMedia, InputPollOption, LabeledPrice, MessageId, MessageOrigin, ReactionType, + SentGuestMessage, SuggestedPostApprovalFailed, SuggestedPostApproved, SuggestedPostDeclined, @@ -686,9 +693,46 @@ class Message(MaybeInaccessibleMessage): .. versionadded:: 22.7 sender_tag (:obj:`str`, optional): Tag or custom title of the sender of the message; for - supergroups only + supergroups only. .. versionadded:: 22.7 + poll_option_added (:class:`telegram.PollOptionAdded`, optional): Service message: + answer option was added to a poll. + + .. versionadded:: 22.8 + poll_option_deleted (:class:`telegram.PollOptionDeleted`, optional): Service message: + answer option was deleted from a poll. + + .. versionadded:: 22.8 + reply_to_poll_option_id (:obj:`str`, optional): Persistent + identifier of the specific poll option that is being replied to. + + .. versionadded:: 22.8 + + managed_bot_created (:class:`telegram.ManagedBotCreated`, optional): Service message: user + created a bot that will be managed by the current bot. + + .. versionadded:: 22.8 + guest_bot_caller_user (:class:`telegram.User`, optional): For a message sent by a guest + bot, this is the user whose original message triggered the bot's response. + + .. versionadded:: 22.8 + guest_bot_caller_chat (:class:`telegram.Chat`, optional): For a message sent by a guest + bot, this is the chat whose original message triggered the bot's response. + + .. versionadded:: 22.8 + guest_query_id (:obj:`str`, optional): The unique identifier for the guest query. Use this + identifier with the method :meth:`telegram.Bot.answer_guest_query` to send a response + message. If non-empty, the message belongs to the chat where the guest bot was + summoned, which may not coincide with other existing bot chats sharing the same + identifier. + + .. versionadded:: 22.8 + live_photo (:class:`telegram.LivePhoto`, optional): Message is a live photo, information + about the live photo. For backward compatibility, when this field is set, the photo + field will also be set. + + .. versionadded:: 22.8 Attributes: message_id (:obj:`int`): Unique message identifier inside this chat. In specific instances @@ -1102,9 +1146,45 @@ class Message(MaybeInaccessibleMessage): .. versionadded:: 22.7 sender_tag (:obj:`str`): Optional. Tag or custom title of the sender of the message; for - supergroups only + supergroups only. .. versionadded:: 22.7 + poll_option_added (:class:`telegram.PollOptionAdded`): Optional. Service message: + answer option was added to a poll. + + .. versionadded:: 22.8 + poll_option_deleted (:class:`telegram.PollOptionDeleted`): Optional. Service message: + answer option was deleted from a poll. + + .. versionadded:: 22.8 + reply_to_poll_option_id (:obj:`str`): Optional. Persistent + identifier of the specific poll option that is being replied to. + + .. versionadded:: 22.8 + managed_bot_created (:class:`telegram.ManagedBotCreated`): Optional. Service message: user + created a bot that will be managed by the current bot. + + .. versionadded:: 22.8 + guest_bot_caller_user (:class:`telegram.User`): Optional. For a message sent by a guest + bot, this is the user whose original message triggered the bot's response. + + .. versionadded:: 22.8 + guest_bot_caller_chat (:class:`telegram.Chat`): Optional. For a message sent by a guest + bot, this is the chat whose original message triggered the bot's response. + + .. versionadded:: 22.8 + guest_query_id (:obj:`str`): Optional. The unique identifier for the guest query. Use this + identifier with the method :meth:`telegram.Bot.answer_guest_query` to send a response + message. If non-empty, the message belongs to the chat where the guest bot was + summoned, which may not coincide with other existing bot chats sharing the same + identifier. + + .. versionadded:: 22.8 + live_photo (:class:`telegram.LivePhoto`): Optional. Message is a live photo, information + about the live photo. For backward compatibility, when this field is set, the photo + field will also be set. + + .. versionadded:: 22.8 .. |custom_emoji_no_md1_support| replace:: Since custom emoji entities are not supported by :attr:`~telegram.constants.ParseMode.MARKDOWN`, this method now raises a @@ -1166,6 +1246,9 @@ class Message(MaybeInaccessibleMessage): "giveaway_created", "giveaway_winners", "group_chat_created", + "guest_bot_caller_chat", + "guest_bot_caller_user", + "guest_query_id", "has_media_spoiler", "has_protected_content", "invoice", @@ -1175,7 +1258,9 @@ class Message(MaybeInaccessibleMessage): "is_topic_message", "left_chat_member", "link_preview_options", + "live_photo", "location", + "managed_bot_created", "media_group_id", "message_auto_delete_timer_changed", "message_thread_id", @@ -1191,12 +1276,15 @@ class Message(MaybeInaccessibleMessage): "photo", "pinned_message", "poll", + "poll_option_added", + "poll_option_deleted", "proximity_alert_triggered", "quote", "refunded_payment", "reply_markup", "reply_to_checklist_task_id", "reply_to_message", + "reply_to_poll_option_id", "reply_to_story", "sender_boost_count", "sender_business_bot", @@ -1337,6 +1425,14 @@ def __init__( chat_owner_changed: ChatOwnerChanged | None = None, chat_owner_left: ChatOwnerLeft | None = None, sender_tag: str | None = None, + poll_option_added: PollOptionAdded | None = None, + poll_option_deleted: PollOptionDeleted | None = None, + reply_to_poll_option_id: str | None = None, + managed_bot_created: ManagedBotCreated | None = None, + guest_bot_caller_user: User | None = None, + guest_bot_caller_chat: Chat | None = None, + guest_query_id: str | None = None, + live_photo: LivePhoto | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -1467,6 +1563,14 @@ def __init__( self.chat_owner_changed: ChatOwnerChanged | None = chat_owner_changed self.chat_owner_left: ChatOwnerLeft | None = chat_owner_left self.sender_tag: str | None = sender_tag + self.poll_option_added: PollOptionAdded | None = poll_option_added + self.poll_option_deleted: PollOptionDeleted | None = poll_option_deleted + self.reply_to_poll_option_id: str | None = reply_to_poll_option_id + self.managed_bot_created: ManagedBotCreated | None = managed_bot_created + self.guest_bot_caller_user: User | None = guest_bot_caller_user + self.guest_bot_caller_chat: Chat | None = guest_bot_caller_chat + self.guest_query_id: str | None = guest_query_id + self.live_photo: LivePhoto | None = live_photo self._effective_attachment = DEFAULT_NONE @@ -1687,6 +1791,22 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Message": data.get("chat_owner_changed"), ChatOwnerChanged, bot ) data["chat_owner_left"] = de_json_optional(data.get("chat_owner_left"), ChatOwnerLeft, bot) + data["poll_option_added"] = de_json_optional( + data.get("poll_option_added"), PollOptionAdded, bot + ) + data["poll_option_deleted"] = de_json_optional( + data.get("poll_option_deleted"), PollOptionDeleted, bot + ) + data["managed_bot_created"] = de_json_optional( + data.get("managed_bot_created"), ManagedBotCreated, bot + ) + data["guest_bot_caller_user"] = de_json_optional( + data.get("guest_bot_caller_user"), User, bot + ) + data["guest_bot_caller_chat"] = de_json_optional( + data.get("guest_bot_caller_chat"), Chat, bot + ) + data["live_photo"] = de_json_optional(data.get("live_photo"), LivePhoto, bot) api_kwargs = {} # This is a deprecated field that TG still returns for backwards compatibility @@ -1718,6 +1838,7 @@ def effective_attachment( | Document | Game | Invoice + | LivePhoto | Location | PassportData | Sequence[PhotoSize] @@ -1742,6 +1863,7 @@ def effective_attachment( * :class:`telegram.Animation` * :class:`telegram.Game` * :class:`telegram.Invoice` + * :class:`telegram.LivePhoto` * :class:`telegram.Location` * :class:`telegram.PassportData` * list[:class:`telegram.PhotoSize`] @@ -2130,7 +2252,7 @@ async def reply_text( async def reply_text_draft( self, draft_id: int, - text: str, + text: str | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, entities: Sequence["MessageEntity"] | None = None, message_thread_id: ODVInput[int] = DEFAULT_NONE, @@ -2157,6 +2279,9 @@ async def reply_text_draft( .. versionadded:: 22.6 + .. versionchanged:: 22.8 + Bot API 10.0 makes the ``text`` argument optional. + Returns: :obj:`bool`: On success, :obj:`True` is returned. @@ -2429,7 +2554,7 @@ async def reply_html( async def reply_media_group( self, media: Sequence[ - "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | InputMediaLivePhoto" # noqa: E501 # pylint: disable=line-too-long ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, @@ -2590,6 +2715,87 @@ async def reply_photo( suggested_post_parameters=suggested_post_parameters, ) + async def reply_live_photo( + self, + live_photo: "FileInput | LivePhoto", + photo: "FileInput | PhotoSize", + caption: str | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + reply_markup: "ReplyMarkup | None" = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + has_spoiler: bool | None = None, + protect_content: ODVInput[bool] = DEFAULT_NONE, + message_thread_id: ODVInput[int] = DEFAULT_NONE, + reply_parameters: "ReplyParameters | None" = None, + message_effect_id: str | None = None, + allow_paid_broadcast: bool | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + *, + reply_to_message_id: int | None = None, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + filename: str | None = None, + do_quote: bool | (_ReplyKwargs | None) = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_live_photo( + update.effective_message.chat_id, + message_thread_id=update.effective_message.message_thread_id, + business_connection_id=self.business_connection_id, + direct_messages_topic_id=self.direct_messages_topic.topic_id, + *args, + **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_live_photo`. + + .. versionadded:: 22.8 + + Keyword Args: + do_quote (:obj:`bool` | :obj:`dict`, optional): |do_quote| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + chat_id, effective_reply_parameters = await self._parse_quote_arguments( + do_quote, reply_to_message_id, reply_parameters, allow_sending_without_reply + ) + message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) + return await self.get_bot().send_live_photo( + chat_id=chat_id, + live_photo=live_photo, + photo=photo, + caption=caption, + disable_notification=disable_notification, + reply_parameters=effective_reply_parameters, + reply_markup=reply_markup, + parse_mode=parse_mode, + caption_entities=caption_entities, + filename=filename, + protect_content=protect_content, + message_thread_id=message_thread_id, + has_spoiler=has_spoiler, + show_caption_above_media=show_caption_above_media, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + business_connection_id=self.business_connection_id, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, + direct_messages_topic_id=self._extract_direct_messages_topic_id(), + suggested_post_parameters=suggested_post_parameters, + ) + async def reply_audio( self, audio: "FileInput | Audio", @@ -3473,6 +3679,18 @@ async def reply_poll( question_entities: Sequence["MessageEntity"] | None = None, message_effect_id: str | None = None, allow_paid_broadcast: bool | None = None, + shuffle_options: bool | None = None, + allows_revoting: bool | None = None, + correct_option_ids: CorrectOptionIds | None = None, + allow_adding_options: bool | None = None, + hide_results_until_closes: bool | None = None, + description: str | None = None, + description_parse_mode: ODVInput[str] | None = None, + description_entities: Sequence["MessageEntity"] | None = None, + members_only: bool | None = None, + country_codes: Sequence[str] | None = None, + explanation_media: "InputPollMedia | None" = None, + media: "InputPollMedia | None" = None, *, reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, @@ -3522,6 +3740,9 @@ async def reply_poll( type=type, allows_multiple_answers=allows_multiple_answers, correct_option_id=correct_option_id, + allows_revoting=allows_revoting, + shuffle_options=shuffle_options, + correct_option_ids=correct_option_ids, is_closed=is_closed, disable_notification=disable_notification, reply_parameters=effective_reply_parameters, @@ -3543,6 +3764,15 @@ async def reply_poll( question_entities=question_entities, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + description=description, + description_parse_mode=description_parse_mode, + description_entities=description_entities, + hide_results_until_closes=hide_results_until_closes, + allow_adding_options=allow_adding_options, + members_only=members_only, + country_codes=country_codes, + explanation_media=explanation_media, + media=media, ) async def reply_dice( @@ -3661,7 +3891,7 @@ async def reply_checklist( ) return await self.get_bot().send_checklist( business_connection_id=self.business_connection_id, - chat_id=chat_id, # type: ignore[arg-type] + chat_id=chat_id, checklist=checklist, disable_notification=disable_notification, reply_parameters=effective_reply_parameters, @@ -3773,7 +4003,7 @@ async def reply_game( ) message_thread_id = self._parse_message_thread_id(chat_id, message_thread_id) return await self.get_bot().send_game( - chat_id=chat_id, # type: ignore[arg-type] + chat_id=chat_id, game_short_name=game_short_name, disable_notification=disable_notification, reply_parameters=effective_reply_parameters, @@ -5148,6 +5378,80 @@ async def decline_suggested_post( api_kwargs=api_kwargs, ) + async def delete_reaction( + self, + user_id: int | None = None, + actor_chat_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """Shortcut for:: + + await bot.delete_message_reaction( + chat_id=message.chat_id, + message_id=message.message_id, + *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.delete_message_reaction`. + + .. versionadded:: 22.8 + + Returns: + :obj:`bool` On success, :obj:`True` is returned. + """ + return await self.get_bot().delete_message_reaction( + chat_id=self.chat_id, + message_id=self.message_id, + user_id=user_id, + actor_chat_id=actor_chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def answer_guest_query( + self, + result: "InlineQueryResult", + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "SentGuestMessage": + """Shortcut for:: + + await bot.answer_guest_query( + self.guest_query_id, + *args, **kwargs, + ) + + For the documentation of the arguments, please see :meth:`telegram.Bot.answer_guest_query`. + + .. versionadded:: 22.8 + + Returns: + :class:`telegram.SentGuestMessage`: On success, a + :class:`telegram.SentGuestMessage` is returned. + """ + return await self.get_bot().answer_guest_query( + guest_query_id=self.guest_query_id, + result=result, + 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/src/telegram/_paidmedia.py b/src/telegram/_paidmedia.py index 67af46710a5..6b746147cb3 100644 --- a/src/telegram/_paidmedia.py +++ b/src/telegram/_paidmedia.py @@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Final from telegram import constants +from telegram._files.livephoto import LivePhoto from telegram._files.photosize import PhotoSize from telegram._files.video import Video from telegram._telegramobject import TelegramObject @@ -68,6 +69,8 @@ class PaidMedia(TelegramObject): """:const:`telegram.constants.PaidMediaType.PHOTO`""" VIDEO: Final[str] = constants.PaidMediaType.VIDEO """:const:`telegram.constants.PaidMediaType.VIDEO`""" + LIVE_PHOTO: Final[str] = constants.PaidMediaType.LIVE_PHOTO + """:const:`telegram.constants.PaidMediaType.LIVE_PHOTO`""" def __init__( self, @@ -100,6 +103,7 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PaidMedia": cls.PREVIEW: PaidMediaPreview, cls.PHOTO: PaidMediaPhoto, cls.VIDEO: PaidMediaVideo, + cls.LIVE_PHOTO: PaidMediaLivePhoto, } if cls is PaidMedia and data.get("type") in _class_mapping: @@ -251,6 +255,47 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PaidMediaVideo": return super().de_json(data=data, bot=bot) # type: ignore[return-value] +class PaidMediaLivePhoto(PaidMedia): + """ + The paid media is a live photo. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`live_photo` are equal. + + .. versionadded:: 22.8 + + Args: + type (:obj:`str`): Type of the paid media, always :tg-const:`telegram.PaidMedia.LIVE_PHOTO` + live_photo (:class:`telegram.LivePhoto`): The photo. + + Attributes: + type (:obj:`str`): Type of the paid media, always :tg-const:`telegram.PaidMedia.LIVE_PHOTO` + live_photo (:class:`telegram.LivePhoto`): The photo. + + """ + + __slots__ = ("live_photo",) + + def __init__( + self, + live_photo: LivePhoto, + *, + api_kwargs: JSONDict | None = None, + ) -> None: + super().__init__(type=PaidMedia.LIVE_PHOTO, api_kwargs=api_kwargs) + + with self._unfrozen(): + self.live_photo: LivePhoto = live_photo + self._id_attrs = (self.type, self.live_photo) + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PaidMediaLivePhoto": + data = cls._parse_data(data) + + data["live_photo"] = de_json_optional(data.get("live_photo"), LivePhoto, bot) + return super().de_json(data=data, bot=bot) # type: ignore[return-value] + + class PaidMediaInfo(TelegramObject): """ Describes the paid media added to a message. diff --git a/src/telegram/_poll.py b/src/telegram/_poll.py index 744edd22eff..dd3689492ac 100644 --- a/src/telegram/_poll.py +++ b/src/telegram/_poll.py @@ -24,6 +24,15 @@ from telegram import constants from telegram._chat import Chat +from telegram._files.animation import Animation +from telegram._files.audio import Audio +from telegram._files.document import Document +from telegram._files.livephoto import LivePhoto +from telegram._files.location import Location +from telegram._files.photosize import PhotoSize +from telegram._files.sticker import Sticker +from telegram._files.venue import Venue +from telegram._files.video import Video from telegram._messageentity import MessageEntity from telegram._telegramobject import TelegramObject from telegram._user import User @@ -42,9 +51,126 @@ from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.entities import parse_message_entities, parse_message_entity from telegram._utils.types import JSONDict, ODVInput, TimePeriod +from telegram._utils.warnings import warn +from telegram.warnings import PTBDeprecationWarning if TYPE_CHECKING: - from telegram import Bot + from telegram import Bot, InputPollOptionMedia, MaybeInaccessibleMessage + + +class PollMedia(TelegramObject): + """ + At most one of the optional fields can be present in any given object. + + 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.8 + + Args: + animation (:class:`telegram.Animation`, optional): Media is an animation, information about + the animation + audio (:class:`telegram.Audio`, optional): Media is an audio file, information about the + file; currently, can't be received in a poll option + document (:class:`telegram.Document`, optional): Media is a general file, information about + the file; currently, can't be received in a poll option + live_photo (:class:`telegram.LivePhoto`, optional): Media is a live photo, information + about the live photo + location (:class:`telegram.Location`, optional): Media is a shared location, information + about the location + photo (Sequence[:class:`telegram.PhotoSize`], optional): Media is a photo, available sizes + of the photo + sticker (:class:`telegram.Sticker`, optional): Media is a sticker, information about the + sticker; currently, for poll options only + venue (:class:`telegram.Venue`, optional): Media is a venue, information about the venue + video (:class:`telegram.Video`, optional): Media is a video, information about the video + + Attributes: + animation (:class:`telegram.Animation`): Optional. Media is an animation, information about + the animation + audio (:class:`telegram.Audio`): Optional. Media is an audio file, information about the + file; currently, can't be received in a poll option + document (:class:`telegram.Document`): Optional. Media is a general file, information about + the file; currently, can't be received in a poll option + live_photo (:class:`telegram.LivePhoto`, optional): Media is a live photo, information + about the live photo + location (:class:`telegram.Location`): Optional. Media is a shared location, information + about the location + photo (tuple[:class:`telegram.PhotoSize`]): Optional. Media is a photo, available sizes + of the photo + sticker (:class:`telegram.Sticker`): Optional. Media is a sticker, information about the + sticker; currently, for poll options only + venue (:class:`telegram.Venue`): Optional. Media is a venue, information about the venue + video (:class:`telegram.Video`): Optional. Media is a video, information about the video + """ + + __slots__ = ( + "animation", + "audio", + "document", + "live_photo", + "location", + "photo", + "sticker", + "venue", + "video", + ) + + def __init__( + self, + animation: Animation | None = None, + audio: Audio | None = None, + document: Document | None = None, + live_photo: LivePhoto | None = None, + location: Location | None = None, + photo: Sequence[PhotoSize] | None = None, + sticker: Sticker | None = None, + venue: Venue | None = None, + video: Video | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.animation: Animation | None = animation + self.audio: Audio | None = audio + self.document: Document | None = document + self.live_photo: LivePhoto | None = live_photo + self.location: Location | None = location + self.photo: tuple[PhotoSize, ...] = parse_sequence_arg(photo) + self.sticker: Sticker | None = sticker + self.venue: Venue | None = venue + self.video: Video | None = video + + self._id_attrs = ( + self.animation, + self.audio, + self.document, + self.live_photo, + self.location, + self.photo, + self.sticker, + self.venue, + self.video, + ) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PollMedia": + """See :meth:`telegram.TelegramObject.de_json`.""" + data = cls._parse_data(data) + + data["animation"] = de_json_optional(data.get("animation"), Animation, bot) + data["audio"] = de_json_optional(data.get("audio"), Audio, bot) + data["document"] = de_json_optional(data.get("document"), Document, bot) + data["live_photo"] = de_json_optional(data.get("live_photo"), LivePhoto, bot) + data["location"] = de_json_optional(data.get("location"), Location, bot) + data["photo"] = de_list_optional(data.get("photo"), PhotoSize, bot) + data["sticker"] = de_json_optional(data.get("sticker"), Sticker, bot) + data["venue"] = de_json_optional(data.get("venue"), Venue, bot) + data["video"] = de_json_optional(data.get("video"), Video, bot) + + return super().de_json(data=data, bot=bot) class InputPollOption(TelegramObject): @@ -67,6 +193,9 @@ class InputPollOption(TelegramObject): :paramref:`text_parse_mode`. Currently, only custom emoji entities are allowed. This list is empty if the text does not contain entities. + media (:class:`telegram.InputPollOptionMedia`, optional): Media added to the poll option. + + .. versionadded:: 22.8 Attributes: text (:obj:`str`): Option text, @@ -79,15 +208,19 @@ class InputPollOption(TelegramObject): :paramref:`text_parse_mode`. Currently, only custom emoji entities are allowed. This list is empty if the text does not contain entities. + media (:class:`telegram.InputPollOptionMedia`): Optional. Media added to the poll option. + + .. versionadded:: 22.8 """ - __slots__ = ("text", "text_entities", "text_parse_mode") + __slots__ = ("media", "text", "text_entities", "text_parse_mode") def __init__( self, text: str, text_parse_mode: ODVInput[str] = DEFAULT_NONE, text_entities: Sequence[MessageEntity] | None = None, + media: "InputPollOptionMedia | None" = None, *, api_kwargs: JSONDict | None = None, ): @@ -95,14 +228,30 @@ def __init__( self.text: str = text self.text_parse_mode: ODVInput[str] = text_parse_mode self.text_entities: tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) + self.media: InputPollOptionMedia | None = media self._id_attrs = (self.text,) self._freeze() + # tags: deprecated 22.8 @classmethod def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "InputPollOption": - """See :meth:`telegram.TelegramObject.de_json`.""" + """See :meth:`telegram.TelegramObject.de_json`. The :paramref:`media` field will + not be included for deserialization. + + .. deprecated:: 22.8 + This class is input only and will be removed in the next version. + """ + warn( + PTBDeprecationWarning( + "22.8", + "`InputPollOption.de_json` is deprecated. This class is input only and will be " + "removed in the next version. The `media` field will not be included for " + "deserialization.", + ), + stacklevel=2, + ) data = cls._parse_data(data) data["text_entities"] = de_list_optional(data.get("text_entities"), MessageEntity, bot) @@ -115,9 +264,18 @@ class PollOption(TelegramObject): This object contains information about one answer option in a poll. Objects of this class are comparable in terms of equality. Two objects of this class are - considered equal, if their :attr:`text` and :attr:`voter_count` are equal. + considered equal, if their :attr:`text`, :attr:`voter_count` and :attr:`persistent_id` + are equal. + + .. versionchanged:: 22.8 + Added attribute :attr:`persistent_id` to equality checks. + Args: + persistent_id (:obj:`str`): Unique identifier of the option, persistent on option addition + and deletion. + + .. versionadded:: 22.8 text (:obj:`str`): Option text, :tg-const:`telegram.PollOption.MIN_LENGTH`-:tg-const:`telegram.PollOption.MAX_LENGTH` characters. @@ -127,8 +285,27 @@ class PollOption(TelegramObject): poll option texts. .. versionadded:: 21.2 + media (:class:`telegram.PollMedia`, optional): Media added to the poll option. + + .. versionadded:: 22.8 + added_by_user (:class:`telegram.User`, optional): User who added the option; + omitted if the option wasn't added by a user after poll creation. + + .. versionadded:: 22.8 + added_by_chat (:class:`telegram.Chat`, optional): Chat that added the option; + omitted if the option wasn't added by a chat after poll creation. + + .. versionadded:: 22.8 + addition_date (:obj:`datetime.datetime`, optional): Point in time + when the option was added; omitted if the option existed in the original poll. + + .. versionadded:: 22.8 Attributes: + persistent_id (:obj:`str`): Unique identifier of the option, persistent on option addition + and deletion. + + .. versionadded:: 22.8 text (:obj:`str`): Option text, :tg-const:`telegram.PollOption.MIN_LENGTH`-:tg-const:`telegram.PollOption.MAX_LENGTH` characters. @@ -139,25 +316,64 @@ class PollOption(TelegramObject): This list is empty if the question does not contain entities. .. versionadded:: 21.2 + media (:class:`telegram.PollMedia`): Optional. Media added to the poll option. + .. versionadded:: 22.8 + added_by_user (:class:`telegram.User`): Optional. User who added the option; + omitted if the option wasn't added by a user after poll creation. + + .. versionadded:: 22.8 + added_by_chat (:class:`telegram.Chat`): Optional. Chat that added the option; + omitted if the option wasn't added by a chat after poll creation. + + .. versionadded:: 22.8 + addition_date (:obj:`datetime.datetime`): Optional. Point in time + when the option was added; omitted if the option existed in the original poll. + + .. versionadded:: 22.8 """ - __slots__ = ("text", "text_entities", "voter_count") + __slots__ = ( + "added_by_chat", + "added_by_user", + "addition_date", + "media", + "persistent_id", + "text", + "text_entities", + "voter_count", + ) def __init__( self, text: str, voter_count: int, text_entities: Sequence[MessageEntity] | None = None, + added_by_user: User | None = None, + added_by_chat: Chat | None = None, + addition_date: dtm.datetime | None = None, + media: PollMedia | None = None, + # tags: required in 22.8, bot api 9.6 + # temporarily optional to avoid breaking changes + persistent_id: str | None = None, *, api_kwargs: JSONDict | None = None, ): + if persistent_id is None: + raise TypeError("`persistent_id` is a required argument since Bot API 9.6") + super().__init__(api_kwargs=api_kwargs) self.text: str = text self.voter_count: int = voter_count + self.added_by_user: User | None = added_by_user + self.added_by_chat: Chat | None = added_by_chat + self.addition_date: dtm.datetime | None = addition_date + self.persistent_id: str = persistent_id + self.media: PollMedia | None = media + self.text_entities: tuple[MessageEntity, ...] = parse_sequence_arg(text_entities) - self._id_attrs = (self.text, self.voter_count) + self._id_attrs = (self.text, self.voter_count, self.persistent_id) self._freeze() @@ -166,7 +382,14 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PollOption": """See :meth:`telegram.TelegramObject.de_json`.""" data = cls._parse_data(data) + # Get the local timezone from the bot if it has defaults + loc_tzinfo = extract_tzinfo_from_defaults(bot) + data["text_entities"] = de_list_optional(data.get("text_entities"), MessageEntity, bot) + data["added_by_user"] = de_json_optional(data.get("added_by_user"), User, bot) + data["added_by_chat"] = de_json_optional(data.get("added_by_chat"), Chat, bot) + data["addition_date"] = from_timestamp(data.get("addition_date"), tzinfo=loc_tzinfo) + data["media"] = de_json_optional(data.get("media"), PollMedia, bot) return super().de_json(data=data, bot=bot) @@ -248,6 +471,10 @@ class PollAnswer(TelegramObject): .. versionchanged:: 20.0 |sequenceclassargs| + option_persistent_ids (Sequence[:obj:`str`]): Persistent identifiers of the + chosen answer options. May be empty if the vote was retracted. + + .. versionadded:: 22.8 user (:class:`telegram.User`, optional): The user that changed the answer to the poll, if the voter isn't anonymous. If the voter is anonymous, this field will contain the user :tg-const:`telegram.constants.ChatID.FAKE_CHANNEL` for backwards compatibility. @@ -266,6 +493,10 @@ class PollAnswer(TelegramObject): .. versionchanged:: 20.0 |tupleclassattrs| + option_persistent_ids (tuple[:obj:`str`]): Persistent identifiers of the + chosen answer options. May be empty if the vote was retracted. + + .. versionadded:: 22.8 user (:class:`telegram.User`): Optional. The user, who changed the answer to the poll, if the voter isn't anonymous. If the voter is anonymous, this field will contain the user :tg-const:`telegram.constants.ChatID.FAKE_CHANNEL` for backwards compatibility @@ -276,10 +507,9 @@ class PollAnswer(TelegramObject): poll, if the voter is anonymous. .. versionadded:: 20.5 - """ - __slots__ = ("option_ids", "poll_id", "user", "voter_chat") + __slots__ = ("option_ids", "option_persistent_ids", "poll_id", "user", "voter_chat") def __init__( self, @@ -287,14 +517,21 @@ def __init__( option_ids: Sequence[int], user: User | None = None, voter_chat: Chat | None = None, + # tags: required in 22.8, bot api 9.6 + # temporarily optional to avoid breaking changes + option_persistent_ids: Sequence[str] | None = None, *, api_kwargs: JSONDict | None = None, ): + if option_persistent_ids is None: + raise TypeError("`option_persistent_ids` is a required argument since Bot API 9.6") + super().__init__(api_kwargs=api_kwargs) self.poll_id: str = poll_id self.voter_chat: Chat | None = voter_chat self.option_ids: tuple[int, ...] = parse_sequence_arg(option_ids) self.user: User | None = user + self.option_persistent_ids: tuple[str, ...] = parse_sequence_arg(option_persistent_ids) self._id_attrs = ( self.poll_id, @@ -316,6 +553,236 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PollAnswer": return super().de_json(data=data, bot=bot) +class PollOptionAdded(TelegramObject): + """ + Describes a service message about an option added to a poll. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`option_persistent_id`, and :attr:`option_text` are equal. + + .. versionadded:: 22.8 + + Args: + option_persistent_id (:obj:`str`): Unique identifier of the added option. + option_text (:obj:`str`): Option text. + poll_message (:class:`telegram.MaybeInaccessibleMessage`, optional): Message + containing the poll to which the option was added, if known. + Note that the Message object in this field will not contain the + :attr:`~telegram.Message.reply_to_message` field even if it itself is a reply. + option_text_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special + entities that appear in the :paramref:`option_text`. + + Attributes: + option_persistent_id (:obj:`str`): Unique identifier of the added option. + option_text (:obj:`str`): Option text. + poll_message (:class:`telegram.MaybeInaccessibleMessage`): Optional. Message + containing the poll to which the option was added, if known. + Note that the Message object in this field will not contain the + :attr:`~telegram.Message.reply_to_message` field even if it itself is a reply. + option_text_entities (tuple[:class:`telegram.MessageEntity`]): Optional. Special + entities that appear in the :paramref:`option_text`. + """ + + __slots__ = ("option_persistent_id", "option_text", "option_text_entities", "poll_message") + + def __init__( + self, + option_persistent_id: str, + option_text: str, + poll_message: "MaybeInaccessibleMessage | None" = None, + option_text_entities: Sequence[MessageEntity] | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.option_persistent_id: str = option_persistent_id + self.option_text: str = option_text + self.poll_message: MaybeInaccessibleMessage | None = poll_message + + self.option_text_entities: tuple[MessageEntity, ...] = parse_sequence_arg( + option_text_entities + ) + + self._id_attrs = (self.option_persistent_id, self.option_text) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PollOptionAdded": + """See :meth:`telegram.TelegramObject.de_json`.""" + from telegram._message import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + MaybeInaccessibleMessage, + ) + + data = cls._parse_data(data) + + data["poll_message"] = de_json_optional( + data.get("poll_message"), MaybeInaccessibleMessage, bot + ) + data["option_text_entities"] = de_list_optional( + data.get("option_text_entities"), MessageEntity, bot + ) + + return super().de_json(data=data, bot=bot) + + def parse_option_text_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`option_text` + from a given :class:`telegram.MessageEntity` of :attr:`option_text_entities`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``Message.text`` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`option_text_entities`. + + Returns: + :obj:`str`: The text of the given entity. + """ + return parse_message_entity(self.option_text, entity) + + def parse_option_text_entities( + self, types: list[str] | None = None + ) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this polls option 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:`option_text_entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + """ + return parse_message_entities(self.option_text, self.option_text_entities, types) + + +class PollOptionDeleted(TelegramObject): + """ + Describes a service message about an option deleted from a poll. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`option_persistent_id`, :attr:`option_text` are equal. + + .. versionadded:: 22.8 + + Args: + option_persistent_id (:obj:`str`): Unique identifier of the deleted option. + option_text (:obj:`str`): Option text. + poll_message (:class:`telegram.MaybeInaccessibleMessage`, optional): Message + containing the poll to which the option was deleted, if known. + Note that the Message object in this field will not contain the + :attr:`~telegram.Message.reply_to_message` field even if it itself is a reply. + option_text_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special + entities that appear in the option_text. + + Attributes: + option_persistent_id (:obj:`str`): Unique identifier of the deleted option. + option_text (:obj:`str`): Option text. + poll_message (:class:`telegram.MaybeInaccessibleMessage`): Optional. Message + containing the poll to which the option was deleted, if known. + Note that the Message object in this field will not contain the + :attr:`~telegram.Message.reply_to_message` field even if it itself is a reply. + option_text_entities (tuple[:class:`telegram.MessageEntity`]): Optional. Special + entities that appear in the option_text. + """ + + __slots__ = ("option_persistent_id", "option_text", "option_text_entities", "poll_message") + + def __init__( + self, + option_persistent_id: str, + option_text: str, + poll_message: "MaybeInaccessibleMessage | None" = None, + option_text_entities: Sequence[MessageEntity] | None = None, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.option_persistent_id: str = option_persistent_id + self.option_text: str = option_text + self.poll_message: MaybeInaccessibleMessage | None = poll_message + + self.option_text_entities: tuple[MessageEntity, ...] = parse_sequence_arg( + option_text_entities + ) + + self._id_attrs = (self.option_persistent_id, self.option_text) + + self._freeze() + + @classmethod + def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "PollOptionDeleted": + """See :meth:`telegram.TelegramObject.de_json`.""" + from telegram._message import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + MaybeInaccessibleMessage, + ) + + data = cls._parse_data(data) + + data["poll_message"] = de_json_optional( + data.get("poll_message"), MaybeInaccessibleMessage, bot + ) + data["option_text_entities"] = de_list_optional( + data.get("option_text_entities"), MessageEntity, bot + ) + + return super().de_json(data=data, bot=bot) + + def parse_option_text_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`option_text` + from a given :class:`telegram.MessageEntity` of :attr:`option_text_entities`. + + Note: + This method is present because Telegram calculates the offset and length in + UTF-16 codepoint pairs, which some versions of Python don't handle automatically. + (That is, you can't just slice ``Message.text`` with the offset and length.) + + Args: + entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must + be an entity that belongs to :attr:`option_text_entities`. + + Returns: + :obj:`str`: The text of the given entity. + """ + return parse_message_entity(self.option_text, entity) + + def parse_option_text_entities( + self, types: list[str] | None = None + ) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this polls option 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:`option_text_entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to + the text that belongs to them, calculated based on UTF-16 codepoints. + """ + return parse_message_entities(self.option_text, self.option_text_entities, types) + + class Poll(TelegramObject): """ This object contains information about a poll. @@ -338,9 +805,17 @@ class Poll(TelegramObject): is_anonymous (:obj:`bool`): :obj:`True`, if the poll is anonymous. type (:obj:`str`): Poll type, currently can be :attr:`REGULAR` or :attr:`QUIZ`. allows_multiple_answers (:obj:`bool`): :obj:`True`, if the poll allows multiple answers. + members_only (:obj:`bool`): :obj:`True`, if voting is limited to users who have been + members of the chat where the poll was originally sent for more than + :tg-const:`telegram.Poll.MIN_MEMBERSHIP_HOURS` hours. + + .. versionadded:: 22.8 correct_option_id (:obj:`int`, optional): A zero based identifier of the correct answer option. Available only for closed polls in the quiz mode, which were sent (not forwarded), by the bot or to a private chat with the bot. + + .. deprecated:: 22.8 + Use :paramref:`correct_option_ids` instead. explanation (:obj:`str`, optional): Text that is shown when a user chooses an incorrect answer or taps on the lamp icon in a quiz-style poll, 0-:tg-const:`telegram.Poll.MAX_EXPLANATION_LENGTH` characters. @@ -353,6 +828,10 @@ class Poll(TelegramObject): * This attribute is now always a (possibly empty) list and never :obj:`None`. * |sequenceclassargs| + explanation_media (:class:`telegram.PollMedia`, optional): Media added to the quiz + explanation. + + .. versionadded:: 22.8 open_period (:obj:`int` | :class:`datetime.timedelta`, optional): Amount of time in seconds the poll will be active after creation. @@ -368,6 +847,33 @@ class Poll(TelegramObject): in poll questions. .. versionadded:: 21.2 + allows_revoting (:obj:`bool`, optional): :obj:`True`, if the poll allows to + change the chosenanswer options. + + .. versionadded:: 22.8 + correct_option_ids (Sequence[:class:`int`], optional): Array of 0-based identifiers of + the correct answer options. Available only for polls in quiz mode which are closed or + were sent (not forwarded) by the bot or to the private chat with the bot. + + .. versionadded:: 22.8 + country_codes (Sequence[:obj:`str`], optional): A list of two-letter ``ISO 3166-1 alpha-2`` + country codes indicating the countries from which users can vote in the poll. The + country code ``"FT"`` is used for users with anonymous numbers. If omitted, then users + from any country can participate in the poll. + + .. versionadded:: 22.8 + description (:obj:`str`, optional): Description of the poll; + for polls inside the :class:`~telegram.Message` object only. + + .. versionadded:: 22.8 + description_entities (Sequence[:class:`telegram.MessageEntity`], optional): Special + entities like usernames, URLs, bot commands, etc. that appear in the description + + .. versionadded:: 22.8 + media (:class:`telegram.PollMedia`, optional): Media added to the poll description; + for polls inside the :class:`~telegram.Message` object only. + + .. versionadded:: 22.8 Attributes: id (:obj:`str`): Unique poll identifier. @@ -382,9 +888,11 @@ class Poll(TelegramObject): is_anonymous (:obj:`bool`): :obj:`True`, if the poll is anonymous. type (:obj:`str`): Poll type, currently can be :attr:`REGULAR` or :attr:`QUIZ`. allows_multiple_answers (:obj:`bool`): :obj:`True`, if the poll allows multiple answers. - correct_option_id (:obj:`int`): Optional. A zero based identifier of the correct answer - option. Available only for closed polls in the quiz mode, which were sent - (not forwarded), by the bot or to a private chat with the bot. + members_only (:obj:`bool`): :obj:`True`, if voting is limited to users who have been + members of the chat where the poll was originally sent for more than + :tg-const:`telegram.Poll.MIN_MEMBERSHIP_HOURS` hours. + + .. versionadded:: 22.8 explanation (:obj:`str`): Optional. Text that is shown when a user chooses an incorrect answer or taps on the lamp icon in a quiz-style poll, 0-:tg-const:`telegram.Poll.MAX_EXPLANATION_LENGTH` characters. @@ -397,6 +905,10 @@ class Poll(TelegramObject): .. versionchanged:: 20.0 This attribute is now always a (possibly empty) list and never :obj:`None`. + explanation_media (:class:`telegram.PollMedia`): Optional. Media added to the quiz + explanation. + + .. versionadded:: 22.8 open_period (:obj:`int` | :class:`datetime.timedelta`): Optional. Amount of time in seconds the poll will be active after creation. @@ -413,19 +925,53 @@ class Poll(TelegramObject): This list is empty if the question does not contain entities. .. versionadded:: 21.2 + allows_revoting (:obj:`bool`): :obj:`True`, if the poll + allows to change the chosenanswer options + + .. versionadded:: 22.8 + correct_option_ids (tuple[:class:`int`]): Array of 0-based identifiers of the + correct answer options. Available only for polls in quiz mode which are closed or were + sent (not forwarded) by the bot or to the private chat with the bot. + + .. versionadded:: 22.8 + country_codes (tuple[:obj:`str`]): Optional. A list of two-letter ``ISO 3166-1 alpha-2`` + country codes indicating the countries from which users can vote in the poll. The + country code ``"FT"`` is used for users with anonymous numbers. If omitted, then users + from any country can participate in the poll. + + .. versionadded:: 22.8 + description (:obj:`str`): Optional. Description of the poll; + for polls inside the Message object only + + .. versionadded:: 22.8 + description_entities (tuple[:class:`telegram.MessageEntity`]): Special + entities like usernames, URLs, bot commands, etc. that appear in the description + + .. versionadded:: 22.8 + media (:class:`telegram.PollMedia`): Optional. Media added to the poll description; + for polls inside the Message object only. + + .. versionadded:: 22.8 """ __slots__ = ( "_open_period", "allows_multiple_answers", + "allows_revoting", "close_date", - "correct_option_id", + "correct_option_ids", + "country_codes", + "description", + "description_entities", "explanation", "explanation_entities", + "explanation_media", "id", "is_anonymous", "is_closed", + "media", + "members_only", "options", "question", "question_entities", @@ -443,15 +989,35 @@ def __init__( is_anonymous: bool, type: str, # pylint: disable=redefined-builtin allows_multiple_answers: bool, + # tags: deprecated 22.8 + # Removed in bot api 9.6: correct_option_id: int | None = None, + # --- explanation: str | None = None, explanation_entities: Sequence[MessageEntity] | None = None, open_period: TimePeriod | None = None, close_date: dtm.datetime | None = None, question_entities: Sequence[MessageEntity] | None = None, + # tags: required in 22.8 + # temporarily optional to avoid breaking changes + allows_revoting: bool | None = None, + members_only: bool | None = None, + # --- + correct_option_ids: Sequence[int] | None = None, + description: str | None = None, + description_entities: Sequence[MessageEntity] | None = None, + country_codes: Sequence[str] | None = None, + media: PollMedia | None = None, + explanation_media: PollMedia | None = None, *, api_kwargs: JSONDict | None = None, ): + if allows_revoting is None: + raise TypeError("`allows_revoting` is a required argument since Bot API 9.6") + + if members_only is None: + raise TypeError("`members_only` is a required argument since Bot API 10.0") + super().__init__(api_kwargs=api_kwargs) self.id: str = id self.question: str = question @@ -461,7 +1027,27 @@ def __init__( self.is_anonymous: bool = is_anonymous self.type: str = enum.get_member(constants.PollType, type, type) self.allows_multiple_answers: bool = allows_multiple_answers - self.correct_option_id: int | None = correct_option_id + self.allows_revoting: bool = allows_revoting + self.members_only: bool = members_only + + # tag: deprecated 22.8 + if correct_option_id is not None: + warn( + PTBDeprecationWarning( + "22.8", + "The parameter `correct_option_id` is deprecated. " + "Use `correct_option_ids` instead.", + ), + stacklevel=2, + ) + if correct_option_ids is None: + correct_option_ids = [correct_option_id] + + self.correct_option_ids: tuple[int, ...] = parse_sequence_arg(correct_option_ids) + self.description: str | None = description + self.description_entities: tuple[MessageEntity, ...] = parse_sequence_arg( + description_entities + ) self.explanation: str | None = explanation self.explanation_entities: tuple[MessageEntity, ...] = parse_sequence_arg( explanation_entities @@ -469,6 +1055,9 @@ def __init__( self._open_period: dtm.timedelta | None = to_timedelta(open_period) self.close_date: dtm.datetime | None = close_date self.question_entities: tuple[MessageEntity, ...] = parse_sequence_arg(question_entities) + self.country_codes: tuple[str, ...] = parse_sequence_arg(country_codes) + self.media: PollMedia | None = media + self.explanation_media: PollMedia | None = explanation_media self._id_attrs = (self.id,) @@ -494,6 +1083,11 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Poll": data["question_entities"] = de_list_optional( data.get("question_entities"), MessageEntity, bot ) + data["description_entities"] = de_list_optional( + data.get("description_entities"), MessageEntity, bot + ) + data["media"] = de_json_optional(data.get("media"), PollMedia, bot) + data["explanation_media"] = de_json_optional(data.get("explanation_media"), PollMedia, bot) return super().de_json(data=data, bot=bot) @@ -598,6 +1192,82 @@ def parse_question_entities(self, types: list[str] | None = None) -> dict[Messag """ return parse_message_entities(self.question, self.question_entities, types) + def parse_description_entity(self, entity: MessageEntity) -> str: + """Returns the text in :attr:`description` from a given :class:`telegram.MessageEntity` of + :attr:`description_entities`. + + .. versionadded:: 22.8 + + 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:`description_entities`. + + Returns: + :obj:`str`: The text of the given entity. + + Raises: + RuntimeError: If the poll has no description. + + """ + if not self.description: + raise RuntimeError("This Poll has no 'description'.") + + return parse_message_entity(self.description, entity) + + def parse_description_entities( + self, types: list[str] | None = None + ) -> dict[MessageEntity, str]: + """ + Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`. + It contains entities from this polls description filtered by their ``type`` attribute as + the key, and the text that each entity belongs to as the value of the :obj:`dict`. + + .. versionadded:: 22.8 + + Note: + This method should always be used instead of the :attr:`description_entities` + attribute, since it calculates the correct substring from the message text based on + UTF-16 codepoints. See :attr:`parse_description_entity` for more info. + + Args: + types (list[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the + ``type`` attribute of an entity is contained in this list, it will be returned. + Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`. + Returns: + dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities + mapped to the text that belongs to them, calculated based on UTF-16 codepoints. + Raises: + RuntimeError: If the poll has no description. + """ + if not self.description: + raise RuntimeError("This Poll has no 'description'.") + + return parse_message_entities(self.description, self.description_entities, types) + + @property + def correct_option_id(self) -> int | None: + """A zero based identifier of the correct answer + option. Available only for closed polls in the quiz mode, which were sent + (not forwarded), by the bot or to a private chat with the bot. + + .. deprecated:: 22.8 + Use :attr:`correct_option_ids` instead. + """ + warn( + PTBDeprecationWarning( + "22.8", + "The attribute `correct_option_id` is deprecated. " + "Use `correct_option_ids` instead.", + ), + stacklevel=2, + ) + return self.correct_option_ids[0] if self.correct_option_ids else None + REGULAR: Final[str] = constants.PollType.REGULAR """:const:`telegram.constants.PollType.REGULAR`""" QUIZ: Final[str] = constants.PollType.QUIZ @@ -652,3 +1322,13 @@ def parse_question_entities(self, types: list[str] | None = None) -> dict[Messag .. versionadded:: 20.0 """ + MAX_DESCRIPTION_CHARACTERS: Final[int] = constants.PollLimit.MAX_DESCRIPTION_CHARACTERS + """:const:`telegram.constants.PollLimit.MAX_DESCRIPTION_CHARACTERS` + + .. versionadded:: 22.8 + """ + MIN_MEMBERSHIP_HOURS: Final[int] = constants.PollLimit.MIN_MEMBERSHIP_HOURS + """:const:`telegram.constants.PollLimit.MIN_MEMBERSHIP_HOURS` + + .. versionadded:: 22.8 + """ diff --git a/src/telegram/_preparedkeyboardbutton.py b/src/telegram/_preparedkeyboardbutton.py new file mode 100644 index 00000000000..cd83f6b0dd7 --- /dev/null +++ b/src/telegram/_preparedkeyboardbutton.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that represents a Telegram PreparedKeyboardButton.""" + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import ( + JSONDict, +) + + +class PreparedKeyboardButton(TelegramObject): + """ + Describes a keyboard button to be used by a user of a Mini App. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`id` is equal. + + .. versionadded:: 22.8 + + Args: + id (:obj:`str`): Unique identifier of the keyboard button. + + Attributes: + id (:obj:`str`): Unique identifier of the keyboard button. + """ + + __slots__ = ("id",) + + def __init__( + self, + id: str, # pylint: disable=redefined-builtin + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + self.id: str = id + self._id_attrs = (self.id,) + self._freeze() diff --git a/src/telegram/_reply.py b/src/telegram/_reply.py index 367d5aad7a0..bbf0f6d9010 100644 --- a/src/telegram/_reply.py +++ b/src/telegram/_reply.py @@ -28,6 +28,7 @@ from telegram._files.audio import Audio from telegram._files.contact import Contact from telegram._files.document import Document +from telegram._files.livephoto import LivePhoto from telegram._files.location import Location from telegram._files.photosize import PhotoSize from telegram._files.sticker import Sticker @@ -114,6 +115,10 @@ class ExternalReplyInfo(TelegramObject): information about the paid media. .. versionadded:: 21.4 + live_photo (:class:`telegram.LivePhoto`, optional): Message is a live photo, information + about the live photo. + + .. versionadded:: 22.8 Attributes: origin (:class:`telegram.MessageOrigin`): Origin of the message replied to by the given @@ -166,6 +171,11 @@ class ExternalReplyInfo(TelegramObject): information about the paid media. .. versionadded:: 21.4 + live_photo (:class:`telegram.LivePhoto`): Optional. Message is a live photo, information + about the live photo. + + .. versionadded:: 22.8 + """ __slots__ = ( @@ -182,6 +192,7 @@ class ExternalReplyInfo(TelegramObject): "has_media_spoiler", "invoice", "link_preview_options", + "live_photo", "location", "message_id", "origin", @@ -223,6 +234,7 @@ def __init__( venue: Venue | None = None, paid_media: PaidMediaInfo | None = None, checklist: Checklist | None = None, + live_photo: LivePhoto | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -253,6 +265,7 @@ def __init__( self.poll: Poll | None = poll self.venue: Venue | None = venue self.paid_media: PaidMediaInfo | None = paid_media + self.live_photo: LivePhoto | None = live_photo self._id_attrs = (self.origin,) @@ -290,6 +303,7 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "ExternalReplyInfo data["venue"] = de_json_optional(data.get("venue"), Venue, bot) data["paid_media"] = de_json_optional(data.get("paid_media"), PaidMediaInfo, bot) data["checklist"] = de_json_optional(data.get("checklist"), Checklist, bot) + data["live_photo"] = de_json_optional(data.get("live_photo"), LivePhoto, bot) return super().de_json(data=data, bot=bot) @@ -311,8 +325,8 @@ class TextQuote(TelegramObject): units as specified by the sender. entities (Sequence[:class:`telegram.MessageEntity`], optional): Special entities that appear - in the quote. Currently, only bold, italic, underline, strikethrough, spoiler, and - custom_emoji entities are kept in quotes. + in the quote. Currently, only bold, italic, underline, strikethrough, spoiler, + custom_emoji, and date_time entities are kept in quotes. is_manual (:obj:`bool`, optional): :obj:`True`, if the quote was chosen manually by the message sender. Otherwise, the quote was added automatically by the server. @@ -322,8 +336,8 @@ class TextQuote(TelegramObject): position (:obj:`int`): Approximate quote position in the original message in UTF-16 code units as specified by the sender. entities (tuple[:class:`telegram.MessageEntity`]): Optional. Special entities that appear - in the quote. Currently, only bold, italic, underline, strikethrough, spoiler, and - custom_emoji entities are kept in quotes. + in the quote. Currently, only bold, italic, underline, strikethrough, spoiler, + custom_emoji, and date_time entities are kept in quotes. is_manual (:obj:`bool`): Optional. :obj:`True`, if the quote was chosen manually by the message sender. Otherwise, the quote was added automatically by the server. """ @@ -394,9 +408,9 @@ class ReplyParameters(TelegramObject): used only for replies in the same chat and forum topic. quote (:obj:`str`, optional): Quoted part of the message to be replied to; 0-1024 characters after entities parsing. The quote must be an exact substring of the message - to be replied to, including bold, italic, underline, strikethrough, spoiler, and - custom_emoji entities. The message will fail to send if the quote isn't found in the - original message. + to be replied to, including bold, italic, underline, strikethrough, spoiler, + custom_emoji, and date_time entities. The message will fail to send if the quote isn't + found in the original message. quote_parse_mode (:obj:`str`, optional): Mode for parsing entities in the quote. See :wiki:`formatting options ` for more details. @@ -410,6 +424,10 @@ class ReplyParameters(TelegramObject): replied to. .. versionadded:: 22.4 + poll_option_id (:obj:`str`, optional): Persistent + identifier of the specific poll option to be replied to. + + .. versionadded:: 22.8 Attributes: message_id (:obj:`int`): Identifier of the message that will be replied to in the current @@ -422,9 +440,9 @@ class ReplyParameters(TelegramObject): used only for replies in the same chat and forum topic. quote (:obj:`str`): Optional. Quoted part of the message to be replied to; 0-1024 characters after entities parsing. The quote must be an exact substring of the message - to be replied to, including bold, italic, underline, strikethrough, spoiler, and - custom_emoji entities. The message will fail to send if the quote isn't found in the - original message. + to be replied to, including bold, italic, underline, strikethrough, spoiler, + custom_emoji, and date_time entities. The message will fail to send if the quote isn't + found in the original message. quote_parse_mode (:obj:`str`): Optional. Mode for parsing entities in the quote. See :wiki:`formatting options ` for more details. @@ -437,6 +455,10 @@ class ReplyParameters(TelegramObject): replied to. .. versionadded:: 22.4 + poll_option_id (:obj:`str`): Optional. Persistent + identifier of the specific poll option to be replied to. + + .. versionadded:: 22.8 """ __slots__ = ( @@ -444,6 +466,7 @@ class ReplyParameters(TelegramObject): "chat_id", "checklist_task_id", "message_id", + "poll_option_id", "quote", "quote_entities", "quote_parse_mode", @@ -460,6 +483,7 @@ def __init__( quote_entities: Sequence[MessageEntity] | None = None, quote_position: int | None = None, checklist_task_id: int | None = None, + poll_option_id: str | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -473,6 +497,7 @@ def __init__( self.quote_entities: tuple[MessageEntity, ...] | None = parse_sequence_arg(quote_entities) self.quote_position: int | None = quote_position self.checklist_task_id: int | None = checklist_task_id + self.poll_option_id: str | None = poll_option_id self._id_attrs = (self.message_id,) diff --git a/src/telegram/_sentguestmessage.py b/src/telegram/_sentguestmessage.py new file mode 100644 index 00000000000..daea15ebdee --- /dev/null +++ b/src/telegram/_sentguestmessage.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that represents a Telegram Sent Guest Message.""" + +from telegram._telegramobject import TelegramObject +from telegram._utils.types import JSONDict + + +class SentGuestMessage(TelegramObject): + """Describes an inline message sent by a guest bot. + + Objects of this class are comparable in terms of equality. Two objects of this class are + considered equal, if their :attr:`inline_message_id` are equal. + + .. versionadded:: 22.8 + + Args: + inline_message_id (:obj:`str`): Identifier of the sent inline message. + + Attributes: + inline_message_id (:obj:`str`): Identifier of the sent inline message. + """ + + __slots__ = ("inline_message_id",) + + def __init__( + self, + inline_message_id: str, + *, + api_kwargs: JSONDict | None = None, + ): + super().__init__(api_kwargs=api_kwargs) + # Required + self.inline_message_id: str = inline_message_id + + self._id_attrs = (self.inline_message_id,) + + self._freeze() diff --git a/src/telegram/_telegramobject.py b/src/telegram/_telegramobject.py index 4098c75c37e..ede04a033d8 100644 --- a/src/telegram/_telegramobject.py +++ b/src/telegram/_telegramobject.py @@ -154,7 +154,7 @@ def __setattr__(self, key: str, value: object) -> None: :exc:`AttributeError` """ # protected attributes can always be set for convenient internal use - if key[0] == "_" or not getattr(self, "_frozen", True): + if key[0] == "_" or not self._frozen: super().__setattr__(key, value) return @@ -169,7 +169,7 @@ def __delattr__(self, key: str) -> None: :exc:`AttributeError` """ # protected attributes can always be set for convenient internal use - if key[0] == "_" or not getattr(self, "_frozen", True): + if key[0] == "_" or not self._frozen: super().__delattr__(key) return diff --git a/src/telegram/_update.py b/src/telegram/_update.py index 9e018e4cb0f..1af292cce8d 100644 --- a/src/telegram/_update.py +++ b/src/telegram/_update.py @@ -28,6 +28,7 @@ from telegram._chatmemberupdated import ChatMemberUpdated from telegram._choseninlineresult import ChosenInlineResult from telegram._inline.inlinequery import InlineQuery +from telegram._managedbot import ManagedBotUpdated from telegram._message import Message from telegram._messagereactionupdated import MessageReactionCountUpdated, MessageReactionUpdated from telegram._paidmedia import PaidMediaPurchased @@ -163,6 +164,16 @@ class Update(TelegramObject): .. versionadded:: 21.6 + managed_bot (:class:`telegram.ManagedBotUpdated`, optional): A new bot was created to be + managed by the bot, or token or owner of a managed bot was changed. + + .. versionadded:: 22.8 + guest_message (:class:`telegram.Message`, optional): New guest message. The bot can use + the field :attr:`telegram.Message.guest_query_id` and the method + :meth:`telegram.Bot.answer_guest_query` to send a message in response. + + .. versionadded:: 22.8 + Attributes: update_id (:obj:`int`): The update's unique identifier. Update identifiers start from a @@ -275,6 +286,15 @@ class Update(TelegramObject): paid media with a non-empty payload sent by the bot in a non-channel chat. .. versionadded:: 21.6 + managed_bot (:class:`telegram.ManagedBotUpdated`): Optional. A new bot was created to be + managed by the bot, or token or owner of a managed bot was changed. + + .. versionadded:: 22.8 + guest_message (:class:`telegram.Message`): Optional. New guest message. The bot can use + the field :attr:`telegram.Message.guest_query_id` and the method + :meth:`telegram.Bot.answerGuestQuery` to send a message in response. + + .. versionadded:: 22.8 """ __slots__ = ( @@ -294,7 +314,9 @@ class Update(TelegramObject): "edited_business_message", "edited_channel_post", "edited_message", + "guest_message", "inline_query", + "managed_bot", "message", "message_reaction", "message_reaction_count", @@ -403,6 +425,17 @@ class Update(TelegramObject): .. versionadded:: 21.6 """ + MANAGED_BOT: Final[str] = constants.UpdateType.MANAGED_BOT + """:const:`telegram.constants.UpdateType.MANAGED_BOT` + + .. versionadded:: 22.8 + """ + GUEST_MESSAGE: Final[str] = constants.UpdateType.GUEST_MESSAGE + """:const:`telegram.constants.UpdateType.GUEST_MESSAGE` + + .. versionadded:: 22.8 + """ + ALL_TYPES: Final[list[str]] = list(constants.UpdateType) """list[:obj:`str`]: A list of all available update types. @@ -434,6 +467,8 @@ def __init__( edited_business_message: Message | None = None, deleted_business_messages: BusinessMessagesDeleted | None = None, purchased_paid_media: PaidMediaPurchased | None = None, + managed_bot: ManagedBotUpdated | None = None, + guest_message: Message | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -464,6 +499,8 @@ def __init__( self.edited_business_message: Message | None = edited_business_message self.deleted_business_messages: BusinessMessagesDeleted | None = deleted_business_messages self.purchased_paid_media: PaidMediaPurchased | None = purchased_paid_media + self.managed_bot: ManagedBotUpdated | None = managed_bot + self.guest_message: Message | None = guest_message self._effective_user: User | None = None self._effective_sender: User | Chat | None = None @@ -481,8 +518,6 @@ def effective_user(self) -> "User | None": is. If no user is associated with this update, this gives :obj:`None`. This is the case if any of - * :attr:`channel_post` - * :attr:`edited_channel_post` * :attr:`poll` * :attr:`chat_boost` * :attr:`removed_chat_boost` @@ -498,6 +533,10 @@ def effective_user(self) -> "User | None": .. versionchanged:: 21.6 This property now also considers :attr:`purchased_paid_media`. + .. versionchanged:: 22.8 + This property now also considers :attr:`managed_bot`, :attr:`guest_message`, + :attr:`channel_post`, and :attr:`edited_channel_post`. + Example: * If :attr:`message` is present, this will give :attr:`telegram.Message.from_user`. @@ -509,11 +548,20 @@ def effective_user(self) -> "User | None": user = None - if self.message: - user = self.message.from_user + if message := ( + self.message + or self.edited_message + or self.business_message + or self.edited_business_message + or self.guest_message + ): + user = message.from_user - elif self.edited_message: - user = self.edited_message.from_user + elif self.channel_post: + user = self.channel_post.from_user + + elif self.edited_channel_post: + user = self.edited_channel_post.from_user elif self.inline_query: user = self.inline_query.from_user @@ -545,18 +593,15 @@ def effective_user(self) -> "User | None": elif self.message_reaction: user = self.message_reaction.user - elif self.business_message: - user = self.business_message.from_user - - elif self.edited_business_message: - user = self.edited_business_message.from_user - elif self.business_connection: user = self.business_connection.user elif self.purchased_paid_media: user = self.purchased_paid_media.from_user + elif self.managed_bot: + user = self.managed_bot.user + self._effective_user = user return user @@ -581,6 +626,9 @@ def effective_sender(self) -> "User | Chat | None": is present. + .. versionchanged:: 22.8 + This property now also considers :attr:`guest_message`. + Example: * If :attr:`message` is present, this will give either :attr:`telegram.Message.from_user` or :attr:`telegram.Message.sender_chat`. @@ -603,6 +651,7 @@ def effective_sender(self) -> "User | Chat | None": or self.edited_channel_post or self.business_message or self.edited_business_message + or self.guest_message ): sender = message.sender_chat @@ -627,13 +676,16 @@ def effective_chat(self) -> "Chat | None": This is the case, if :attr:`inline_query`, :attr:`chosen_inline_result`, :attr:`callback_query` from inline messages, :attr:`shipping_query`, :attr:`pre_checkout_query`, :attr:`poll`, - :attr:`poll_answer`, :attr:`business_connection`, or :attr:`purchased_paid_media` - is present. + :attr:`poll_answer`, :attr:`business_connection`, :attr:`purchased_paid_media`, + or :attr:`managed_bot` is present. .. versionchanged:: 21.1 This property now also considers :attr:`business_message`, :attr:`edited_business_message`, and :attr:`deleted_business_messages`. + .. versionchanged:: 22.8 + This property now also considers :attr:`guest_message`. + Example: If :attr:`message` is present, this will give :attr:`telegram.Message.chat`. @@ -643,21 +695,21 @@ def effective_chat(self) -> "Chat | None": chat = None - if self.message: - chat = self.message.chat - - elif self.edited_message: - chat = self.edited_message.chat + if message := ( + self.message + or self.edited_message + or self.channel_post + or self.edited_channel_post + or self.business_message + or self.edited_business_message + or self.deleted_business_messages + or self.guest_message + ): + chat = message.chat elif self.callback_query and self.callback_query.message: chat = self.callback_query.message.chat - elif self.channel_post: - chat = self.channel_post.chat - - elif self.edited_channel_post: - chat = self.edited_channel_post.chat - elif self.my_chat_member: chat = self.my_chat_member.chat @@ -679,15 +731,6 @@ def effective_chat(self) -> "Chat | None": elif self.message_reaction_count: chat = self.message_reaction_count.chat - elif self.business_message: - chat = self.business_message.chat - - elif self.edited_business_message: - chat = self.edited_business_message.chat - - elif self.deleted_business_messages: - chat = self.deleted_business_messages.chat - self._effective_chat = chat return chat @@ -704,6 +747,9 @@ def effective_message(self) -> Message | None: This property now also considers :attr:`business_message`, and :attr:`edited_business_message`. + .. versionchanged:: 22.8 + This property now also considers :attr:`guest_message`. + Tip: This property will only ever return objects of type :class:`telegram.Message` or :obj:`None`, never :class:`telegram.MaybeInaccessibleMessage` or @@ -752,6 +798,9 @@ def effective_message(self) -> Message | None: elif self.edited_business_message: message = self.edited_business_message + elif self.guest_message: + message = self.guest_message + self._effective_message = message return message @@ -807,5 +856,7 @@ def de_json(cls, data: JSONDict, bot: "Bot | None" = None) -> "Update": data["purchased_paid_media"] = de_json_optional( data.get("purchased_paid_media"), PaidMediaPurchased, bot ) + data["managed_bot"] = de_json_optional(data.get("managed_bot"), ManagedBotUpdated, bot) + data["guest_message"] = de_json_optional(data.get("guest_message"), Message, bot) return super().de_json(data=data, bot=bot) diff --git a/src/telegram/_user.py b/src/telegram/_user.py index 89dae50d147..eb95639da8c 100644 --- a/src/telegram/_user.py +++ b/src/telegram/_user.py @@ -29,6 +29,7 @@ from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import ( CorrectOptionID, + CorrectOptionIds, JSONDict, ODVInput, TimePeriod, @@ -41,17 +42,21 @@ from telegram import ( Animation, Audio, + BotAccessSettings, Contact, Document, Gift, InlineKeyboardMarkup, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, + InputPollMedia, InputPollOption, LabeledPrice, LinkPreviewOptions, + LivePhoto, Location, Message, MessageEntity, @@ -107,7 +112,7 @@ class User(TelegramObject): .. versionadded:: 20.0 can_connect_to_business (:obj:`bool`, optional): :obj:`True`, if the bot can be connected - to a Telegram Business account to receive its messages. Returned only in + to a user account to manage it. Returned only in :meth:`telegram.Bot.get_me`. .. versionadded:: 21.1 @@ -124,6 +129,15 @@ class User(TelegramObject): :meth:`telegram.Bot.get_me`. .. versionadded:: 22.7 + can_manage_bots (:obj:`bool`, optional): :obj:`True`, if other bots can be created to be + controlled by the bot. Returned only in :meth:`telegram.Bot.get_me`. + + .. versionadded:: 22.8 + supports_guest_queries (:obj:`bool`, optional): :obj:`True`, if the bot supports guest + queries from chats it is not a member of. Returned only in + :meth:`telegram.Bot.get_me`. + + .. versionadded:: 22.8 Attributes: id (:obj:`int`): Unique identifier for this user or bot. @@ -164,6 +178,15 @@ class User(TelegramObject): :meth:`telegram.Bot.get_me`. .. versionadded:: 22.7 + can_manage_bots (:obj:`bool`): Optional. :obj:`True`, if other bots can be created to be + controlled by the bot. Returned only in :meth:`telegram.Bot.get_me`. + + .. versionadded:: 22.8 + supports_guest_queries (:obj:`bool`): Optional. :obj:`True`, if the bot supports guest + queries from chats it is not a member of. Returned only in + :meth:`telegram.Bot.get_me`. + + .. versionadded:: 22.8 .. |user_chat_id_note| replace:: This shortcuts build on the assumption that :attr:`User.id` coincides with the :attr:`Chat.id` of the private chat with the user. This has been the @@ -175,6 +198,7 @@ class User(TelegramObject): "allows_users_to_create_topics", "can_connect_to_business", "can_join_groups", + "can_manage_bots", "can_read_all_group_messages", "first_name", "has_main_web_app", @@ -184,6 +208,7 @@ class User(TelegramObject): "is_premium", "language_code", "last_name", + "supports_guest_queries", "supports_inline_queries", "username", ) @@ -205,6 +230,8 @@ def __init__( has_main_web_app: bool | None = None, has_topics_enabled: bool | None = None, allows_users_to_create_topics: bool | None = None, + can_manage_bots: bool | None = None, + supports_guest_queries: bool | None = None, *, api_kwargs: JSONDict | None = None, ): @@ -226,6 +253,8 @@ def __init__( self.has_main_web_app: bool | None = has_main_web_app self.has_topics_enabled: bool | None = has_topics_enabled self.allows_users_to_create_topics: bool | None = allows_users_to_create_topics + self.can_manage_bots: bool | None = can_manage_bots + self.supports_guest_queries: bool | None = supports_guest_queries self._id_attrs = (self.id,) @@ -516,7 +545,7 @@ async def send_message( async def send_message_draft( self, draft_id: int, - text: str, + text: str | None = None, message_thread_id: int | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, entities: Sequence["MessageEntity"] | None = None, @@ -538,6 +567,9 @@ async def send_message_draft( .. versionadded:: 22.6 + .. versionchanged:: 22.8 + Bot API 10.0 makes the ``text`` argument optional. + Returns: :obj:`bool`: On success, :obj:`True` is returned. @@ -689,10 +721,83 @@ async def send_photo( suggested_post_parameters=suggested_post_parameters, ) + async def send_live_photo( + self, + live_photo: "FileInput | LivePhoto", + photo: "FileInput | PhotoSize", + business_connection_id: str | None = None, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + has_spoiler: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + allow_paid_broadcast: bool | None = None, + message_effect_id: str | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + filename: str | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "Message": + """Shortcut for:: + + await bot.send_live_photo(update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see :meth:`telegram.Bot.send_live_photo`. + + .. versionadded:: 22.8 + + Note: + |user_chat_id_note| + + Returns: + :class:`telegram.Message`: On success, instance representing the message posted. + + """ + return await self.get_bot().send_live_photo( + chat_id=self.id, + live_photo=live_photo, + photo=photo, + business_connection_id=business_connection_id, + message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + show_caption_above_media=show_caption_above_media, + has_spoiler=has_spoiler, + disable_notification=disable_notification, + protect_content=protect_content, + allow_paid_broadcast=allow_paid_broadcast, + message_effect_id=message_effect_id, + suggested_post_parameters=suggested_post_parameters, + reply_parameters=reply_parameters, + reply_markup=reply_markup, + allow_sending_without_reply=allow_sending_without_reply, + reply_to_message_id=reply_to_message_id, + filename=filename, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + async def send_media_group( self, media: Sequence[ - "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | InputMediaLivePhoto" # noqa: E501 # pylint: disable=line-too-long ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, @@ -1730,6 +1835,18 @@ async def send_poll( question_entities: Sequence["MessageEntity"] | None = None, message_effect_id: str | None = None, allow_paid_broadcast: bool | None = None, + shuffle_options: bool | None = None, + allows_revoting: bool | None = None, + correct_option_ids: CorrectOptionIds | None = None, + allow_adding_options: bool | None = None, + hide_results_until_closes: bool | None = None, + description: str | None = None, + description_parse_mode: ODVInput[str] | None = None, + description_entities: Sequence["MessageEntity"] | None = None, + members_only: bool | None = None, + country_codes: Sequence[str] | None = None, + explanation_media: "InputPollMedia | None" = None, + media: "InputPollMedia | None" = None, *, reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, @@ -1760,6 +1877,9 @@ async def send_poll( type=type, # pylint=pylint, allows_multiple_answers=allows_multiple_answers, correct_option_id=correct_option_id, + allows_revoting=allows_revoting, + shuffle_options=shuffle_options, + correct_option_ids=correct_option_ids, is_closed=is_closed, disable_notification=disable_notification, reply_to_message_id=reply_to_message_id, @@ -1769,6 +1889,8 @@ async def send_poll( write_timeout=write_timeout, connect_timeout=connect_timeout, pool_timeout=pool_timeout, + message_effect_id=message_effect_id, + allow_paid_broadcast=allow_paid_broadcast, explanation=explanation, explanation_parse_mode=explanation_parse_mode, open_period=open_period, @@ -1781,8 +1903,15 @@ async def send_poll( business_connection_id=business_connection_id, question_parse_mode=question_parse_mode, question_entities=question_entities, - message_effect_id=message_effect_id, - allow_paid_broadcast=allow_paid_broadcast, + description=description, + description_parse_mode=description_parse_mode, + description_entities=description_entities, + hide_results_until_closes=hide_results_until_closes, + allow_adding_options=allow_adding_options, + members_only=members_only, + country_codes=country_codes, + explanation_media=explanation_media, + media=media, ) async def send_gift( @@ -2712,3 +2841,227 @@ async def set_chat_member_tag( pool_timeout=pool_timeout, api_kwargs=api_kwargs, ) + + async def replace_token( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> str: + """ + Shortcut for:: + + await bot.replace_managed_bot_token(user_id=update.effective_user.id, *args, **kwargs) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.replace_managed_bot_token`. + + .. versionadded:: 22.8 + + Returns: + :obj:`str`: On success, :obj:`str` is returned. + """ + return await self.get_bot().replace_managed_bot_token( + user_id=self.id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def get_managed_bot_access_settings( + self, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> "BotAccessSettings": + """ + Shortcut for:: + + await bot.get_managed_bot_access_settings( + user_id=update.effective_user.id, + *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.get_managed_bot_access_settings`. + + .. versionadded:: 22.8 + + Returns: + :class:`telegram.BotAccessSettings`: On success, returns the access settings of the bot + managed by the user. + """ + + return await self.get_bot().get_managed_bot_access_settings( + user_id=self.id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def set_managed_bot_access_settings( + self, + is_access_restricted: bool, + added_user_ids: Sequence[int] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Shortcut for:: + + await bot.set_managed_bot_access_settings( + user_id=update.effective_user.id, + *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.set_managed_bot_access_settings`. + + .. versionadded:: 22.8 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + + return await self.get_bot().set_managed_bot_access_settings( + user_id=self.id, + is_access_restricted=is_access_restricted, + added_user_ids=added_user_ids, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_reaction( + self, + chat_id: int | str, + message_id: int, + actor_chat_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Shortcut for:: + + await bot.delete_message_reaction( + user_id=update.effective_user.id, + *args, + **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.delete_message_reaction`. + + .. versionadded:: 22.8 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().delete_message_reaction( + user_id=self.id, + chat_id=chat_id, + message_id=message_id, + actor_chat_id=actor_chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def get_personal_chat_messages( + self, + limit: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> tuple["Message", ...]: + """ + Shortcut for:: + + await bot.get_user_personal_chat_messages( + user_id=update.effective_user.id, + *args, **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.get_user_personal_chat_messages`. + + .. versionadded:: 22.8 + + Returns: + tuple[:class:`telegram.Message`]: On success, a tuple of messages from the personal + channel chat is returned. + """ + + return await self.get_bot().get_user_personal_chat_messages( + user_id=self.id, + limit=limit, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) + + async def delete_all_reactions( + self, + chat_id: int | str, + actor_chat_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + ) -> bool: + """ + Shortcut for:: + + await bot.delete_all_message_reactions( + user_id=update.effective_user.id, + *args, + **kwargs + ) + + For the documentation of the arguments, please see + :meth:`telegram.Bot.delete_all_message_reactions`. + + .. versionadded:: 22.8 + + Returns: + :obj:`bool`: On success, :obj:`True` is returned. + """ + return await self.get_bot().delete_all_message_reactions( + chat_id=chat_id, + user_id=self.id, + actor_chat_id=actor_chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=api_kwargs, + ) diff --git a/src/telegram/_utils/types.py b/src/telegram/_utils/types.py index 1b29e2e8de6..a6c70545593 100644 --- a/src/telegram/_utils/types.py +++ b/src/telegram/_utils/types.py @@ -25,7 +25,7 @@ """ import datetime as dtm -from collections.abc import Callable, Collection +from collections.abc import Callable, Collection, Sequence from pathlib import Path from typing import IO, TYPE_CHECKING, Any, Literal, TypeAlias, TypeVar, Union @@ -93,6 +93,11 @@ CorrectOptionID: TypeAlias = Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] # pylint: disable=invalid-name +CorrectOptionIds: TypeAlias = Sequence[Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]] +""" +.. versionadded:: 22.8 +""" + MarkdownVersion: TypeAlias = Literal[1, 2] SocketOpt: TypeAlias = ( diff --git a/src/telegram/_version.py b/src/telegram/_version.py index d905e7c0797..257e57c95c8 100644 --- a/src/telegram/_version.py +++ b/src/telegram/_version.py @@ -51,6 +51,6 @@ def __str__(self) -> str: __version_info__: Final[Version] = Version( - major=22, minor=7, micro=0, releaselevel="final", serial=0 + major=22, minor=8, micro=0, releaselevel="final", serial=0 ) __version__: Final[str] = str(__version_info__) diff --git a/src/telegram/_webhookinfo.py b/src/telegram/_webhookinfo.py index 94f6f7bf22e..7955949d516 100644 --- a/src/telegram/_webhookinfo.py +++ b/src/telegram/_webhookinfo.py @@ -63,7 +63,8 @@ class WebhookInfo(TelegramObject): connections to the webhook for update delivery. allowed_updates (Sequence[:obj:`str`], optional): A sequence of update types the bot is subscribed to. Defaults to all update types, except - :attr:`telegram.Update.chat_member`. + :attr:`~telegram.Update.chat_member`, :attr:`~telegram.Update.message_reaction`, + and :attr:`~telegram.Update.message_reaction_count`. .. versionchanged:: 20.0 |sequenceclassargs| @@ -93,7 +94,8 @@ class WebhookInfo(TelegramObject): connections to the webhook for update delivery. allowed_updates (tuple[:obj:`str`]): Optional. A tuple of update types the bot is subscribed to. Defaults to all update types, except - :attr:`telegram.Update.chat_member`. + :attr:`~telegram.Update.chat_member`, :attr:`~telegram.Update.message_reaction`, + and :attr:`~telegram.Update.message_reaction_count`. .. versionchanged:: 20.0 diff --git a/src/telegram/constants.py b/src/telegram/constants.py index 3ae66ff0b4b..2369f2d435b 100644 --- a/src/telegram/constants.py +++ b/src/telegram/constants.py @@ -45,6 +45,7 @@ "BackgroundFillType", "BackgroundTypeLimit", "BackgroundTypeType", + "BaseInputMediaType", "BotCommandLimit", "BotCommandScopeType", "BotDescriptionLimit", @@ -87,6 +88,7 @@ "KeyboardButtonRequestUsersLimit", "KeyboardButtonStyle", "LocationLimit", + "ManagedBotAccessLimit", "MaskPosition", "MediaGroupLimit", "MenuButtonType", @@ -101,6 +103,7 @@ "OwnedGiftType", "PaidMediaType", "ParseMode", + "PersonalChatMessagesLimit", "PollLimit", "PollType", "PollingLimit", @@ -181,7 +184,7 @@ class _AccentColor(NamedTuple): #: :data:`telegram.__bot_api_version_info__`. #: #: .. versionadded:: 20.0 -BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=9, minor=5) +BOT_API_VERSION_INFO: Final[_BotAPIVersion] = _BotAPIVersion(major=10, minor=0) #: :obj:`str`: Telegram Bot API #: version supported by this version of `python-telegram-bot`. Also available as #: :data:`telegram.__bot_api_version__`. @@ -1519,10 +1522,43 @@ class InputChecklistLimit(IntEnum): """ +class BaseInputMediaType(StringEnum): + """This enum contains the available types of :class:`telegram.InputMedia`, + :class:`telegram.InputPollMedia` and :class:`telegram.InputPollOptionMedia`. The enum + members of this enumeration are instances of :class:`str` and can be treated as such. + + .. versionadded:: 22.8 + """ + + __slots__ = () + + ANIMATION = "animation" + """:obj:`str`: Type of :class:`telegram.InputMediaAnimation`.""" + DOCUMENT = "document" + """:obj:`str`: Type of :class:`telegram.InputMediaDocument`.""" + AUDIO = "audio" + """:obj:`str`: Type of :class:`telegram.InputMediaAudio`.""" + PHOTO = "photo" + """:obj:`str`: Type of :class:`telegram.InputMediaPhoto`.""" + VIDEO = "video" + """:obj:`str`: Type of :class:`telegram.InputMediaVideo`.""" + LOCATION = "location" + """:obj:`str`: Type of :class:`telegram.InputMediaLocation`.""" + STICKER = "sticker" + """:obj:`str`: Type of :class:`telegram.InputMediaSticker`.""" + VENUE = "venue" + """:obj:`str`: Type of :class:`telegram.InputMediaVenue`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: Type of :class:`telegram.InputMediaLivePhoto`.""" + + class InputMediaType(StringEnum): """This enum contains the available types of :class:`telegram.InputMedia`. The enum members of this enumeration are instances of :class:`str` and can be treated as such. + .. deprecated:: 22.8 + Use :class:`telegram.constants.BaseInputMediaType` instead. + .. versionadded:: 20.0 """ @@ -1538,6 +1574,11 @@ class InputMediaType(StringEnum): """:obj:`str`: Type of :class:`telegram.InputMediaPhoto`.""" VIDEO = "video" """:obj:`str`: Type of :class:`telegram.InputMediaVideo`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: Type of :class:`telegram.InputMediaLivePhoto`. + + .. versionadded:: 22.8 + """ class InputPaidMediaType(StringEnum): @@ -1550,9 +1591,14 @@ class InputPaidMediaType(StringEnum): __slots__ = () PHOTO = "photo" - """:obj:`str`: Type of :class:`telegram.InputMediaPhoto`.""" + """:obj:`str`: Type of :class:`telegram.InputPaidMediaPhoto`.""" VIDEO = "video" - """:obj:`str`: Type of :class:`telegram.InputMediaVideo`.""" + """:obj:`str`: Type of :class:`telegram.InputPaidMediaVideo`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: Type of :class:`telegram.InputPaidMediaLivePhoto`. + + .. versionadded:: 22.8 + """ class InputProfilePhotoType(StringEnum): @@ -1784,6 +1830,8 @@ class LocationLimit(IntEnum): :meth:`telegram.Bot.edit_message_live_location` * :paramref:`~telegram.Bot.send_location.horizontal_accuracy` parameter of :meth:`telegram.Bot.send_location` + * :paramref:`~telegram.InputMediaLocation.horizontal_accuracy` parameter of + :class:`telegram.InputMediaLocation` """ MIN_HEADING = 1 @@ -1963,6 +2011,11 @@ class MessageAttachmentType(StringEnum): """:obj:`str`: Messages with :attr:`telegram.Message.game`.""" INVOICE = "invoice" """:obj:`str`: Messages with :attr:`telegram.Message.invoice`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: Messages with :attr:`telegram.Message.live_photo`. + + .. versionadded:: 22.8 + """ LOCATION = "location" """:obj:`str`: Messages with :attr:`telegram.Message.location`.""" PAID_MEDIA = "paid_media" @@ -2345,8 +2398,15 @@ class MessageType(StringEnum): """:obj:`str`: Messages with :attr:`telegram.Message.invoice`.""" LEFT_CHAT_MEMBER = "left_chat_member" """:obj:`str`: Messages with :attr:`telegram.Message.left_chat_member`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: Messages with :attr:`telegram.Message.live_photo`. + + .. versionadded:: 22.8 + """ LOCATION = "location" """:obj:`str`: Messages with :attr:`telegram.Message.location`.""" + MANAGED_BOT_CREATED = "managed_bot_created" + """:obj:`str`: Messages with :attr:`telegram.Message.managed_bot_created`.""" MESSAGE_AUTO_DELETE_TIMER_CHANGED = "message_auto_delete_timer_changed" """:obj:`str`: Messages with :attr:`telegram.Message.message_auto_delete_timer_changed`.""" MIGRATE_TO_CHAT_ID = "migrate_to_chat_id" @@ -2367,6 +2427,16 @@ class MessageType(StringEnum): .. versionadded:: v22.2 """ + POLL_OPTION_ADDED = "poll_option_added" + """:obj:`str`: Messages with :attr:`telegram.Message.poll_option_added`. + + .. versionadded:: 22.8 + """ + POLL_OPTION_DELETED = "poll_option_deleted" + """:obj:`str`: Messages with :attr:`telegram.Message.poll_option_deleted`. + + .. versionadded:: 22.8 + """ SUGGESTED_POST_APPROVAL_FAILED = "suggested_post_approval_failed" """:obj:`str`: Messages with :attr:`telegram.Message.suggested_post_approval_failed`. @@ -2554,6 +2624,33 @@ class PaidMediaType(StringEnum): """:obj:`str`: The type of :class:`telegram.PaidMediaVideo`.""" PHOTO = "photo" """:obj:`str`: The type of :class:`telegram.PaidMediaPhoto`.""" + LIVE_PHOTO = "live_photo" + """:obj:`str`: The type of :class:`telegram.PaidMediaLivePhoto` + + .. versionadded:: 22.8 + """ + + +class PersonalChatMessagesLimit(IntEnum): + """This enum contains limitations for + :paramref:`telegram.Bot.get_user_personal_chat_messages.limit`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.8 + """ + + __slots__ = () + + MIN_LIMIT = 1 + """:obj:`int`: Minimum value allowed for the + :paramref:`~telegram.Bot.get_user_personal_chat_messages.limit` + parameter of :meth:`telegram.Bot.get_user_personal_chat_messages`. + """ + MAX_LIMIT = 20 + """:obj:`int`: Maximum value allowed for the + :paramref:`~telegram.Bot.get_user_personal_chat_messages.limit` + parameter of :meth:`telegram.Bot.get_user_personal_chat_messages`. + """ class PollingLimit(IntEnum): @@ -3410,10 +3507,13 @@ class PollLimit(IntEnum): to the :paramref:`~telegram.Bot.send_poll.options` parameter of :meth:`telegram.Bot.send_poll`. """ - MIN_OPTION_NUMBER = 2 + MIN_OPTION_NUMBER = 1 """:obj:`int`: Minimum number of strings passed in a :obj:`list` to the :paramref:`~telegram.Bot.send_poll.options` parameter of :meth:`telegram.Bot.send_poll`. + + .. versionchanged:: 22.8 + Bot API 10.0 decreased this value from ``2`` to ``1``. """ MAX_OPTION_NUMBER = 12 """:obj:`int`: Maximum number of strings passed in a :obj:`list` @@ -3439,11 +3539,33 @@ class PollLimit(IntEnum): Also used in the :paramref:`~telegram.Bot.send_poll.close_date` parameter of :meth:`telegram.Bot.send_poll`. """ - MAX_OPEN_PERIOD = 600 + MAX_OPEN_PERIOD = 2628000 """:obj:`int`: Maximum value allowed for the :paramref:`~telegram.Bot.send_poll.open_period` parameter of :meth:`telegram.Bot.send_poll`. Also used in the :paramref:`~telegram.Bot.send_poll.close_date` parameter of :meth:`telegram.Bot.send_poll`. + + .. versionchanged:: 22.8 + Changed from ``600`` to ``2628000`` since Bot API 9.6. + """ + MAX_DESCRIPTION_CHARACTERS = 1024 + """:obj:`int`: Maximum value allowed for the + :paramref:`~telegram.Bot.send_poll.description` parameter of :meth:`telegram.Bot.send_poll`. + + .. versionadded:: 22.8 + """ + MIN_MEMBERSHIP_HOURS = 24 + """:obj:`int`: Minimum number of hours a user must have been a member of the chat + before they can vote in a members-only poll. + + .. versionadded:: 22.8 + """ + MAX_COUNTRY_CODES = 12 + """:obj:`int`: Maximum number of two-letter ``ISO 3166-1 alpha-2`` country codes passed in a + :obj:`list` to the :paramref:`~telegram.Bot.send_poll.country_codes` parameter of + :meth:`telegram.Bot.send_poll`. + + .. versionadded:: 22.8 """ @@ -3594,6 +3716,16 @@ class UpdateType(StringEnum): .. versionadded:: 21.6 """ + MANAGED_BOT = "managed_bot" + """:obj:`str`: Updates with :attr:`telegram.Update.managed_bot`. + + .. versionadded:: 22.8 + """ + GUEST_MESSAGE = "guest_message" + """:obj:`str`: Updates with :attr:`telegram.Update.guest_message`. + + .. versionadded:: 22.8 + """ class InvoiceLimit(IntEnum): @@ -4013,6 +4145,22 @@ class ReactionEmoji(StringEnum): """:obj:`str`: Pouting face""" +class ManagedBotAccessLimit(IntEnum): + """This enum contains limitations for :meth:`~telegram.Bot.set_managed_bot_access_settings`. + The enum members of this enumeration are instances of :class:`int` and can be treated as such. + + .. versionadded:: 22.8 + """ + + __slots__ = () + + MAX_ALLOWED_USERS = 10 + """:obj:`int`: Maximum number of users that can be allowed to access a managed bot in the + :paramref:`~telegram.Bot.set_managed_bot_access_settings.added_user_ids` parameter of + :meth:`~telegram.Bot.set_managed_bot_access_settings`. + """ + + class VerifyLimit(IntEnum): """This enum contains limitations for :meth:`~telegram.Bot.verify_chat` and :meth:`~telegram.Bot.verify_user`. diff --git a/src/telegram/ext/__init__.py b/src/telegram/ext/__init__.py index 4c7fa9f5a8c..d286722b594 100644 --- a/src/telegram/ext/__init__.py +++ b/src/telegram/ext/__init__.py @@ -46,6 +46,7 @@ "InvalidCallbackData", "Job", "JobQueue", + "ManagedBotUpdatedHandler", "MessageHandler", "MessageReactionHandler", "PaidMediaPurchasedHandler", @@ -88,6 +89,7 @@ from ._handlers.commandhandler import CommandHandler from ._handlers.conversationhandler import ConversationHandler from ._handlers.inlinequeryhandler import InlineQueryHandler +from ._handlers.managedbotupdatedhandler import ManagedBotUpdatedHandler from ._handlers.messagehandler import MessageHandler from ._handlers.messagereactionhandler import MessageReactionHandler from ._handlers.paidmediapurchasedhandler import PaidMediaPurchasedHandler diff --git a/src/telegram/ext/_extbot.py b/src/telegram/ext/_extbot.py index c09b1a5f6d4..0d8ef596e50 100644 --- a/src/telegram/ext/_extbot.py +++ b/src/telegram/ext/_extbot.py @@ -38,6 +38,7 @@ Animation, Audio, Bot, + BotAccessSettings, BotCommand, BotCommandScope, BotDescription, @@ -63,6 +64,7 @@ InputPaidMedia, InputPollOption, InputProfilePhoto, + KeyboardButton, LinkPreviewOptions, MaskPosition, MenuButton, @@ -72,8 +74,10 @@ PhotoSize, Poll, PreparedInlineMessage, + PreparedKeyboardButton, ReactionType, ReplyParameters, + SentGuestMessage, SentWebAppMessage, StarAmount, StarTransactions, @@ -98,6 +102,7 @@ from telegram._utils.types import ( BaseUrl, CorrectOptionID, + CorrectOptionIds, FileInput, JSONDict, ODVInput, @@ -116,11 +121,14 @@ InlineQueryResult, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, InputMediaPhoto, InputMediaVideo, + InputPollMedia, InputSticker, InputStoryContent, LabeledPrice, + LivePhoto, Location, MessageEntity, PassportElementError, @@ -1116,6 +1124,28 @@ async def answer_web_app_query( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def answer_guest_query( + self, + guest_query_id: str, + result: "InlineQueryResult", + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> SentGuestMessage: + return await super().answer_guest_query( + guest_query_id=guest_query_id, + result=result, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + async def approve_chat_join_request( self, chat_id: str | int, @@ -1841,6 +1871,7 @@ async def forward_messages( async def get_chat_administrators( self, chat_id: str | int, + return_bots: bool | None = None, *, read_timeout: ODVInput[float] = DEFAULT_NONE, write_timeout: ODVInput[float] = DEFAULT_NONE, @@ -1851,6 +1882,7 @@ async def get_chat_administrators( ) -> tuple[ChatMember, ...]: return await super().get_chat_administrators( chat_id=chat_id, + return_bots=return_bots, read_timeout=read_timeout, write_timeout=write_timeout, connect_timeout=connect_timeout, @@ -2686,7 +2718,7 @@ async def send_contact( async def send_checklist( self, business_connection_id: str, - chat_id: int, + chat_id: int | str, checklist: InputChecklist, disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, @@ -2724,7 +2756,7 @@ async def send_checklist( async def edit_message_checklist( self, business_connection_id: str, - chat_id: int, + chat_id: int | str, message_id: int, checklist: InputChecklist, reply_markup: "InlineKeyboardMarkup | None" = None, @@ -2855,7 +2887,7 @@ async def send_document( async def send_game( self, - chat_id: int, + chat_id: int | str, game_short_name: str, disable_notification: ODVInput[bool] = DEFAULT_NONE, reply_markup: "InlineKeyboardMarkup | None" = None, @@ -3041,7 +3073,7 @@ async def send_media_group( self, chat_id: int | str, media: Sequence[ - "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo" + "InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo | InputMediaLivePhoto" # noqa: E501 # pylint: disable=line-too-long ], disable_notification: ODVInput[bool] = DEFAULT_NONE, protect_content: ODVInput[bool] = DEFAULT_NONE, @@ -3145,7 +3177,7 @@ async def send_message_draft( self, chat_id: int, draft_id: int, - text: str, + text: str | None = None, message_thread_id: int | None = None, parse_mode: ODVInput[str] = DEFAULT_NONE, entities: Sequence["MessageEntity"] | None = None, @@ -3254,6 +3286,18 @@ async def send_poll( question_entities: Sequence["MessageEntity"] | None = None, message_effect_id: str | None = None, allow_paid_broadcast: bool | None = None, + allows_revoting: bool | None = None, + allow_adding_options: bool | None = None, + hide_results_until_closes: bool | None = None, + correct_option_ids: CorrectOptionIds | None = None, + description: str | None = None, + description_parse_mode: ODVInput[str] | None = None, + description_entities: Sequence["MessageEntity"] | None = None, + shuffle_options: bool | None = None, + members_only: bool | None = None, + country_codes: Sequence[str] | None = None, + explanation_media: "InputPollMedia | None" = None, + media: "InputPollMedia | None" = None, *, reply_to_message_id: int | None = None, allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, @@ -3295,6 +3339,18 @@ async def send_poll( question_entities=question_entities, message_effect_id=message_effect_id, allow_paid_broadcast=allow_paid_broadcast, + allows_revoting=allows_revoting, + shuffle_options=shuffle_options, + correct_option_ids=correct_option_ids, + description=description, + description_parse_mode=description_parse_mode, + description_entities=description_entities, + hide_results_until_closes=hide_results_until_closes, + allow_adding_options=allow_adding_options, + members_only=members_only, + country_codes=country_codes, + explanation_media=explanation_media, + media=media, ) async def send_sticker( @@ -5013,6 +5069,74 @@ async def edit_user_star_subscription( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def get_managed_bot_access_settings( + self, + user_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> BotAccessSettings: + + return await super().get_managed_bot_access_settings( + user_id=user_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def set_managed_bot_access_settings( + self, + user_id: int, + is_access_restricted: bool, + added_user_ids: Sequence[int] | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + + return await super().set_managed_bot_access_settings( + user_id=user_id, + is_access_restricted=is_access_restricted, + added_user_ids=added_user_ids, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def get_user_personal_chat_messages( + self, + user_id: int, + limit: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> tuple[Message, ...]: + return await super().get_user_personal_chat_messages( + user_id=user_id, + limit=limit, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + async def send_paid_media( self, chat_id: str | int, @@ -5511,6 +5635,180 @@ async def set_chat_member_tag( api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), ) + async def get_managed_bot_token( + self, + user_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> str: + return await super().get_managed_bot_token( + user_id=user_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def replace_managed_bot_token( + self, + user_id: int, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> str: + return await super().replace_managed_bot_token( + user_id=user_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def save_prepared_keyboard_button( + self, + user_id: int, + button: KeyboardButton, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> PreparedKeyboardButton: + + return await super().save_prepared_keyboard_button( + user_id=user_id, + button=button, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def send_live_photo( + self, + chat_id: int | str, + live_photo: "FileInput | LivePhoto", + photo: "FileInput | PhotoSize", + business_connection_id: str | None = None, + message_thread_id: int | None = None, + direct_messages_topic_id: int | None = None, + caption: str | None = None, + parse_mode: ODVInput[str] = DEFAULT_NONE, + caption_entities: Sequence["MessageEntity"] | None = None, + show_caption_above_media: bool | None = None, + has_spoiler: bool | None = None, + disable_notification: ODVInput[bool] = DEFAULT_NONE, + protect_content: ODVInput[bool] = DEFAULT_NONE, + allow_paid_broadcast: bool | None = None, + message_effect_id: str | None = None, + suggested_post_parameters: "SuggestedPostParameters | None" = None, + reply_parameters: "ReplyParameters | None" = None, + reply_markup: "ReplyMarkup | None" = None, + *, + allow_sending_without_reply: ODVInput[bool] = DEFAULT_NONE, + reply_to_message_id: int | None = None, + filename: str | None = None, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> Message: + + return await super().send_live_photo( + chat_id=chat_id, + live_photo=live_photo, + photo=photo, + business_connection_id=business_connection_id, + message_thread_id=message_thread_id, + direct_messages_topic_id=direct_messages_topic_id, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + show_caption_above_media=show_caption_above_media, + has_spoiler=has_spoiler, + disable_notification=disable_notification, + protect_content=protect_content, + allow_paid_broadcast=allow_paid_broadcast, + message_effect_id=message_effect_id, + suggested_post_parameters=suggested_post_parameters, + reply_parameters=reply_parameters, + allow_sending_without_reply=allow_sending_without_reply, + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + filename=filename, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def delete_message_reaction( + self, + chat_id: int | str, + message_id: int, + user_id: int | None = None, + actor_chat_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().delete_message_reaction( + chat_id=chat_id, + message_id=message_id, + user_id=user_id, + actor_chat_id=actor_chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + + async def delete_all_message_reactions( + self, + chat_id: int | str, + user_id: int | None = None, + actor_chat_id: int | None = None, + *, + read_timeout: ODVInput[float] = DEFAULT_NONE, + write_timeout: ODVInput[float] = DEFAULT_NONE, + connect_timeout: ODVInput[float] = DEFAULT_NONE, + pool_timeout: ODVInput[float] = DEFAULT_NONE, + api_kwargs: JSONDict | None = None, + rate_limit_args: RLARGS | None = None, + ) -> bool: + return await super().delete_all_message_reactions( + chat_id=chat_id, + user_id=user_id, + actor_chat_id=actor_chat_id, + read_timeout=read_timeout, + write_timeout=write_timeout, + connect_timeout=connect_timeout, + pool_timeout=pool_timeout, + api_kwargs=self._merge_api_rl_kwargs(api_kwargs, rate_limit_args), + ) + # updated camelCase aliases getMe = get_me sendMessage = send_message @@ -5565,6 +5863,7 @@ async def set_chat_member_tag( answerShippingQuery = answer_shipping_query answerPreCheckoutQuery = answer_pre_checkout_query answerWebAppQuery = answer_web_app_query + answerGuestQuery = answer_guest_query restrictChatMember = restrict_chat_member promoteChatMember = promote_chat_member setChatPermissions = set_chat_permissions @@ -5677,3 +5976,12 @@ async def set_chat_member_tag( removeMyProfilePhoto = remove_my_profile_photo getUserProfileAudios = get_user_profile_audios setChatMemberTag = set_chat_member_tag + getManagedBotToken = get_managed_bot_token + replaceManagedBotToken = replace_managed_bot_token + savePreparedKeyboardButton = save_prepared_keyboard_button + sendLivePhoto = send_live_photo + getManagedBotAccessSettings = get_managed_bot_access_settings + setManagedBotAccessSettings = set_managed_bot_access_settings + getUserPersonalChatMessages = get_user_personal_chat_messages + deleteMessageReaction = delete_message_reaction + deleteAllMessageReactions = delete_all_message_reactions diff --git a/src/telegram/ext/_handlers/managedbotupdatedhandler.py b/src/telegram/ext/_handlers/managedbotupdatedhandler.py new file mode 100644 index 00000000000..6a52e8c2d4c --- /dev/null +++ b/src/telegram/ext/_handlers/managedbotupdatedhandler.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains the ManagedBotUpdatedHandler class.""" + +from typing import TypeVar + +from telegram import Update +from telegram._utils.defaultvalue import DEFAULT_TRUE +from telegram._utils.types import SCT, DVType +from telegram.ext._handlers.basehandler import BaseHandler +from telegram.ext._utils._update_parsing import parse_chat_id, parse_username +from telegram.ext._utils.types import CCT, HandlerCallback + +RT = TypeVar("RT") + + +class ManagedBotUpdatedHandler(BaseHandler[Update, CCT, RT]): + """Handler class to handle + :attr:`updated Telegram Managed Bots `. + + .. versionadded:: 22.8 + + Args: + callback (:term:`coroutine function`): The callback function for this handler. Will be + called when :meth:`check_update` has determined that an update should be processed by + this handler. Callback signature:: + + async def callback(update: Update, context: CallbackContext) + user_id (:obj:`int` | Collection[:obj:`int`], optional): Filters requests to allow only + those which are from the specified user ID(s). The user ID(s) can be either the + creator of the managed bot or the bot itself. + + username (:obj:`str` | Collection[:obj:`str`], optional): Filters requests to allow only + those which are from the specified username(s). The username(s) can be either the + creator of the managed bot or the bot itself. + + block (:obj:`bool`, optional): Determines whether the return value of the callback should + be awaited before processing the next handler in + :meth:`telegram.ext.Application.process_update`. Defaults to :obj:`True`. + + .. seealso:: :wiki:`Concurrency` + Attributes: + callback (:term:`coroutine function`): The callback function for this handler. + block (:obj:`bool`): Determines whether the return value of the callback should be + awaited before processing the next handler in + :meth:`telegram.ext.Application.process_update`. + """ + + __slots__ = ( + "_user_ids", + "_usernames", + ) + + def __init__( + self: "ManagedBotUpdatedHandler[CCT, RT]", + callback: HandlerCallback[Update, CCT, RT], + user_id: SCT[int] | None = None, + username: SCT[str] | None = None, + block: DVType[bool] = DEFAULT_TRUE, + ): + super().__init__(callback, block=block) + + self._user_ids = parse_chat_id(user_id) + self._usernames = parse_username(username) + + def check_update(self, update: object) -> bool: + """Determines whether an update should be passed to this handler's :attr:`callback`. + + Args: + update (:class:`telegram.Update` | :obj:`object`): Incoming update. + + Returns: + :obj:`bool` + + """ + if isinstance(update, Update) and update.managed_bot: + if not self._user_ids and not self._usernames: + return True + if ( + update.managed_bot.user.id in self._user_ids + or update.managed_bot.bot.id in self._user_ids + ): + return True + return ( + update.managed_bot.user.username in self._usernames + or update.managed_bot.bot.username in self._usernames + ) + return False diff --git a/src/telegram/ext/_utils/trackingdict.py b/src/telegram/ext/_utils/trackingdict.py index bf7af4d5dab..a5eedbe86e7 100644 --- a/src/telegram/ext/_utils/trackingdict.py +++ b/src/telegram/ext/_utils/trackingdict.py @@ -26,6 +26,7 @@ the changelog. """ +import sys from collections import UserDict from collections.abc import Mapping from typing import Final, Generic, TypeVar @@ -109,7 +110,14 @@ def pop( # type: ignore[override] self.__track_write(key) if isinstance(default, DefaultValue): return super().pop(key) - return super().pop(key, default=default) + return super().pop(key, default) + + # Python 3.15 added a popitem() to UserDict, which does LIFO instead of the FIFO behaviour + # of MutableMapping.popitem(). So we keep it consistent across versions by overriding here. + if sys.version_info >= (3, 15): + + def popitem(self) -> tuple[_KT, _VT]: + return super(UserDict, self).popitem() def clear(self) -> None: self.__track_write(set(super().keys())) diff --git a/src/telegram/ext/filters.py b/src/telegram/ext/filters.py index d099b0c5313..a86e4b96399 100644 --- a/src/telegram/ext/filters.py +++ b/src/telegram/ext/filters.py @@ -62,6 +62,7 @@ "IS_AUTOMATIC_FORWARD", "IS_FROM_OFFLINE", "IS_TOPIC_MESSAGE", + "LIVE_PHOTO", "LOCATION", "PAID_MEDIA", "PASSPORT_DATA", @@ -273,6 +274,10 @@ def check_update(self, update: Update) -> bool | FilterDataDict | None: :attr:`~telegram.Update.business_message` or :attr:`~telegram.Update.edited_business_message`. + .. versionchanged:: 22.8 + This filter now also returns :obj:`True` if the update contains + :attr:`~telegram.Update.guest_message`. + Args: update (:class:`telegram.Update`): The update to check. @@ -281,7 +286,8 @@ def check_update(self, update: Update) -> bool | FilterDataDict | None: :attr:`~telegram.Update.channel_post`, :attr:`~telegram.Update.message`, :attr:`~telegram.Update.edited_channel_post`, :attr:`~telegram.Update.edited_message`, :attr:`telegram.Update.business_message`, - :attr:`telegram.Update.edited_business_message`, or :obj:`False` otherwise. + :attr:`telegram.Update.edited_business_message`, + :attr:`telegram.Update.guest_message`, or :obj:`False` otherwise. """ return bool( # Only message updates should be handled. update.channel_post @@ -290,6 +296,7 @@ def check_update(self, update: Update) -> bool | FilterDataDict | None: or update.edited_message or update.business_message or update.edited_business_message + or update.guest_message ) @@ -430,7 +437,7 @@ def __init__( ): self.data_filter = True self.or_filter = or_filter - if self.or_filter and not isinstance(self.and_filter, bool) and self.or_filter.data_filter: + if self.or_filter and not isinstance(self.or_filter, bool) and self.or_filter.data_filter: self.data_filter = True @staticmethod @@ -1651,6 +1658,20 @@ def filter(self, message: Message) -> bool: ) +class _LivePhoto(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.live_photo) + + +LIVE_PHOTO = _LivePhoto(name="filters.LIVE_PHOTO") +"""Messages that contain :attr:`telegram.Message.live_photo`. + +.. versionadded:: 22.8 +""" + + class _Location(MessageFilter): __slots__ = () @@ -1984,6 +2005,7 @@ def filter(self, update: Update) -> bool: or StatusUpdate.GIVEAWAY_COMPLETED.check_update(update) or StatusUpdate.GIVEAWAY_CREATED.check_update(update) or StatusUpdate.LEFT_CHAT_MEMBER.check_update(update) + or StatusUpdate.MANAGED_BOT_CREATED.check_update(update) or StatusUpdate.MESSAGE_AUTO_DELETE_TIMER_CHANGED.check_update(update) or StatusUpdate.MIGRATE.check_update(update) or StatusUpdate.NEW_CHAT_MEMBERS.check_update(update) @@ -1991,6 +2013,8 @@ def filter(self, update: Update) -> bool: 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.POLL_OPTION_ADDED.check_update(update) + or StatusUpdate.POLL_OPTION_DELETED.check_update(update) or StatusUpdate.PROXIMITY_ALERT_TRIGGERED.check_update(update) or StatusUpdate.REFUNDED_PAYMENT.check_update(update) or StatusUpdate.SUGGESTED_POST_APPROVAL_FAILED.check_update(update) @@ -2259,6 +2283,15 @@ def filter(self, message: Message) -> bool: LEFT_CHAT_MEMBER = _LeftChatMember(name="filters.StatusUpdate.LEFT_CHAT_MEMBER") """Messages that contain :attr:`telegram.Message.left_chat_member`.""" + class _ManagedBotCreated(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.managed_bot_created) + + MANAGED_BOT_CREATED = _ManagedBotCreated(name="filters.StatusUpdate.MANAGED_BOT_CREATED") + """Messages that contain :attr:`telegram.Message.managed_bot_created`.""" + class _MessageAutoDeleteTimerChanged(MessageFilter): __slots__ = () @@ -2333,6 +2366,28 @@ def filter(self, message: Message) -> bool: PINNED_MESSAGE = _PinnedMessage(name="filters.StatusUpdate.PINNED_MESSAGE") """Messages that contain :attr:`telegram.Message.pinned_message`.""" + class _PollOptionAdded(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.poll_option_added) + + POLL_OPTION_ADDED = _PollOptionAdded(name="filters.StatusUpdate.POLL_OPTION_ADDED") + """Messages that contain :attr:`telegram.Message.poll_option_added`. + .. versionadded:: 22.8 + """ + + class _PollOptionDeleted(MessageFilter): + __slots__ = () + + def filter(self, message: Message) -> bool: + return bool(message.poll_option_deleted) + + POLL_OPTION_DELETED = _PollOptionDeleted(name="filters.StatusUpdate.POLL_OPTION_DELETED") + """Messages that contain :attr:`telegram.Message.poll_option_deleted`. + .. versionadded:: 22.8 + """ + class _ProximityAlertTriggered(MessageFilter): __slots__ = () @@ -2860,6 +2915,18 @@ def filter(self, update: Update) -> bool: .. versionadded:: 21.1 """ + class _GuestMessage(UpdateFilter): + __slots__ = () + + def filter(self, update: Update) -> bool: + return update.guest_message is not None + + GUEST_MESSAGE = _GuestMessage(name="filters.UpdateType.GUEST_MESSAGE") + """Updates with :attr:`telegram.Update.guest_message`. + + .. versionadded:: 22.8 + """ + class User(_ChatUserBaseFilter): """Filters messages to allow only those which are from specified user ID(s) or diff --git a/src/telegram/request/_requestparameter.py b/src/telegram/request/_requestparameter.py index 26a2285324f..70024558c0f 100644 --- a/src/telegram/request/_requestparameter.py +++ b/src/telegram/request/_requestparameter.py @@ -24,11 +24,11 @@ from dataclasses import dataclass from typing import 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._files.inputstorycontent import InputStoryContent from telegram._telegramobject import TelegramObject from telegram._utils.datetime import to_timestamp from telegram._utils.enum import StringEnum diff --git a/tests/_files/conftest.py b/tests/_files/conftest.py index 5bff5e23728..9e8e5212e11 100644 --- a/tests/_files/conftest.py +++ b/tests/_files/conftest.py @@ -145,3 +145,19 @@ def video_sticker_file(): def video_sticker(bot, chat_id): with data_file("telegram_video_sticker.webm").open("rb") as f: return bot.send_sticker(chat_id, sticker=f, timeout=50).sticker + + +@pytest.fixture(scope="session") +async def real_live_photo(bot, chat_id): + with ( + data_file("telegram.jpg").open("rb") as photo, + data_file("telegram.mp4").open("rb") as video, + ): + return ( + await bot.send_live_photo( + chat_id, + live_photo=video, + photo=photo, + read_timeout=50, + ) + ).live_photo diff --git a/tests/_files/test_audio.py b/tests/_files/test_audio.py index 0bd9b2e6fd8..dd832966fde 100644 --- a/tests/_files/test_audio.py +++ b/tests/_files/test_audio.py @@ -263,7 +263,7 @@ async def test_send_all_args(self, bot, chat_id, audio_file, thumb_file, duratio assert message.audio.file_name == self.file_name assert message.audio.mime_type == self.mime_type assert message.audio.file_size == self.file_size - assert message.audio.thumbnail.file_size == self.thumb_file_size + assert message.audio.thumbnail.file_size in [self.thumb_file_size, 1395] assert message.audio.thumbnail.width == self.thumb_width assert message.audio.thumbnail.height == self.thumb_height assert message.has_protected_content diff --git a/tests/_files/test_inputmedia.py b/tests/_files/test_inputmedia.py index d43a853ec7d..2cd9c9bc75a 100644 --- a/tests/_files/test_inputmedia.py +++ b/tests/_files/test_inputmedia.py @@ -29,15 +29,22 @@ InputMediaAnimation, InputMediaAudio, InputMediaDocument, + InputMediaLivePhoto, + InputMediaLocation, InputMediaPhoto, + InputMediaSticker, + InputMediaVenue, InputMediaVideo, + InputPaidMediaLivePhoto, InputPaidMediaPhoto, InputPaidMediaVideo, + InputPollMedia, + InputPollOptionMedia, Message, MessageEntity, ReplyParameters, ) -from telegram.constants import InputMediaType, ParseMode +from telegram.constants import BaseInputMediaType, ParseMode from telegram.error import BadRequest from telegram.request import RequestData from telegram.warnings import PTBDeprecationWarning @@ -121,6 +128,37 @@ def input_media_document(class_thumb_file): ) +@pytest.fixture(scope="module") +def input_media_location(): + return InputMediaLocation( + latitude=InputMediaLocationTestBase.latitude, + longitude=InputMediaLocationTestBase.longitude, + horizontal_accuracy=InputMediaLocationTestBase.horizontal_accuracy, + ) + + +@pytest.fixture(scope="module") +def input_media_venue(): + return InputMediaVenue( + latitude=InputMediaVenueTestBase.latitude, + longitude=InputMediaVenueTestBase.longitude, + title=InputMediaVenueTestBase.title, + address=InputMediaVenueTestBase.address, + foursquare_id=InputMediaVenueTestBase.foursquare_id, + foursquare_type=InputMediaVenueTestBase.foursquare_type, + google_place_id=InputMediaVenueTestBase.google_place_id, + google_place_type=InputMediaVenueTestBase.google_place_type, + ) + + +@pytest.fixture(scope="module") +def input_media_sticker(): + return InputMediaSticker( + media=InputMediaStickerTestBase.media, + emoji=InputMediaStickerTestBase.emoji, + ) + + @pytest.fixture(scope="module") def input_paid_media_photo(): return InputPaidMediaPhoto( @@ -142,6 +180,27 @@ def input_paid_media_video(class_thumb_file): ) +@pytest.fixture(scope="module") +def input_media_live_photo(): + return InputMediaLivePhoto( + media=InputMediaLivePhotoTestBase.media, + photo=InputMediaLivePhotoTestBase.photo, + caption=InputMediaLivePhotoTestBase.caption, + parse_mode=InputMediaLivePhotoTestBase.parse_mode, + caption_entities=InputMediaLivePhotoTestBase.caption_entities, + show_caption_above_media=InputMediaLivePhotoTestBase.show_caption_above_media, + has_spoiler=InputMediaLivePhotoTestBase.has_spoiler, + ) + + +@pytest.fixture(scope="module") +def input_paid_media_live_photo(): + return InputPaidMediaLivePhoto( + media=InputMediaLivePhotoTestBase.media, + photo=InputMediaLivePhotoTestBase.photo, + ) + + class InputMediaVideoTestBase: type_ = "video" media = "NOTAREALFILEID" @@ -157,6 +216,21 @@ class InputMediaVideoTestBase: show_caption_above_media = True +class TestInputMediaWithoutRequest: + def test_type_enum_conversion(self): + assert type(InputMedia(media_type="video", media="media").type) is BaseInputMediaType + assert InputMedia(media_type="unknown", media="media").type == "unknown" + + def test_to_dict(self): + assert InputMedia( + media_type="video", + media="media", + ).to_dict() == { + "type": BaseInputMediaType.VIDEO, + "media": "media", + } + + class TestInputMediaVideoWithoutRequest(InputMediaVideoTestBase): def test_slot_behaviour(self, input_media_video): inst = input_media_video @@ -180,6 +254,10 @@ def test_expected_values(self, input_media_video): assert input_media_video.has_spoiler == self.has_spoiler assert input_media_video.show_caption_above_media == self.show_caption_above_media + assert isinstance(input_media_video, InputMedia) + assert isinstance(input_media_video, InputPollMedia) + assert isinstance(input_media_video, InputPollOptionMedia) + def test_caption_entities_always_tuple(self): input_media_video = InputMediaVideo(self.media) assert input_media_video.caption_entities == () @@ -253,25 +331,91 @@ def test_with_local_files(self): assert input_media_video.thumbnail == data_file("telegram.jpg").as_uri() assert input_media_video.cover == data_file("telegram.jpg").as_uri() - def test_type_enum_conversion(self): - # Since we have a lot of different test classes for all the input media types, we test this - # conversion only here. It is independent of the specific class - assert ( - type( - InputMedia( - media_type="animation", - media="media", - ).type - ) - is InputMediaType + def test_effective_filename(self, video_file): + inst = InputMediaVideo( + video_file, + "caption", + 24, + 24, + 10, + True, + "parse_mode", + [], + "pos_filename_depr", ) - assert ( - InputMedia( - media_type="unknown", - media="media", - ).type - == "unknown" + assert inst.media.filename == "pos_filename_depr" + + inst = InputMediaVideo( + video_file, + filename="kw_only_filename", + ) + assert inst.media.filename == "kw_only_filename" + + # Deprecated, but for completeness + inst = InputMediaVideo( + video_file, + filename_depr="kw_filename_depr", ) + assert inst.media.filename == "kw_filename_depr" + + def test_filename_depr_mutually_exclusive_filename(self, video_file): + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaVideo( + video_file, + "caption", + 24, + 24, + 10, + True, + "parse_mode", + [], + "pos_filename_depr", + filename="kw_filename", + ) + + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaVideo( + video_file, + filename_depr="filename_depr", + filename="kw_filename", + ) + + def test_positional_filename_deprecated(self, video_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaVideo( + video_file, + "caption", + 24, + 24, + 10, + True, + "parse_mode", + [], + "pos_filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + + def test_keyword_filename_depr_deprecated(self, video_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaVideo( + video_file, + filename_depr="filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" class InputMediaPhotoTestBase: @@ -300,6 +444,10 @@ def test_expected_values(self, input_media_photo): assert input_media_photo.has_spoiler == self.has_spoiler assert input_media_photo.show_caption_above_media == self.show_caption_above_media + assert isinstance(input_media_photo, InputMedia) + assert isinstance(input_media_photo, InputPollMedia) + assert isinstance(input_media_photo, InputPollOptionMedia) + def test_caption_entities_always_tuple(self): input_media_photo = InputMediaPhoto(self.media) assert input_media_photo.caption_entities == () @@ -337,6 +485,80 @@ def test_with_local_files(self): input_media_photo = InputMediaPhoto(data_file("telegram.mp4")) assert input_media_photo.media == data_file("telegram.mp4").as_uri() + def test_effective_filename(self, photo_file): + inst = InputMediaPhoto( + photo_file, + "caption", + "parse_mode", + [], + "pos_filename_depr", + ) + assert inst.media.filename == "pos_filename_depr" + + inst = InputMediaPhoto( + photo_file, + filename="kw_only_filename", + ) + assert inst.media.filename == "kw_only_filename" + + # Deprecated, but for completeness + inst = InputMediaPhoto( + photo_file, + filename_depr="kw_filename_depr", + ) + assert inst.media.filename == "kw_filename_depr" + + def test_filename_depr_mutually_exclusive_filename(self, photo_file): + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaPhoto( + photo_file, + "caption", + "parse_mode", + [], + "filename_depr", + filename="kw_filename", + ) + + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaPhoto( + photo_file, + filename_depr="filename_depr", + filename="kw_filename", + ) + + def test_positional_filename_deprecated(self, photo_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaPhoto( + photo_file, + "caption", + "parse_mode", + [], + "filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + + def test_keyword_filename_depr_deprecated(self, photo_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaPhoto( + photo_file, + filename_depr="filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + class InputMediaAnimationTestBase: type_ = "animation" @@ -369,6 +591,10 @@ def test_expected_values(self, input_media_animation): assert input_media_animation.show_caption_above_media == self.show_caption_above_media assert input_media_animation._duration == self.duration + assert isinstance(input_media_animation, InputMedia) + assert isinstance(input_media_animation, InputPollMedia) + assert isinstance(input_media_animation, InputPollOptionMedia) + def test_caption_entities_always_tuple(self): input_media_animation = InputMediaAnimation(self.media) assert input_media_animation.caption_entities == () @@ -433,6 +659,89 @@ def test_with_local_files(self): assert input_media_animation.media == data_file("telegram.mp4").as_uri() assert input_media_animation.thumbnail == data_file("telegram.jpg").as_uri() + def test_effective_filename(self, animation_file): + inst = InputMediaAnimation( + animation_file, + "caption", + "parse_mode", + 24, + 24, + 10, + [], + "pos_filename_depr", + ) + assert inst.media.filename == "pos_filename_depr" + + inst = InputMediaAnimation( + animation_file, + filename="kw_only_filename", + ) + assert inst.media.filename == "kw_only_filename" + + # Deprecated, but for completeness + inst = InputMediaAnimation( + animation_file, + filename_depr="kw_filename_depr", + ) + assert inst.media.filename == "kw_filename_depr" + + def test_filename_depr_mutually_exclusive_filename(self, animation_file): + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaAnimation( + animation_file, + "caption", + "parse_mode", + 24, + 24, + 10, + [], + "pos_filename_depr", + filename="kw_filename", + ) + + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaAnimation( + animation_file, + filename_depr="filename_depr", + filename="kw_filename", + ) + + def test_positional_filename_deprecated(self, animation_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaAnimation( + animation_file, + "caption", + "parse_mode", + 24, + 24, + 10, + [], + "pos_filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + + def test_keyword_filename_depr_deprecated(self, animation_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaAnimation( + animation_file, + filename_depr="filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + class InputMediaAudioTestBase: type_ = "audio" @@ -463,6 +772,10 @@ def test_expected_values(self, input_media_audio): assert input_media_audio.caption_entities == tuple(self.caption_entities) assert isinstance(input_media_audio.thumbnail, InputFile) + assert isinstance(input_media_audio, InputMedia) + assert isinstance(input_media_audio, InputPollMedia) + assert not isinstance(input_media_audio, InputPollOptionMedia) + def test_caption_entities_always_tuple(self): input_media_audio = InputMediaAudio(self.media) assert input_media_audio.caption_entities == () @@ -526,6 +839,89 @@ def test_with_local_files(self): assert input_media_audio.media == data_file("telegram.mp4").as_uri() assert input_media_audio.thumbnail == data_file("telegram.jpg").as_uri() + def test_effective_filename(self, audio_file): + inst = InputMediaAudio( + audio_file, + "caption", + "parse_mode", + 10, + "performer", + "title", + [], + "pos_filename_depr", + ) + assert inst.media.filename == "pos_filename_depr" + + inst = InputMediaAudio( + audio_file, + filename="kw_only_filename", + ) + assert inst.media.filename == "kw_only_filename" + + # Deprecated, but for completeness + inst = InputMediaAudio( + audio_file, + filename_depr="kw_filename_depr", + ) + assert inst.media.filename == "kw_filename_depr" + + def test_filename_depr_mutually_exclusive_filename(self, audio_file): + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaAudio( + audio_file, + "caption", + "parse_mode", + 10, + "performer", + "title", + [], + "pos_filename_depr", + filename="kw_filename", + ) + + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaAudio( + audio_file, + filename_depr="filename_depr", + filename="kw_filename", + ) + + def test_positional_filename_deprecated(self, audio_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaAudio( + audio_file, + "caption", + "parse_mode", + 10, + "performer", + "title", + [], + "pos_filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + + def test_keyword_filename_depr_deprecated(self, audio_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaAudio( + audio_file, + filename_depr="filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + class InputMediaDocumentTestBase: type_ = "document" @@ -536,6 +932,136 @@ class InputMediaDocumentTestBase: disable_content_type_detection = True +class InputMediaLocationTestBase: + type_ = "location" + latitude = 1.0 + longitude = 2.0 + horizontal_accuracy = 10.0 + + +class TestInputMediaLocationWithoutRequest(InputMediaLocationTestBase): + def test_slot_behaviour(self, input_media_location): + inst = input_media_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, input_media_location): + assert input_media_location.type == self.type_ + assert input_media_location.latitude == self.latitude + assert input_media_location.longitude == self.longitude + assert input_media_location.horizontal_accuracy == self.horizontal_accuracy + + assert isinstance(input_media_location, InputPollMedia) + assert isinstance(input_media_location, InputPollOptionMedia) + assert not isinstance(input_media_location, InputMedia) + + def test_to_dict(self, input_media_location): + input_media_location_dict = input_media_location.to_dict() + assert input_media_location_dict["type"] == input_media_location.type + assert input_media_location_dict["latitude"] == input_media_location.latitude + assert input_media_location_dict["longitude"] == input_media_location.longitude + assert ( + input_media_location_dict["horizontal_accuracy"] + == input_media_location.horizontal_accuracy + ) + + +class InputMediaVenueTestBase: + type_ = "venue" + latitude = 1.0 + longitude = 2.0 + title = "title" + address = "address" + foursquare_id = "foursquare_id" + foursquare_type = "food/icecream" + google_place_id = "google_place_id" + google_place_type = "restaurant" + + +class TestInputMediaVenueWithoutRequest(InputMediaVenueTestBase): + def test_slot_behaviour(self, input_media_venue): + inst = input_media_venue + 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_media_venue): + assert input_media_venue.type == self.type_ + assert input_media_venue.latitude == self.latitude + assert input_media_venue.longitude == self.longitude + assert input_media_venue.title == self.title + assert input_media_venue.address == self.address + assert input_media_venue.foursquare_id == self.foursquare_id + assert input_media_venue.foursquare_type == self.foursquare_type + assert input_media_venue.google_place_id == self.google_place_id + assert input_media_venue.google_place_type == self.google_place_type + + assert isinstance(input_media_venue, InputPollMedia) + assert isinstance(input_media_venue, InputPollOptionMedia) + assert not isinstance(input_media_venue, InputMedia) + + def test_to_dict(self, input_media_venue): + input_media_venue_dict = input_media_venue.to_dict() + assert input_media_venue_dict["type"] == input_media_venue.type + assert input_media_venue_dict["latitude"] == input_media_venue.latitude + assert input_media_venue_dict["longitude"] == input_media_venue.longitude + assert input_media_venue_dict["title"] == input_media_venue.title + assert input_media_venue_dict["address"] == input_media_venue.address + assert input_media_venue_dict["foursquare_id"] == input_media_venue.foursquare_id + assert input_media_venue_dict["foursquare_type"] == input_media_venue.foursquare_type + assert input_media_venue_dict["google_place_id"] == input_media_venue.google_place_id + assert input_media_venue_dict["google_place_type"] == input_media_venue.google_place_type + + +class InputMediaStickerTestBase: + type_ = "sticker" + media = "NOTAREALFILEID" + emoji = "💪" + + +class TestInputMediaStickerWithoutRequest(InputMediaStickerTestBase): + def test_slot_behaviour(self, input_media_sticker): + inst = input_media_sticker + 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_media_sticker): + assert input_media_sticker.type == self.type_ + assert input_media_sticker.media == self.media + assert input_media_sticker.emoji == self.emoji + + assert isinstance(input_media_sticker, InputPollOptionMedia) + assert not isinstance(input_media_sticker, InputPollMedia) + assert not isinstance(input_media_sticker, InputMedia) + + def test_to_dict(self, input_media_sticker): + input_media_sticker_dict = input_media_sticker.to_dict() + assert input_media_sticker_dict["type"] == input_media_sticker.type + assert input_media_sticker_dict["media"] == input_media_sticker.media + assert input_media_sticker_dict["emoji"] == input_media_sticker.emoji + + def test_with_sticker(self, sticker): + input_media_sticker = InputMediaSticker(sticker, emoji=self.emoji) + assert input_media_sticker.type == self.type_ + assert input_media_sticker.media == sticker.file_id + assert input_media_sticker.emoji == self.emoji + + def test_with_sticker_file(self, sticker_file): + input_media_sticker = InputMediaSticker(sticker_file, emoji=self.emoji) + assert input_media_sticker.type == self.type_ + assert isinstance(input_media_sticker.media, InputFile) + assert input_media_sticker.emoji == self.emoji + + def test_with_local_files(self): + input_media_sticker = InputMediaSticker( + data_file("telegram_sticker.png"), emoji=self.emoji + ) + assert input_media_sticker.media == data_file("telegram_sticker.png").as_uri() + assert input_media_sticker.emoji == self.emoji + + class TestInputMediaDocumentWithoutRequest(InputMediaDocumentTestBase): def test_slot_behaviour(self, input_media_document): inst = input_media_document @@ -555,6 +1081,10 @@ def test_expected_values(self, input_media_document): ) assert isinstance(input_media_document.thumbnail, InputFile) + assert isinstance(input_media_document, InputMedia) + assert isinstance(input_media_document, InputPollMedia) + assert not isinstance(input_media_document, InputPollOptionMedia) + def test_caption_entities_always_tuple(self): input_media_document = InputMediaDocument(self.media) assert input_media_document.caption_entities == () @@ -594,6 +1124,83 @@ def test_with_local_files(self): assert input_media_document.media == data_file("telegram.mp4").as_uri() assert input_media_document.thumbnail == data_file("telegram.jpg").as_uri() + def test_effective_filename(self, document_file): + inst = InputMediaDocument( + document_file, + "caption", + "parse_mode", + True, + [], + "pos_filename_depr", + ) + assert inst.media.filename == "pos_filename_depr" + + inst = InputMediaDocument( + document_file, + filename="kw_only_filename", + ) + assert inst.media.filename == "kw_only_filename" + + # Deprecated, but for completeness + inst = InputMediaDocument( + document_file, + filename_depr="kw_filename_depr", + ) + assert inst.media.filename == "kw_filename_depr" + + def test_filename_depr_mutually_exclusive_filename(self, document_file): + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaDocument( + document_file, + "caption", + "parse_mode", + True, + [], + "pos_filename_depr", + filename="kw_filename", + ) + + with pytest.raises( + ValueError, match="`filename_depr` and `filename` are mutually exclusive" + ): + InputMediaDocument( + document_file, + filename_depr="filename_depr", + filename="kw_filename", + ) + + def test_positional_filename_deprecated(self, document_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaDocument( + document_file, + "caption", + "parse_mode", + True, + [], + "pos_filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + + def test_keyword_filename_depr_deprecated(self, document_file): + with pytest.warns( + PTBDeprecationWarning, + match="Positional.*`filename`.*keyword.*`filename_depr`.*deprecated", + ) as record: + InputMediaDocument( + document_file, + filename_depr="filename_depr", + ) + + assert record[0].category == PTBDeprecationWarning + assert record[0].filename == __file__, "wrong stacklevel!" + class TestInputPaidMediaPhotoWithoutRequest(InputMediaPhotoTestBase): def test_slot_behaviour(self, input_paid_media_photo): @@ -612,13 +1219,13 @@ def test_to_dict(self, input_paid_media_photo): assert input_paid_media_photo_dict["media"] == input_paid_media_photo.media def test_with_photo(self, photo): - # fixture found in test_photo + # fixture found in conftest.py input_paid_media_photo = InputPaidMediaPhoto(photo) assert input_paid_media_photo.type == self.type_ assert input_paid_media_photo.media == photo.file_id def test_with_photo_file(self, photo_file): - # fixture found in test_photo + # fixture found in conftest.py input_paid_media_photo = InputPaidMediaPhoto(photo_file) assert input_paid_media_photo.type == self.type_ assert isinstance(input_paid_media_photo.media, InputFile) @@ -628,6 +1235,76 @@ def test_with_local_files(self): assert input_paid_media_photo.media == data_file("telegram.jpg").as_uri() +class InputMediaLivePhotoTestBase: + type_ = "live_photo" + media = "NOTAREALFILEID" + photo = "NOTAREALFILEID" + caption = "My Caption" + parse_mode = "Markdown" + caption_entities = [MessageEntity(MessageEntity.BOLD, 0, 2)] + show_caption_above_media = True + has_spoiler = True + + +class TestInputMediaLivePhotoWithoutRequest(InputMediaLivePhotoTestBase): + def test_slot_behaviour(self, input_media_live_photo): + inst = input_media_live_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_media_live_photo): + assert input_media_live_photo.type == self.type_ + assert input_media_live_photo.media == self.media + assert input_media_live_photo.photo == self.photo + assert input_media_live_photo.caption == self.caption + assert input_media_live_photo.parse_mode == self.parse_mode + assert input_media_live_photo.caption_entities == tuple(self.caption_entities) + assert input_media_live_photo.show_caption_above_media == self.show_caption_above_media + assert input_media_live_photo.has_spoiler == self.has_spoiler + + def test_caption_entities_always_tuple(self): + input_media_live_photo = InputMediaLivePhoto(self.media, self.photo) + assert input_media_live_photo.caption_entities == () + + def test_to_dict(self, input_media_live_photo): + input_media_live_photo_dict = input_media_live_photo.to_dict() + assert input_media_live_photo_dict["type"] == input_media_live_photo.type + assert input_media_live_photo_dict["media"] == input_media_live_photo.media + assert input_media_live_photo_dict["photo"] == input_media_live_photo.photo + assert input_media_live_photo_dict["caption"] == input_media_live_photo.caption + assert input_media_live_photo_dict["parse_mode"] == input_media_live_photo.parse_mode + assert input_media_live_photo_dict["caption_entities"] == [ + ce.to_dict() for ce in input_media_live_photo.caption_entities + ] + assert ( + input_media_live_photo_dict["show_caption_above_media"] + == input_media_live_photo.show_caption_above_media + ) + assert input_media_live_photo_dict["has_spoiler"] == input_media_live_photo.has_spoiler + + def test_with_photo_and_video(self, video, photo): + # fixtures found in conftest.py + input_media_live_photo = InputMediaLivePhoto(video, photo) + assert input_media_live_photo.type == self.type_ + assert input_media_live_photo.media == video.file_id + assert input_media_live_photo.photo == photo.file_id + + def test_with_photo_and_video_files(self, video_file, photo_file): + # fixture found in conftest.py + input_media_live_photo = InputMediaLivePhoto(video_file, photo_file) + assert input_media_live_photo.type == self.type_ + assert isinstance(input_media_live_photo.media, InputFile) + assert isinstance(input_media_live_photo.photo, InputFile) + + def test_with_local_files(self): + input_media_live_photo = InputMediaLivePhoto( + media=data_file("telegram.mp4"), photo=data_file("telegram.jpg") + ) + assert input_media_live_photo.media == data_file("telegram.mp4").as_uri() + assert input_media_live_photo.photo == data_file("telegram.jpg").as_uri() + + class TestInputPaidMediaVideoWithoutRequest(InputMediaVideoTestBase): def test_slot_behaviour(self, input_paid_media_video): inst = input_paid_media_video @@ -711,6 +1388,46 @@ def test_with_local_files(self): assert input_paid_media_video.cover == data_file("telegram.jpg").as_uri() +class TestInputPaidMediaLivePhotoWithoutRequest(InputMediaLivePhotoTestBase): + def test_slot_behaviour(self, input_paid_media_live_photo): + inst = input_paid_media_live_photo + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_expected_values(self, input_paid_media_live_photo): + assert input_paid_media_live_photo.type == self.type_ + assert input_paid_media_live_photo.media == self.media + assert input_paid_media_live_photo.photo == self.photo + + def test_to_dict(self, input_paid_media_live_photo): + input_paid_media_live_photo_dict = input_paid_media_live_photo.to_dict() + assert input_paid_media_live_photo_dict["type"] == input_paid_media_live_photo.type + assert input_paid_media_live_photo_dict["media"] == input_paid_media_live_photo.media + assert input_paid_media_live_photo_dict["photo"] == input_paid_media_live_photo.photo + + def test_with_photo(self, video, photo): + # fixtures found in conftest.py + input_paid_media_live_photo = InputPaidMediaLivePhoto(video, photo) + assert input_paid_media_live_photo.type == self.type_ + assert input_paid_media_live_photo.media == video.file_id + assert input_paid_media_live_photo.photo == photo.file_id + + def test_with_photo_file(self, photo_file): + # fixture found in conftest.py + input_paid_media_live_photo = InputPaidMediaLivePhoto(photo_file, photo_file) + assert input_paid_media_live_photo.type == self.type_ + assert isinstance(input_paid_media_live_photo.media, InputFile) + assert isinstance(input_paid_media_live_photo.photo, InputFile) + + def test_with_local_files(self): + input_paid_media_live_photo = InputPaidMediaLivePhoto( + media=data_file("telegram.mp4"), photo=data_file("telegram.jpg") + ) + assert input_paid_media_live_photo.media == data_file("telegram.mp4").as_uri() + assert input_paid_media_live_photo.photo == data_file("telegram.jpg").as_uri() + + @pytest.fixture(scope="module") def media_group(photo, thumb): return [ @@ -1155,7 +1872,9 @@ async def test_send_media_group_default_parse_mode( @pytest.mark.parametrize( "default_bot", [{"parse_mode": ParseMode.HTML}], indirect=True, ids=["HTML-Bot"] ) - @pytest.mark.parametrize("media_type", ["animation", "document", "audio", "photo", "video"]) + @pytest.mark.parametrize( + "media_type", ["animation", "document", "audio", "live_photo", "photo", "video"] + ) async def test_edit_message_media_default_parse_mode( self, chat_id, @@ -1194,6 +1913,8 @@ def build_media(parse_mode, med_type): return InputMediaPhoto(photo, **kwargs) if med_type == "video": return InputMediaVideo(video, **kwargs) + if med_type == "live_photo": + return InputMediaLivePhoto(video, photo, **kwargs) return None message = await default_bot.send_photo(chat_id, photo) diff --git a/tests/_files/test_livephoto.py b/tests/_files/test_livephoto.py new file mode 100644 index 00000000000..0dba659033e --- /dev/null +++ b/tests/_files/test_livephoto.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that tests a Telegram LivePhoto.""" + +import asyncio +import datetime as dtm +import os +from pathlib import Path + +import pytest + +from telegram import ( + InputFile, + LivePhoto, + MessageEntity, + PhotoSize, + ReplyParameters, + Voice, +) +from telegram.constants import ParseMode +from telegram.error import BadRequest, TelegramError +from telegram.helpers import escape_markdown +from telegram.request import RequestData +from tests.auxil.build_messages import make_message +from tests.auxil.files import data_file +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def live_photo(): + return LivePhoto( + file_id=LivePhotoTestBase.file_id, + file_unique_id=LivePhotoTestBase.file_unique_id, + width=LivePhotoTestBase.width, + height=LivePhotoTestBase.height, + duration=LivePhotoTestBase.duration, + photo=LivePhotoTestBase.photo, + mime_type=LivePhotoTestBase.mime_type, + file_size=LivePhotoTestBase.file_size, + ) + + +class LivePhotoTestBase: + caption = "LivePhotoTest - *Caption*" + width = 360 + height = 640 + duration = dtm.timedelta(seconds=5) + file_size = 326534 + mime_type = "video/mp4" + photo = (PhotoSize("file_id", "unique_id", 640, 360, file_size=0),) + file_id = "5a3128a4d2a04750b5b58397f3b5e812" + file_unique_id = "adc3145fd2e84d95b64d68eaa22aa33e" + + +class TestLivePhotoWithoutRequest(LivePhotoTestBase): + def test_slot_behaviour(self, live_photo): + for attr in live_photo.__slots__: + assert getattr(live_photo, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(live_photo)) == len(set(mro_slots(live_photo))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "file_id": self.file_id, + "file_unique_id": self.file_unique_id, + "width": self.width, + "height": self.height, + "duration": int(self.duration.total_seconds()), + "mime_type": self.mime_type, + "file_size": self.file_size, + "photo": [photo_size.to_dict() for photo_size in self.photo], + } + json_live_photo = LivePhoto.de_json(json_dict, offline_bot) + assert json_live_photo.api_kwargs == {} + + assert json_live_photo.file_id == self.file_id + assert json_live_photo.file_unique_id == self.file_unique_id + assert json_live_photo.width == self.width + assert json_live_photo.height == self.height + assert json_live_photo.duration == self.duration + assert json_live_photo.mime_type == self.mime_type + assert json_live_photo.file_size == self.file_size + assert json_live_photo.photo == self.photo + + def test_to_dict(self, live_photo): + live_photo_dict = live_photo.to_dict() + + assert isinstance(live_photo_dict, dict) + assert live_photo_dict["file_id"] == live_photo.file_id + assert live_photo_dict["file_unique_id"] == live_photo.file_unique_id + assert live_photo_dict["width"] == live_photo.width + assert live_photo_dict["height"] == live_photo.height + assert live_photo_dict["duration"] == int(self.duration.total_seconds()) + assert isinstance(live_photo_dict["duration"], int) + assert live_photo_dict["mime_type"] == live_photo.mime_type + assert live_photo_dict["file_size"] == live_photo.file_size + assert live_photo_dict["photo"] == [p.to_dict() for p in self.photo] + + def test_equality(self, live_photo): + a = LivePhoto( + live_photo.file_id, live_photo.file_unique_id, self.width, self.height, self.duration + ) + b = LivePhoto("", live_photo.file_unique_id, self.width, self.height, self.duration) + c = LivePhoto(live_photo.file_id, live_photo.file_unique_id, 0, 0, 0) + d = LivePhoto("", "", self.width, self.height, self.duration) + e = Voice(live_photo.file_id, live_photo.file_unique_id, self.duration) + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) + + assert a != e + assert hash(a) != hash(e) + + async def test_send_with_live_photo(self, monkeypatch, offline_bot, chat_id, live_photo): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + data = request_data.parameters + return ( + data["live_photo"] == live_photo.file_id + and data["photo"] == live_photo.photo[0].file_id + ) + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + assert await offline_bot.send_live_photo( + chat_id=chat_id, + live_photo=live_photo, + photo=live_photo.photo[0], + ) + + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"parse_mode": ParseMode.HTML}, None), + ({"parse_mode": ParseMode.HTML}, ParseMode.MARKDOWN_V2), + ({"parse_mode": None}, ParseMode.MARKDOWN_V2), + ], + indirect=["default_bot"], + ) + async def test_send_live_photo_default_quote_parse_mode( + self, default_bot, chat_id, live_photo, custom, monkeypatch + ): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters["reply_parameters"].get("quote_parse_mode") == ( + custom or default_bot.defaults.quote_parse_mode + ) + return make_message("dummy reply").to_dict() + + kwargs = {"message_id": "1"} + if custom is not None: + kwargs["quote_parse_mode"] = custom + + monkeypatch.setattr(default_bot.request, "post", make_assertion) + await default_bot.send_live_photo( + chat_id=chat_id, + live_photo=live_photo, + photo=live_photo.photo[0], + reply_parameters=ReplyParameters(**kwargs), + ) + + @pytest.mark.parametrize("local_mode", [True, False]) + async def test_send_live_photo( + self, dummy_message_dict, monkeypatch, offline_bot, chat_id, local_mode + ): + try: + offline_bot._local_mode = local_mode + # For just test that the correct paths are passed as we have no local Bot API set up + test_flag = False + photo = data_file("telegram.jpg") + expected_photo = photo.as_uri() + + live_photo = data_file("telegram.mp4") + expected_live_photo = live_photo.as_uri() + + async def make_assertion(_, data, *args, **kwargs): + nonlocal test_flag + if local_mode: + test_flag = ( + data.get("live_photo") == expected_live_photo + and data.get("photo") == expected_photo + ) + else: + test_flag = isinstance(data.get("live_photo"), InputFile) and isinstance( + data.get("photo"), InputFile + ) + return dummy_message_dict + + monkeypatch.setattr(offline_bot, "_post", make_assertion) + await offline_bot.send_live_photo(chat_id, live_photo=live_photo, photo=photo) + assert test_flag + finally: + offline_bot._local_mode = False + + +class TestLivePhotoWithRequest(LivePhotoTestBase): + async def test_error_send_empty_file(self, bot, chat_id): + with Path(os.devnull).open("rb") as f, pytest.raises(TelegramError): + await bot.send_live_photo(chat_id=chat_id, live_photo=f, photo=f) + + async def test_error_send_empty_file_id(self, bot, chat_id): + with pytest.raises(TelegramError): + await bot.send_live_photo(chat_id=chat_id, live_photo="", photo="") + + async def test_get_and_download(self, bot, real_live_photo, tmp_file): + new_file = await bot.get_file(real_live_photo.file_id) + + assert new_file.file_size == real_live_photo.file_size + assert new_file.file_unique_id == real_live_photo.file_unique_id + assert new_file.file_path.startswith("https://") + + await new_file.download_to_drive(tmp_file) + + assert tmp_file.is_file() + + async def test_send_resend(self, bot, chat_id, real_live_photo, photo_file): + message = await bot.send_live_photo( + chat_id=chat_id, live_photo=real_live_photo.file_id, photo=photo_file + ) + assert message.live_photo == real_live_photo + + async def test_send_all_args(self, bot, chat_id, video_file, live_photo, photo_file): + message = await bot.send_live_photo( + chat_id, + live_photo=video_file, + photo=photo_file, + caption=self.caption, + disable_notification=False, + protect_content=True, + filename="telegram_custom.png", + parse_mode="Markdown", + ) + + assert isinstance(message.live_photo, LivePhoto) + assert isinstance(message.live_photo.file_id, str) + assert message.live_photo.file_id + assert isinstance(message.live_photo.file_unique_id, str) + assert message.live_photo.file_unique_id + assert message.live_photo.photo + assert isinstance(message.live_photo.photo[0], PhotoSize) + assert message.live_photo.mime_type == live_photo.mime_type + assert message.live_photo.file_size == live_photo.file_size + assert message.caption == self.caption.replace("*", "") + assert message.has_protected_content + + @pytest.mark.parametrize("default_bot", [{"protect_content": True}], indirect=True) + async def test_send_live_photo_default_protect_content( + self, + chat_id, + default_bot, + real_live_photo, + ): + tasks = asyncio.gather( + default_bot.send_live_photo( + chat_id, photo=real_live_photo.photo[0], live_photo=real_live_photo + ), + default_bot.send_live_photo( + chat_id, + photo=real_live_photo.photo[0], + live_photo=real_live_photo, + protect_content=False, + ), + ) + protected, unprotected = await tasks + assert protected.has_protected_content + assert not unprotected.has_protected_content + + async def test_send_live_photo_caption_entities(self, bot, chat_id, video_file, photo_file): + test_string = "Italic Bold Code" + entities = [ + MessageEntity(MessageEntity.ITALIC, 0, 6), + MessageEntity(MessageEntity.ITALIC, 7, 4), + MessageEntity(MessageEntity.ITALIC, 12, 4), + ] + message = await bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + caption=test_string, + caption_entities=entities, + ) + + assert message.caption == test_string + assert message.caption_entities == tuple(entities) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + async def test_send_live_photo_default_parse_mode_1( + self, default_bot, chat_id, video_file, photo_file + ): + test_string = "Italic Bold Code" + test_markdown_string = "_Italic_ *Bold* `Code`" + + message = await default_bot.send_live_photo( + chat_id, photo=photo_file, live_photo=video_file, caption=test_markdown_string + ) + assert message.caption_markdown == test_markdown_string + assert message.caption == test_string + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + async def test_send_live_photo_default_parse_mode_2( + self, default_bot, chat_id, video_file, photo_file + ): + test_markdown_string = "_Italic_ *Bold* `Code`" + + message = await default_bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + caption=test_markdown_string, + parse_mode=None, + ) + assert message.caption == test_markdown_string + assert message.caption_markdown == escape_markdown(test_markdown_string) + + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) + async def test_send_live_photo_default_parse_mode_3( + self, default_bot, chat_id, video_file, photo_file + ): + test_markdown_string = "_Italic_ *Bold* `Code`" + + message = await default_bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + caption=test_markdown_string, + parse_mode="HTML", + ) + assert message.caption == test_markdown_string + assert message.caption_markdown == escape_markdown(test_markdown_string) + + @pytest.mark.parametrize( + ("default_bot", "custom"), + [ + ({"allow_sending_without_reply": True}, None), + ({"allow_sending_without_reply": False}, None), + ({"allow_sending_without_reply": False}, True), + ], + indirect=["default_bot"], + ) + async def test_send_live_photo_default_allow_sending_without_reply( + self, default_bot, chat_id, video_file, photo_file, custom + ): + reply_to_message = await default_bot.send_message(chat_id, "test") + await reply_to_message.delete() + if custom is not None: + message = await default_bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + allow_sending_without_reply=custom, + reply_to_message_id=reply_to_message.message_id, + ) + assert message.reply_to_message is None + elif default_bot.defaults.allow_sending_without_reply: + message = await default_bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + reply_to_message_id=reply_to_message.message_id, + ) + assert message.reply_to_message is None + else: + with pytest.raises(BadRequest, match="Message to be replied not found"): + await default_bot.send_live_photo( + chat_id, + photo=photo_file, + live_photo=video_file, + reply_to_message_id=reply_to_message.message_id, + ) diff --git a/tests/_files/test_photo.py b/tests/_files/test_photo.py index 83ba28ea701..93e0ac40ea1 100644 --- a/tests/_files/test_photo.py +++ b/tests/_files/test_photo.py @@ -75,7 +75,7 @@ def test_expected_values(self, photo, thumb): assert thumb.height == 90 # File sizes don't seem to be consistent, so we use the values that we have observed # so far - assert thumb.file_size in [1475, 1477] + assert thumb.file_size in [1474, 1475, 1477] def test_de_json(self, offline_bot, photo): json_dict = { diff --git a/tests/_files/test_video.py b/tests/_files/test_video.py index 8be6d9df2cf..4a34e126668 100644 --- a/tests/_files/test_video.py +++ b/tests/_files/test_video.py @@ -319,7 +319,7 @@ async def test_send_all_args( assert message.caption == self.caption.replace("*", "") - assert message.video.thumbnail.file_size == self.thumb_file_size + assert message.video.thumbnail.file_size in [self.thumb_file_size, 1769] assert message.video.thumbnail.width == self.thumb_width assert message.video.thumbnail.height == self.thumb_height diff --git a/tests/_files/test_videonote.py b/tests/_files/test_videonote.py index 3af79baf497..c2993021883 100644 --- a/tests/_files/test_videonote.py +++ b/tests/_files/test_videonote.py @@ -263,7 +263,7 @@ async def test_send_all_args( assert message.video_note.duration == video_note.duration assert message.video_note.file_size == video_note.file_size - assert message.video_note.thumbnail.file_size == self.thumb_file_size + assert message.video_note.thumbnail.file_size in [self.thumb_file_size, 11631] assert message.video_note.thumbnail.width == self.thumb_width assert message.video_note.thumbnail.height == self.thumb_height assert message.has_protected_content diff --git a/tests/auxil/bot_method_checks.py b/tests/auxil/bot_method_checks.py index 6714e65655a..8624b45a739 100644 --- a/tests/auxil/bot_method_checks.py +++ b/tests/auxil/bot_method_checks.py @@ -45,7 +45,7 @@ ) from telegram._utils.datetime import to_timestamp from telegram._utils.defaultvalue import DEFAULT_NONE, DefaultValue -from telegram.constants import InputMediaType +from telegram.constants import BaseInputMediaType from telegram.ext import Defaults, ExtBot from telegram.request import RequestData from tests.auxil.dummy_objects import get_dummy_object_json_dict @@ -512,7 +512,7 @@ def check_input_media(m: dict): media = data.pop("media", None) paid_media = media and data.pop("star_count", None) if media and not paid_media: - if isinstance(media, dict) and isinstance(media.get("type", None), InputMediaType): + if isinstance(media, dict) and isinstance(media.get("type", None), BaseInputMediaType): check_input_media(media) else: for m in media: diff --git a/tests/auxil/dummy_objects.py b/tests/auxil/dummy_objects.py index 611e19a67f0..41c938c70d0 100644 --- a/tests/auxil/dummy_objects.py +++ b/tests/auxil/dummy_objects.py @@ -4,6 +4,7 @@ from telegram import ( AcceptedGiftTypes, + BotAccessSettings, BotCommand, BotDescription, BotName, @@ -29,6 +30,8 @@ Poll, PollOption, PreparedInlineMessage, + PreparedKeyboardButton, + SentGuestMessage, SentWebAppMessage, StarAmount, StarTransaction, @@ -62,6 +65,7 @@ _PREPARED_DUMMY_OBJECTS: dict[str, object] = { "bool": True, + "BotAccessSettings": BotAccessSettings(is_access_restricted=True, added_users=[_DUMMY_USER]), "BotCommand": BotCommand(command="dummy_command", description="dummy_description"), "BotDescription": BotDescription(description="dummy_description"), "BotName": BotName(name="dummy_name"), @@ -126,14 +130,18 @@ "Poll": Poll( id="dummy_id", question="dummy_question", - options=[PollOption(text="dummy_text", voter_count=1)], + options=[PollOption(text="dummy_text", voter_count=1, persistent_id="persistent_id")], is_closed=False, is_anonymous=False, total_voter_count=1, type="dummy_type", allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ), + "PreparedKeyboardButton": PreparedKeyboardButton(id=1234), "PreparedInlineMessage": PreparedInlineMessage(id="dummy_id", expiration_date=_DUMMY_DATE), + "SentGuestMessage": SentGuestMessage(inline_message_id="dummy_inline_message_id"), "SentWebAppMessage": SentWebAppMessage(inline_message_id="dummy_inline_message_id"), "StarAmount": StarAmount(amount=100, nanostar_amount=356), "StarTransactions": StarTransactions( diff --git a/tests/ext/test_filters.py b/tests/ext/test_filters.py index a045d2a6dd7..8b101b73f4c 100644 --- a/tests/ext/test_filters.py +++ b/tests/ext/test_filters.py @@ -276,6 +276,18 @@ def test_filters_merged_with_regex(self, update): result = (filters.COMMAND | filters.Regex(r"linked param")).check_update(update) assert result is True + def test_merged_filter_or_data_filter(self, update): + sre_type = type(re.match("", "")) + update.message.text = "deep-linked param" + update.message.entities = [] + # COMMAND doesn't match; or_filter (Regex, a data filter) should return match data + result = (filters.COMMAND | filters.Regex(r"linked param")).check_update(update) + assert result + assert isinstance(result, dict) + matches = result["matches"] + assert isinstance(matches, list) + assert all(type(res) is sre_type for res in matches) + def test_regex_complex_merges(self, update, message_origin_user): sre_type = type(re.match("", "")) update.message.text = "test it out" @@ -659,7 +671,13 @@ def test_filters_document_type(self, update): assert not filters.Document.AUDIO.check_update(update) update.message.document.mime_type = "application/octet-stream" - assert filters.Document.EXE.check_update(update) + # Python 3.15 changes the "exe" mime type to application/vnd.microsoft.portable-executable + if int(platform.python_version_tuple()[1]) <= 14: + assert filters.Document.EXE.check_update(update) + else: + assert not filters.Document.EXE.check_update(update) + update.message.document.mime_type = "application/vnd.microsoft.portable-executable" + assert filters.Document.EXE.check_update(update) assert filters.Document.APPLICATION.check_update(update) assert not filters.Document.DOCX.check_update(update) assert not filters.Document.AUDIO.check_update(update) @@ -933,6 +951,11 @@ def test_filters_location(self, update): update.message.location = "test" assert filters.LOCATION.check_update(update) + def test_filters_live_photo(self, update): + assert not filters.LIVE_PHOTO.check_update(update) + update.message.live_photo = "test" + assert filters.LIVE_PHOTO.check_update(update) + def test_filters_venue(self, update): assert not filters.VENUE.check_update(update) update.message.venue = "test" @@ -1171,6 +1194,21 @@ def test_filters_status_update(self, update): assert filters.StatusUpdate.CHAT_OWNER_LEFT.check_update(update) update.message.chat_owner_left = None + update.message.poll_option_added = "poll_option_added" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.POLL_OPTION_ADDED.check_update(update) + update.message.poll_option_added = None + + update.message.poll_option_deleted = "poll_option_deleted" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.POLL_OPTION_DELETED.check_update(update) + update.message.poll_option_deleted = None + + update.message.managed_bot_created = "test" + assert filters.StatusUpdate.ALL.check_update(update) + assert filters.StatusUpdate.MANAGED_BOT_CREATED.check_update(update) + update.message.managed_bot_created = None + def test_filters_forwarded(self, update): assert filters.FORWARDED.check_update(update) update.message.forward_origin = MessageOriginHiddenUser(dtm.datetime.utcnow(), 1) @@ -2444,6 +2482,7 @@ def test_update_type_message(self, update): assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_update_type_edited_message(self, update): update.edited_message, update.message = update.message, update.edited_message @@ -2457,6 +2496,7 @@ def test_update_type_edited_message(self, update): assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_update_type_channel_post(self, update): update.channel_post, update.message = update.message, update.edited_message @@ -2470,6 +2510,7 @@ def test_update_type_channel_post(self, update): assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_update_type_edited_channel_post(self, update): update.edited_channel_post, update.message = update.message, update.edited_message @@ -2483,6 +2524,7 @@ def test_update_type_edited_channel_post(self, update): assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_update_type_business_message(self, update): update.business_message, update.message = update.message, update.edited_message @@ -2496,6 +2538,7 @@ def test_update_type_business_message(self, update): assert filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_update_type_edited_business_message(self, update): update.edited_business_message, update.message = update.message, update.edited_message @@ -2509,6 +2552,21 @@ def test_update_type_edited_business_message(self, update): assert filters.UpdateType.BUSINESS_MESSAGES.check_update(update) assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) assert filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.GUEST_MESSAGE.check_update(update) + + def test_update_type_guest_message(self, update): + update.guest_message, update.message = update.message, update.edited_message + assert not filters.UpdateType.MESSAGE.check_update(update) + assert not filters.UpdateType.EDITED_MESSAGE.check_update(update) + assert not filters.UpdateType.MESSAGES.check_update(update) + assert not filters.UpdateType.CHANNEL_POST.check_update(update) + assert not filters.UpdateType.EDITED_CHANNEL_POST.check_update(update) + assert not filters.UpdateType.CHANNEL_POSTS.check_update(update) + assert not filters.UpdateType.EDITED.check_update(update) + assert not filters.UpdateType.BUSINESS_MESSAGES.check_update(update) + assert not filters.UpdateType.BUSINESS_MESSAGE.check_update(update) + assert not filters.UpdateType.EDITED_BUSINESS_MESSAGE.check_update(update) + assert filters.UpdateType.GUEST_MESSAGE.check_update(update) def test_merged_short_circuit_and(self, update, base_class): update.message.text = "/test" diff --git a/tests/ext/test_managedbotupdatedhandler.py b/tests/ext/test_managedbotupdatedhandler.py new file mode 100644 index 00000000000..9fe5781d1d2 --- /dev/null +++ b/tests/ext/test_managedbotupdatedhandler.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +import asyncio +import datetime as dtm + +import pytest + +from telegram import ( + Bot, + CallbackQuery, + Chat, + ChosenInlineResult, + ManagedBotUpdated, + Message, + PreCheckoutQuery, + ShippingQuery, + Update, + User, +) +from telegram._utils.datetime import UTC +from telegram.ext import CallbackContext, JobQueue, ManagedBotUpdatedHandler +from tests.auxil.slots import mro_slots + +message = Message(1, None, Chat(1, ""), from_user=User(1, "", False), text="Text") + +params = [ + {"message": message}, + {"edited_message": message}, + {"callback_query": CallbackQuery(1, User(1, "", False), "chat", message=message)}, + {"channel_post": message}, + {"edited_channel_post": message}, + {"chosen_inline_result": ChosenInlineResult("id", User(1, "", False), "")}, + {"shipping_query": ShippingQuery("id", User(1, "", False), "", None)}, + {"pre_checkout_query": PreCheckoutQuery("id", User(1, "", False), "", 0, "")}, + {"callback_query": CallbackQuery(1, User(1, "", False), "chat")}, +] + +ids = ( + "message", + "edited_message", + "callback_query", + "channel_post", + "edited_channel_post", + "chosen_inline_result", + "shipping_query", + "pre_checkout_query", + "callback_query_without_message", +) + + +@pytest.fixture(scope="class", params=params, ids=ids) +def false_update(request): + return Update(update_id=2, **request.param) + + +@pytest.fixture(scope="class") +def time(): + return dtm.datetime.now(tz=UTC) + + +@pytest.fixture(scope="class") +def managed_bot_updated(bot): + bmd = ManagedBotUpdated( + user=User(1, "", False, username="user_a"), + bot=User(2, "", True, username="test_bot"), + ) + bmd.set_bot(bot) + return bmd + + +@pytest.fixture +def managed_bot_updated_update(bot, managed_bot_updated): + return Update(0, managed_bot=managed_bot_updated) + + +class TestManagedBotUpdatedHandler: + test_flag = False + + def test_slot_behaviour(self): + action = ManagedBotUpdatedHandler(self.callback) + for attr in action.__slots__: + assert getattr(action, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(action)) == len(set(mro_slots(action))), "duplicate slot" + + @pytest.fixture(autouse=True) + def _reset(self): + self.test_flag = False + + async def callback(self, update, context): + self.test_flag = ( + isinstance(context, CallbackContext) + and isinstance(context.bot, Bot) + and isinstance(update, Update) + and isinstance(context.update_queue, asyncio.Queue) + and isinstance(context.job_queue, JobQueue) + and isinstance(context.user_data, dict) + and isinstance(context.bot_data, dict) + and isinstance( + update.managed_bot, + ManagedBotUpdated, + ) + ) + + def test_with_user_id(self, managed_bot_updated_update): + handler = ManagedBotUpdatedHandler(self.callback, user_id=1) + assert handler.check_update(managed_bot_updated_update) + handler = ManagedBotUpdatedHandler(self.callback, user_id=[1]) + assert handler.check_update(managed_bot_updated_update) + handler = ManagedBotUpdatedHandler(self.callback, user_id=2) + assert handler.check_update(managed_bot_updated_update) + handler = ManagedBotUpdatedHandler(self.callback, user_id=2, username="@user_a") + assert handler.check_update(managed_bot_updated_update) + handler = ManagedBotUpdatedHandler(self.callback, user_id=[1, 2]) + assert handler.check_update(managed_bot_updated_update) + + handler = ManagedBotUpdatedHandler(self.callback, user_id=3) + assert not handler.check_update(managed_bot_updated_update) + handler = ManagedBotUpdatedHandler(self.callback, user_id=[3]) + assert not handler.check_update(managed_bot_updated_update) + + def test_with_username(self, managed_bot_updated_update): + handler = ManagedBotUpdatedHandler(self.callback, username="user_a") + assert handler.check_update(managed_bot_updated_update) + handler = ManagedBotUpdatedHandler(self.callback, username="@user_a") + assert handler.check_update(managed_bot_updated_update) + handler = ManagedBotUpdatedHandler(self.callback, username=["user_a"]) + assert handler.check_update(managed_bot_updated_update) + handler = ManagedBotUpdatedHandler(self.callback, username=["@user_a"]) + assert handler.check_update(managed_bot_updated_update) + handler = ManagedBotUpdatedHandler(self.callback, username=["test_bot"]) + assert handler.check_update(managed_bot_updated_update) + handler = ManagedBotUpdatedHandler(self.callback, username=["@test_bot"]) + assert handler.check_update(managed_bot_updated_update) + handler = ManagedBotUpdatedHandler(self.callback, user_id=[2], username="@user_b") + assert handler.check_update(managed_bot_updated_update) + + handler = ManagedBotUpdatedHandler(self.callback, username="user_b") + assert not handler.check_update(managed_bot_updated_update) + handler = ManagedBotUpdatedHandler(self.callback, username="@user_b") + assert not handler.check_update(managed_bot_updated_update) + handler = ManagedBotUpdatedHandler(self.callback, username=["user_b"]) + assert not handler.check_update(managed_bot_updated_update) + handler = ManagedBotUpdatedHandler(self.callback, username=["@user_b"]) + assert not handler.check_update(managed_bot_updated_update) + + managed_bot_updated_update.managed_bot.user._unfreeze() + managed_bot_updated_update.managed_bot.user.username = None + assert not handler.check_update(managed_bot_updated_update) + + def test_other_update_types(self, false_update): + handler = ManagedBotUpdatedHandler(self.callback) + assert not handler.check_update(false_update) + assert not handler.check_update(True) + + async def test_context(self, app, managed_bot_updated_update): + handler = ManagedBotUpdatedHandler(callback=self.callback) + app.add_handler(handler) + + async with app: + await app.process_update(managed_bot_updated_update) + assert self.test_flag diff --git a/tests/ext/test_messagehandler.py b/tests/ext/test_messagehandler.py index aeb2e1b0391..8df5de7d1ef 100644 --- a/tests/ext/test_messagehandler.py +++ b/tests/ext/test_messagehandler.py @@ -95,22 +95,7 @@ async def callback(self, update, context): and isinstance(context.job_queue, JobQueue) and isinstance(context.chat_data, dict) and isinstance(context.bot_data, dict) - and ( - ( - isinstance(context.user_data, dict) - and ( - isinstance(update.message, Message) - or isinstance(update.edited_message, Message) - ) - ) - or ( - context.user_data is None - and ( - isinstance(update.channel_post, Message) - or isinstance(update.edited_channel_post, Message) - ) - ) - ) + and isinstance(context.user_data, dict) ) def callback_regex1(self, update, context): diff --git a/tests/ext/test_pollanswerhandler.py b/tests/ext/test_pollanswerhandler.py index 8900f466486..b22e9f0e0d9 100644 --- a/tests/ext/test_pollanswerhandler.py +++ b/tests/ext/test_pollanswerhandler.py @@ -69,7 +69,16 @@ def false_update(request): @pytest.fixture def poll_answer(bot): - return Update(0, poll_answer=PollAnswer(1, [0, 1], User(2, "test user", False), Chat(1, ""))) + return Update( + 0, + poll_answer=PollAnswer( + poll_id=1, + option_ids=[0, 1], + option_persistent_ids=["0", "1"], + user=User(2, "test user", False), + voter_chat=Chat(1, ""), + ), + ) class TestPollAnswerHandler: diff --git a/tests/ext/test_pollhandler.py b/tests/ext/test_pollhandler.py index cb2864822fe..c85da77eec3 100644 --- a/tests/ext/test_pollhandler.py +++ b/tests/ext/test_pollhandler.py @@ -73,14 +73,19 @@ def poll(bot): return Update( 0, poll=Poll( - 1, - "question", - [PollOption("1", 0), PollOption("2", 0)], - 0, - False, - False, - Poll.REGULAR, - True, + id=1, + question="question", + options=[ + PollOption(text="1", voter_count=0, persistent_id="1"), + PollOption(text="2", voter_count=0, persistent_id="2"), + ], + total_voter_count=0, + is_closed=False, + is_anonymous=False, + type=Poll.REGULAR, + allows_revoting=True, + members_only=True, + allows_multiple_answers=True, ), ) diff --git a/tests/test_bot.py b/tests/test_bot.py index 1585c581aa1..5f9fb22a3eb 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -33,6 +33,7 @@ from telegram import ( Bot, + BotAccessSettings, BotCommand, BotCommandScopeChat, BotDescription, @@ -53,11 +54,14 @@ InlineQueryResultVoice, InputFile, InputMediaDocument, + InputMediaLocation, InputMediaPhoto, InputMessageContent, InputPollOption, InputProfilePhotoStatic, InputTextMessageContent, + KeyboardButton, + KeyboardButtonRequestManagedBot, LabeledPrice, LinkPreviewOptions, MenuButton, @@ -70,9 +74,11 @@ Poll, PollOption, PreparedInlineMessage, + PreparedKeyboardButton, ReactionTypeCustomEmoji, ReactionTypeEmoji, ReplyParameters, + SentGuestMessage, SentWebAppMessage, ShippingOption, StarTransaction, @@ -99,7 +105,7 @@ from telegram.ext import ExtBot, InvalidCallbackData from telegram.helpers import escape_markdown from telegram.request import BaseRequest, HTTPXRequest, RequestData -from telegram.warnings import PTBUserWarning +from telegram.warnings import PTBDeprecationWarning, 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 @@ -857,6 +863,150 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): == copied_result.input_message_content.parse_mode ) + async def test_answer_guest_query(self, offline_bot, raw_bot, monkeypatch): + params = False + + # For now just test that our internals pass the correct data + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + nonlocal params + params = request_data.parameters == { + "guest_query_id": "12345", + "result": { + "title": "title", + "input_message_content": { + "message_text": "text", + }, + "type": InlineQueryResultType.ARTICLE, + "id": "1", + }, + } + return SentGuestMessage("321").to_dict() + + result = InlineQueryResultArticle("1", "title", InputTextMessageContent("text")) + copied_result = copy.copy(result) + + ext_bot = offline_bot + for bot_type in (ext_bot, raw_bot): + monkeypatch.setattr(bot_type.request, "post", make_assertion) + guest_msg = await bot_type.answer_guest_query("12345", result) + assert params, "something went wrong with passing arguments to the request" + assert isinstance(guest_msg, SentGuestMessage) + assert guest_msg.inline_message_id == "321" + + # make sure that the results were not edited in-place + assert result == copied_result + assert ( + result.input_message_content.parse_mode + == copied_result.input_message_content.parse_mode + ) + + @pytest.mark.parametrize( + "default_bot", + [{"parse_mode": "Markdown", "link_preview_options": LinkPreviewOptions(is_disabled=True)}], + indirect=True, + ) + @pytest.mark.parametrize( + ("ilq_result", "expected_params"), + [ + ( + InlineQueryResultArticle("1", "title", InputTextMessageContent("text")), + { + "guest_query_id": "12345", + "result": { + "title": "title", + "input_message_content": { + "message_text": "text", + "parse_mode": "Markdown", + "link_preview_options": { + "is_disabled": True, + }, + }, + "type": InlineQueryResultType.ARTICLE, + "id": "1", + }, + }, + ), + ( + InlineQueryResultArticle( + "1", + "title", + InputTextMessageContent( + "text", parse_mode="HTML", disable_web_page_preview=False + ), + ), + { + "guest_query_id": "12345", + "result": { + "title": "title", + "input_message_content": { + "message_text": "text", + "parse_mode": "HTML", + "link_preview_options": { + "is_disabled": False, + }, + }, + "type": InlineQueryResultType.ARTICLE, + "id": "1", + }, + }, + ), + ( + InlineQueryResultArticle( + "1", + "title", + InputTextMessageContent( + "text", parse_mode=None, disable_web_page_preview="False" + ), + ), + { + "guest_query_id": "12345", + "result": { + "title": "title", + "input_message_content": { + "message_text": "text", + "link_preview_options": { + "is_disabled": "False", + }, + }, + "type": InlineQueryResultType.ARTICLE, + "id": "1", + }, + }, + ), + ], + ) + async def test_answer_guest_query_defaults( + self, default_bot, ilq_result, expected_params, monkeypatch + ): + offline_bot = default_bot + params = False + + # For now just test that our internals pass the correct data + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + nonlocal params + params = request_data.parameters == expected_params + return SentGuestMessage("321").to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + + # We test different result types more thoroughly for answer_inline_query, so we just + # use the one type here + copied_result = copy.copy(ilq_result) + + guest_msg = await offline_bot.answer_guest_query("12345", ilq_result) + assert params, "something went wrong with passing arguments to the request" + assert isinstance(guest_msg, SentGuestMessage) + assert guest_msg.inline_message_id == "321" + + # make sure that the results were not edited in-place + assert ilq_result == copied_result + assert ( + ilq_result.input_message_content.parse_mode + == copied_result.input_message_content.parse_mode + ) + # TODO: Needs improvement. We need incoming inline query to test answer. @pytest.mark.parametrize("button_type", ["start", "web_app"]) @pytest.mark.parametrize("cache_time", [74, dtm.timedelta(seconds=74)]) @@ -1791,12 +1941,16 @@ async def post(*args, **kwargs): poll=Poll( "42", "question", - options=[PollOption("option", 0)], + options=[ + PollOption(text="option", voter_count=0, persistent_id="persistent_id") + ], total_voter_count=0, is_closed=False, is_anonymous=True, type=Poll.REGULAR, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ), ) return [update.to_dict()] @@ -2443,12 +2597,14 @@ async def test_business_connection_id_argument( Poll( id="42", question="question", - options=[PollOption("option", 0)], + options=[PollOption(text="option", voter_count=0, persistent_id="persistent_id")], total_voter_count=5, is_closed=True, is_anonymous=True, type="regular", allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ).to_dict() ) await return_values.put(True) @@ -2847,6 +3003,132 @@ async def make_assertion(url, request_data: RequestData, *args, **kwargs): await offline_bot.set_chat_member_tag(1234, 5678, "This is a tag") + async def test_send_poll_warn_correct_option_id(self, offline_bot, monkeypatch, recwarn): + async def make_first_assert(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("correct_option_ids") == [1] + assert request_data.parameters.get("correct_option_id") is None + return make_message("dummy reply").to_dict() + + async def make_second_assert(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("correct_option_ids") == [1, 2] + assert request_data.parameters.get("correct_option_id") is None + return make_message("dummy reply").to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_first_assert) + + await offline_bot.send_poll( + 1, + question="question", + options=["option1", "option2"], + correct_option_id=1, + ) + + w = recwarn.pop() + assert issubclass(w.category, PTBDeprecationWarning) + assert "correct_option_id" in str(w.message) + + # Test that correct_option_ids takes priority when both correct_option_id(s) are given + monkeypatch.setattr(offline_bot.request, "post", make_second_assert) + assert await offline_bot.send_poll( + 1, + question="question", + options=["option1", "option2"], + correct_option_id=1, + correct_option_ids=[1, 2], + ) + + # TODO: If we create a managed bot, we could test this for real + async def test_get_managed_bot_token(self, offline_bot, monkeypatch): + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("user_id") == 1234 + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + await offline_bot.get_managed_bot_token(1234) + + async def test_replace_managed_bot_token(self, offline_bot, monkeypatch): + + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("user_id") == 1234 + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + await offline_bot.replace_managed_bot_token(1234) + + # Need real user id to test with request. Otherwise returns User_id_invalid + async def test_save_prepared_keyboard_button(self, offline_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("user_id") == 1234 + assert ( + request_data.parameters.get("button") + == KeyboardButton( + text="this", request_managed_bot=KeyboardButtonRequestManagedBot(1234) + ).to_dict() + ) + return PreparedKeyboardButton(234).to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + inst = await offline_bot.save_prepared_keyboard_button( + 1234, + KeyboardButton(text="this", request_managed_bot=KeyboardButtonRequestManagedBot(1234)), + ) + assert isinstance(inst, PreparedKeyboardButton) + + async def test_get_managed_bot_access_settings(self, offline_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("user_id") == 1234 + return BotAccessSettings( + is_access_restricted=True, + added_users=[User(1, "first", False)], + ).to_dict() + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + settings = await offline_bot.get_managed_bot_access_settings(1234) + assert isinstance(settings, BotAccessSettings) + + async def test_set_managed_bot_access_settings(self, offline_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("user_id") == 1234 + assert request_data.parameters.get("is_access_restricted") is True + assert request_data.parameters.get("added_user_ids") == [1, 2, 3] + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + await offline_bot.set_managed_bot_access_settings( + 1234, + is_access_restricted=True, + added_user_ids=[1, 2, 3], + ) + + async def test_get_user_personal_chat_messages(self, offline_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("user_id") == 1234 + assert request_data.parameters.get("limit") == 1 + return [make_message("dummy reply").to_dict()] + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + msgs = await offline_bot.get_user_personal_chat_messages(1234, limit=1) + assert isinstance(msgs, tuple) + assert all(isinstance(msg, Message) for msg in msgs) + + # Bots cannot delete their own reaction from my testing, so we aren't making a real request + async def test_delete_message_reaction(self, offline_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("chat_id") == 1234 + assert request_data.parameters.get("message_id") == 12 + assert request_data.parameters.get("user_id") == 3432 + assert request_data.parameters.get("actor_chat_id") == 1232 + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + await offline_bot.delete_message_reaction(1234, 12, 3432, 1232) + + async def test_delete_all_message_reactions(self, offline_bot, monkeypatch): + async def make_assertion(url, request_data: RequestData, *args, **kwargs): + assert request_data.parameters.get("chat_id") == 1234 + assert request_data.parameters.get("user_id") == 3432 + assert request_data.parameters.get("actor_chat_id") == 1232 + + monkeypatch.setattr(offline_bot.request, "post", make_assertion) + await offline_bot.delete_all_message_reactions(1234, 3432, 1232) + class TestBotWithRequest: """ @@ -3202,6 +3484,34 @@ async def test_send_poll_explanation_entities(self, bot, chat_id): assert message.poll.explanation == test_string assert message.poll.explanation_entities == tuple(entities) + async def test_send_poll_media_parameters(self, bot, channel_id): + with ( + data_file("telegram.jpg").open("rb") as photo_file, + data_file("text_file.txt").open("rb") as document_file, + ): + i_photo = InputMediaPhoto(InputFile(photo_file, attach=True)) + i_document = InputMediaDocument(InputFile(document_file, attach=True)) + i_location = InputMediaLocation(latitude=0, longitude=0) + + message = await bot.send_poll( + channel_id, + question="question", + options=[ + InputPollOption("option1", media=i_location), + InputPollOption("option2"), + ], + type=Poll.QUIZ, + correct_option_ids=[0], + media=i_photo, + explanation_media=i_document, + is_closed=True, + read_timeout=60, + ) + + assert message.poll.media.photo + assert message.poll.explanation_media.document + assert message.poll.options[0].media.location + @pytest.mark.parametrize("default_bot", [{"parse_mode": "Markdown"}], indirect=True) async def test_send_poll_default_parse_mode(self, default_bot, super_group_id): explanation = "Italic Bold Code" @@ -3632,11 +3942,15 @@ async def test_get_chat(self, bot, super_group_id): assert cfi.id == int(super_group_id) async def test_get_chat_administrators(self, bot, channel_id): - admins = await bot.get_chat_administrators(channel_id) + admins = await bot.get_chat_administrators(channel_id, return_bots=True) assert isinstance(admins, tuple) + bots_found = 0 for a in admins: assert a.status in ("administrator", "creator") + if a.user.is_bot: + bots_found += 1 + assert bots_found > 1 # will be False if return_bots=False async def test_get_chat_member_count(self, bot, channel_id): count = await bot.get_chat_member_count(channel_id) @@ -4851,6 +5165,12 @@ async def test_my_profile_photo(self, bot): bot_profile_photos = await bot.get_user_profile_photos(bot.id) assert bot_profile_photos.total_count == 1 + async def test_get_user_personal_chat_messages(self, bot): + # id is of the Test User + messages = await bot.get_user_personal_chat_messages(user_id=675666224, limit=2) + assert isinstance(messages, tuple) + assert len(messages) == 2 + async def test_initialize_tracks_requests_and_bot_separately(self, offline_bot, monkeypatch): """Test that requests and bot user are initialized separately and only once.""" request_init_count = 0 diff --git a/tests/test_botaccesssettings.py b/tests/test_botaccesssettings.py new file mode 100644 index 00000000000..56cb8d05f50 --- /dev/null +++ b/tests/test_botaccesssettings.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +"""This module contains an object that represents a Telegram Bot Access Settings.""" + +import pytest + +from telegram import BotAccessSettings, Dice +from telegram._user import User +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def bot_access_settings(): + return BotAccessSettings( + is_access_restricted=BotAccessSettingsTestBase.is_access_restricted, + added_users=BotAccessSettingsTestBase.added_users, + ) + + +class BotAccessSettingsTestBase: + is_access_restricted = True + added_users = [User(id=123, first_name="John", is_bot=False)] + + +class TestBotAccessSettingsWithoutRequest(BotAccessSettingsTestBase): + def test_slot_behaviour(self, bot_access_settings): + inst = bot_access_settings + 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 = { + "is_access_restricted": self.is_access_restricted, + "added_users": [user.to_dict() for user in self.added_users], + } + bot_access_settings = BotAccessSettings.de_json(json_dict, offline_bot) + assert bot_access_settings.api_kwargs == {} + + assert bot_access_settings.is_access_restricted == self.is_access_restricted + assert bot_access_settings.added_users == tuple(self.added_users) + + def test_to_dict(self, bot_access_settings): + bot_access_settings_dict = bot_access_settings.to_dict() + + assert isinstance(bot_access_settings_dict, dict) + assert ( + bot_access_settings_dict["is_access_restricted"] + == bot_access_settings.is_access_restricted + ) + assert isinstance(bot_access_settings_dict["added_users"], list) + assert bot_access_settings_dict["added_users"][0] == self.added_users[0].to_dict() + + def test_equality(self): + a = BotAccessSettings(is_access_restricted=True, added_users=self.added_users) + b = BotAccessSettings(is_access_restricted=True, added_users=self.added_users) + c = BotAccessSettings(is_access_restricted=False, added_users=self.added_users) + d = BotAccessSettings(is_access_restricted=True, added_users=None) + e = Dice(emoji="🎲", value=4) + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + assert a != e + assert hash(a) != hash(e) diff --git a/tests/test_business_methods.py b/tests/test_business_methods.py index 43a666eb07e..b31a7c4c270 100644 --- a/tests/test_business_methods.py +++ b/tests/test_business_methods.py @@ -33,7 +33,7 @@ StoryAreaTypeUniqueGift, User, ) -from telegram._files._inputstorycontent import InputStoryContentVideo +from telegram._files.inputstorycontent import InputStoryContentVideo from telegram._files.sticker import Sticker from telegram._gifts import AcceptedGiftTypes, Gift from telegram._inline.inlinekeyboardbutton import InlineKeyboardButton diff --git a/tests/test_chat.py b/tests/test_chat.py index 8d1d2db23ce..99208b5844c 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -563,6 +563,21 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(chat.get_bot(), "send_photo", make_assertion) assert await chat.send_photo(photo="test_photo") + async def test_instance_method_send_live_photo(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["live_photo"] == "test_live_photo" + and kwargs["photo"] == "test_photo" + ) + + assert check_shortcut_signature(Chat.send_live_photo, Bot.send_live_photo, ["chat_id"], []) + assert await check_shortcut_call(chat.send_live_photo, chat.get_bot(), "send_live_photo") + assert await check_defaults_handling(chat.send_live_photo, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "send_live_photo", make_assertion) + assert await chat.send_live_photo(live_photo="test_live_photo", photo="test_photo") + async def test_instance_method_send_contact(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == chat.id and kwargs["phone_number"] == "test_contact" @@ -1537,6 +1552,68 @@ async def make_assertion(*_, **kwargs): active_period=3600, ) + async def test_instance_method_delete_reaction(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["message_id"] == 321 + and kwargs["user_id"] == 123 + and kwargs["actor_chat_id"] == 222 + ) + + assert check_shortcut_signature( + Chat.delete_reaction, + Bot.delete_message_reaction, + [ + "chat_id", + ], + additional_kwargs=[], + ) + assert await check_shortcut_call( + chat.delete_reaction, + chat.get_bot(), + "delete_message_reaction", + shortcut_kwargs=["chat_id"], + ) + assert await check_defaults_handling(chat.delete_reaction, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "delete_message_reaction", make_assertion) + assert await chat.delete_reaction( + user_id=123, + message_id=321, + actor_chat_id=222, + ) + + async def test_instance_method_delete_all_reactions(self, monkeypatch, chat): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == chat.id + and kwargs["user_id"] == 123 + and kwargs["actor_chat_id"] == 222 + ) + + assert check_shortcut_signature( + Chat.delete_all_reactions, + Bot.delete_all_message_reactions, + [ + "chat_id", + ], + additional_kwargs=[], + ) + assert await check_shortcut_call( + chat.delete_all_reactions, + chat.get_bot(), + "delete_all_message_reactions", + shortcut_kwargs=["chat_id"], + ) + assert await check_defaults_handling(chat.delete_all_reactions, chat.get_bot()) + + monkeypatch.setattr(chat.get_bot(), "delete_all_message_reactions", make_assertion) + assert await chat.delete_all_reactions( + user_id=123, + actor_chat_id=222, + ) + async def test_instance_method_get_gifts(self, monkeypatch, chat): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == chat.id diff --git a/tests/test_chatmember.py b/tests/test_chatmember.py index 7bc4a2979f6..ead273bdbd2 100644 --- a/tests/test_chatmember.py +++ b/tests/test_chatmember.py @@ -61,6 +61,7 @@ class ChatMemberTestBase: can_pin_messages = True can_post_stories = True can_edit_stories = True + can_react_to_messages = True can_delete_stories = True can_manage_topics = True until_date = dtm.datetime.now(UTC).replace(microsecond=0) @@ -576,6 +577,7 @@ def chat_member_restricted(): is_member=TestChatMemberRestrictedWithoutRequest.is_member, until_date=TestChatMemberRestrictedWithoutRequest.until_date, can_edit_tag=TestChatMemberRestrictedWithoutRequest.can_edit_tag, + can_react_to_messages=TestChatMemberRestrictedWithoutRequest.can_react_to_messages, tag=TestChatMemberRestrictedWithoutRequest.tag, ) @@ -609,6 +611,7 @@ def test_de_json(self, offline_bot): "is_member": self.is_member, "until_date": to_timestamp(self.until_date), "can_edit_tag": self.can_edit_tag, + "can_react_to_messages": self.can_react_to_messages, "tag": self.tag, # legacy argument "can_send_media_messages": False, @@ -636,6 +639,7 @@ def test_de_json(self, offline_bot): assert chat_member.is_member == self.is_member assert chat_member.until_date == self.until_date assert chat_member.can_edit_tag == self.can_edit_tag + assert chat_member.can_react_to_messages == self.can_react_to_messages assert chat_member.tag == self.tag def test_de_json_localization(self, tz_bot, offline_bot, raw_bot, chat_member_restricted): @@ -676,9 +680,22 @@ def test_to_dict(self, chat_member_restricted): "is_member": chat_member_restricted.is_member, "until_date": to_timestamp(chat_member_restricted.until_date), "can_edit_tag": chat_member_restricted.can_edit_tag, + "can_react_to_messages": chat_member_restricted.can_react_to_messages, "tag": chat_member_restricted.tag, } + def test_can_react_to_messages_raises(self, chat_member_restricted): + with pytest.raises( + TypeError, match="`can_react_to_messages` is required and cannot be None" + ): + ChatMemberRestricted( + *[ + getattr(chat_member_restricted, k) + for k in chat_member_restricted.__slots__ + if k != "can_react_to_messages" + ] + ) + def test_equality(self, chat_member_restricted): a = chat_member_restricted b = deepcopy(chat_member_restricted) @@ -701,6 +718,7 @@ def test_equality(self, chat_member_restricted): False, False, False, + False, "tag", ) d = Dice(5, "test") diff --git a/tests/test_chatpermissions.py b/tests/test_chatpermissions.py index 0baee3f89d7..43aeabcf537 100644 --- a/tests/test_chatpermissions.py +++ b/tests/test_chatpermissions.py @@ -41,6 +41,7 @@ def chat_permissions(): can_send_video_notes=True, can_send_voice_notes=True, can_edit_tag=True, + can_react_to_messages=True, ) @@ -60,6 +61,7 @@ class ChatPermissionsTestBase: can_send_video_notes = False can_send_voice_notes = None can_edit_tag = None + can_react_to_messages = True class TestChatPermissionsWithoutRequest(ChatPermissionsTestBase): @@ -86,6 +88,7 @@ def test_de_json(self, offline_bot): "can_send_video_notes": self.can_send_video_notes, "can_send_voice_notes": self.can_send_voice_notes, "can_edit_tag": self.can_edit_tag, + "can_react_to_messages": self.can_react_to_messages, } permissions = ChatPermissions.de_json(json_dict, offline_bot) assert permissions.api_kwargs == {"can_send_media_messages": "can_send_media_messages"} @@ -105,6 +108,7 @@ def test_de_json(self, offline_bot): assert permissions.can_send_video_notes == self.can_send_video_notes assert permissions.can_send_voice_notes == self.can_send_voice_notes assert permissions.can_edit_tag == self.can_edit_tag + assert permissions.can_react_to_messages == self.can_react_to_messages def test_to_dict(self, chat_permissions): permissions_dict = chat_permissions.to_dict() @@ -130,6 +134,7 @@ def test_to_dict(self, chat_permissions): assert permissions_dict["can_send_video_notes"] == chat_permissions.can_send_video_notes assert permissions_dict["can_send_voice_notes"] == chat_permissions.can_send_voice_notes assert permissions_dict["can_edit_tag"] == chat_permissions.can_edit_tag + assert permissions_dict["can_react_to_messages"] == chat_permissions.can_react_to_messages def test_equality(self): a = ChatPermissions( @@ -159,6 +164,7 @@ def test_equality(self): can_send_video_notes=True, can_send_voice_notes=True, can_edit_tag=True, + can_react_to_messages=True, ) f = ChatPermissions( can_send_messages=True, @@ -171,6 +177,7 @@ def test_equality(self): can_send_video_notes=True, can_send_voice_notes=True, can_edit_tag=True, + can_react_to_messages=True, ) assert a == b diff --git a/tests/test_constants.py b/tests/test_constants.py index 40fd2d41814..0a72417cfab 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -204,10 +204,14 @@ def is_type_attribute(name: str) -> bool: "external_reply", "via_bot", "is_from_offline", + "guest_bot_caller_chat", + "guest_bot_caller_user", + "guest_query_id", "show_caption_above_media", "paid_star_count", "is_paid_post", "reply_to_checklist_task_id", + "reply_to_poll_option_id", } @pytest.mark.parametrize( diff --git a/tests/test_keyboardbutton.py b/tests/test_keyboardbutton.py index 22434be3efb..a664b81cd8e 100644 --- a/tests/test_keyboardbutton.py +++ b/tests/test_keyboardbutton.py @@ -23,6 +23,7 @@ KeyboardButton, KeyboardButtonPollType, KeyboardButtonRequestChat, + KeyboardButtonRequestManagedBot, KeyboardButtonRequestUsers, WebAppInfo, ) @@ -41,6 +42,7 @@ def keyboard_button(): request_users=KeyboardButtonTestBase.request_users, style=KeyboardButtonTestBase.style, icon_custom_emoji_id=KeyboardButtonTestBase.icon_custom_emoji_id, + request_managed_bot=KeyboardButtonTestBase.request_managed_bot, ) @@ -54,6 +56,7 @@ class KeyboardButtonTestBase: request_users = KeyboardButtonRequestUsers(2) style = "primary" icon_custom_emoji_id = "5237829955978547322" + request_managed_bot = KeyboardButtonRequestManagedBot(4, "suggested_name", "username") class TestKeyboardButtonWithoutRequest(KeyboardButtonTestBase): @@ -73,6 +76,7 @@ def test_expected_values(self, keyboard_button): assert keyboard_button.request_users == self.request_users assert keyboard_button.style == self.style assert keyboard_button.icon_custom_emoji_id == self.icon_custom_emoji_id + assert keyboard_button.request_managed_bot == self.request_managed_bot def test_to_dict(self, keyboard_button): keyboard_button_dict = keyboard_button.to_dict() @@ -87,6 +91,10 @@ def test_to_dict(self, keyboard_button): assert keyboard_button_dict["request_users"] == keyboard_button.request_users.to_dict() assert keyboard_button_dict["style"] == keyboard_button.style assert keyboard_button_dict["icon_custom_emoji_id"] == keyboard_button.icon_custom_emoji_id + assert ( + keyboard_button_dict["request_managed_bot"] + == keyboard_button.request_managed_bot.to_dict() + ) @pytest.mark.parametrize("request_user", [True, False]) def test_de_json(self, request_user): @@ -100,6 +108,7 @@ def test_de_json(self, request_user): "request_users": self.request_users.to_dict(), "style": self.style, "icon_custom_emoji_id": self.icon_custom_emoji_id, + "request_managed_bot": self.request_managed_bot.to_dict(), } if request_user: json_dict["request_user"] = {"request_id": 2} @@ -119,6 +128,7 @@ def test_de_json(self, request_user): assert keyboard_button.request_users == self.request_users assert keyboard_button.style == self.style assert keyboard_button.icon_custom_emoji_id == self.icon_custom_emoji_id + assert keyboard_button.request_managed_bot == self.request_managed_bot def test_equality(self): a = KeyboardButton("test", request_contact=True) diff --git a/tests/test_keyboardbuttonrequestmanagedbot.py b/tests/test_keyboardbuttonrequestmanagedbot.py new file mode 100644 index 00000000000..4f3fa95b73e --- /dev/null +++ b/tests/test_keyboardbuttonrequestmanagedbot.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import pytest + +from telegram import KeyboardButtonRequestManagedBot +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def keyboard_button_request_managed_bot(): + return KeyboardButtonRequestManagedBot( + KeyboardButtonRequestManagedBotTestBase.request_id, + KeyboardButtonRequestManagedBotTestBase.suggested_name, + KeyboardButtonRequestManagedBotTestBase.suggested_username, + ) + + +class KeyboardButtonRequestManagedBotTestBase: + request_id = 4 + suggested_name = "suggested_name" + suggested_username = "username" + + +class TestKeyboardButtonRequestManagedBotWithoutRequest(KeyboardButtonRequestManagedBotTestBase): + def test_slot_behaviour(self, keyboard_button_request_managed_bot): + inst = keyboard_button_request_managed_bot + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "request_id": self.request_id, + "suggested_name": self.suggested_name, + "suggested_username": self.suggested_username, + } + keyboard_button_request_managed_bot = KeyboardButtonRequestManagedBot.de_json( + json_dict, offline_bot + ) + assert keyboard_button_request_managed_bot.api_kwargs == {} + assert keyboard_button_request_managed_bot.request_id == self.request_id + assert keyboard_button_request_managed_bot.suggested_name == self.suggested_name + assert keyboard_button_request_managed_bot.suggested_username == self.suggested_username + + def test_to_dict(self): + keyboard_button_request_managed_bot = KeyboardButtonRequestManagedBot( + self.request_id, + self.suggested_name, + self.suggested_username, + ) + keyboard_button_request_managed_bot_dict = keyboard_button_request_managed_bot.to_dict() + assert ( + keyboard_button_request_managed_bot_dict["request_id"] + == keyboard_button_request_managed_bot.request_id + ) + assert ( + keyboard_button_request_managed_bot_dict["suggested_name"] + == keyboard_button_request_managed_bot.suggested_name + ) + assert ( + keyboard_button_request_managed_bot_dict["suggested_username"] + == keyboard_button_request_managed_bot.suggested_username + ) + + def test_equality(self): + a = KeyboardButtonRequestManagedBot( + self.request_id, self.suggested_name, self.suggested_username + ) + b = KeyboardButtonRequestManagedBot( + self.request_id, self.suggested_name, self.suggested_username + ) + c = KeyboardButtonRequestManagedBot(1, self.suggested_name, self.suggested_username) + d = "not a KeyboardButtonRequestManagedBot" + + 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_managedbot.py b/tests/test_managedbot.py new file mode 100644 index 00000000000..9ec374c5894 --- /dev/null +++ b/tests/test_managedbot.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +import pytest + +from telegram import ManagedBotCreated, ManagedBotUpdated, User +from tests.auxil.slots import mro_slots + + +@pytest.fixture +def managed_bot_created(): + return ManagedBotCreated( + bot=User( + id=ManagedBotTestBase.bot_id, + is_bot=ManagedBotTestBase.is_bot, + first_name=ManagedBotTestBase.bot_first_name, + username=ManagedBotTestBase.bot_username, + ), + ) + + +@pytest.fixture +def managed_bot_updated(): + return ManagedBotUpdated( + user=User( + id=ManagedBotTestBase.bot_creator_id, + is_bot=ManagedBotTestBase.is_bot, + first_name=ManagedBotTestBase.bot_creator_first_name, + username=ManagedBotTestBase.bot_creator_username, + ), + bot=User( + id=ManagedBotTestBase.bot_id, + is_bot=ManagedBotTestBase.is_bot, + first_name=ManagedBotTestBase.bot_first_name, + username=ManagedBotTestBase.bot_username, + ), + ) + + +class ManagedBotTestBase: + bot_creator_id = 123 + bot_creator_first_name = "TestBotManager" + bot_creator_username = "test_bot_manager" + bot_id = 321 + bot_first_name = "TestBot" + bot_username = "test_bot" + is_bot = True + + +class TestManagedBotCreatedWithoutRequest(ManagedBotTestBase): + def test_slot_behaviour(self, managed_bot_created): + inst = managed_bot_created + 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, managed_bot_created): + json_dict = { + "bot": managed_bot_created.bot.to_dict(), + } + managed_bot_created = ManagedBotCreated.de_json(json_dict, offline_bot) + assert managed_bot_created.api_kwargs == {} + assert managed_bot_created.bot.id == self.bot_id + assert managed_bot_created.bot.is_bot is True + assert managed_bot_created.bot.first_name == self.bot_first_name + assert managed_bot_created.bot.username == self.bot_username + + def test_to_dict(self, managed_bot_created): + managed_bot_created_dict = managed_bot_created.to_dict() + assert managed_bot_created_dict["bot"]["id"] == self.bot_id + assert managed_bot_created_dict["bot"]["is_bot"] is True + assert managed_bot_created_dict["bot"]["first_name"] == self.bot_first_name + assert managed_bot_created_dict["bot"]["username"] == self.bot_username + + def test_equality(self, managed_bot_created): + managed_bot_created_2 = ManagedBotCreated( + bot=User( + id=self.bot_id, + is_bot=True, + first_name=self.bot_first_name, + username=self.bot_username, + ), + ) + managed_bot_created_3 = ManagedBotCreated( + bot=User( + id=4534, is_bot=True, first_name=self.bot_first_name, username=self.bot_username + ), + ) + not_a_managed_bot_created = ManagedBotUpdated( + user=User( + id=self.bot_creator_id, + is_bot=True, + first_name=self.bot_creator_first_name, + username=self.bot_creator_username, + ), + bot=User( + id=self.bot_id, + is_bot=True, + first_name=self.bot_first_name, + username=self.bot_username, + ), + ) + + assert managed_bot_created == managed_bot_created_2 + assert hash(managed_bot_created) == hash(managed_bot_created_2) + + assert managed_bot_created != managed_bot_created_3 + assert hash(managed_bot_created) != hash(managed_bot_created_3) + + assert managed_bot_created != not_a_managed_bot_created + assert hash(managed_bot_created) != hash(not_a_managed_bot_created) + + +class TestManagedBotUpdatedWithoutRequest(ManagedBotTestBase): + def test_slot_behaviour(self, managed_bot_updated): + inst = managed_bot_updated + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot, managed_bot_updated): + json_dict = { + "user": managed_bot_updated.user.to_dict(), + "bot": managed_bot_updated.bot.to_dict(), + } + managed_bot_updated_new = ManagedBotUpdated.de_json(json_dict, offline_bot) + assert managed_bot_updated_new.api_kwargs == {} + assert managed_bot_updated_new.user == managed_bot_updated.user + assert managed_bot_updated_new.bot == managed_bot_updated.bot + + def test_to_dict(self, managed_bot_updated): + managed_bot_updated_dict = managed_bot_updated.to_dict() + assert managed_bot_updated_dict["user"] == managed_bot_updated.user.to_dict() + assert managed_bot_updated_dict["bot"] == managed_bot_updated.bot.to_dict() + + def test_equality(self, managed_bot_updated): + managed_bot_updated_2 = ManagedBotUpdated( + user=User( + id=self.bot_creator_id, + is_bot=True, + first_name=self.bot_creator_first_name, + username=self.bot_creator_username, + ), + bot=User( + id=self.bot_id, + is_bot=True, + first_name=self.bot_first_name, + username=self.bot_username, + ), + ) + managed_bot_updated_3 = ManagedBotUpdated( + user=User( + id=4534, + is_bot=True, + first_name=self.bot_creator_first_name, + username=self.bot_creator_username, + ), + bot=User( + id=self.bot_id, + is_bot=True, + first_name=self.bot_first_name, + username=self.bot_username, + ), + ) + not_a_managed_bot_updated = ManagedBotCreated( + bot=User( + id=self.bot_id, + is_bot=True, + first_name=self.bot_first_name, + username=self.bot_username, + ), + ) + + assert managed_bot_updated == managed_bot_updated_2 + assert hash(managed_bot_updated) == hash(managed_bot_updated_2) + + assert managed_bot_updated != managed_bot_updated_3 + assert hash(managed_bot_updated) != hash(managed_bot_updated_3) + + assert managed_bot_updated != not_a_managed_bot_updated + assert hash(managed_bot_updated) != hash(not_a_managed_bot_updated) diff --git a/tests/test_message.py b/tests/test_message.py index 2873f17414c..1003e3b161e 100644 --- a/tests/test_message.py +++ b/tests/test_message.py @@ -50,12 +50,16 @@ GiveawayCompleted, GiveawayCreated, GiveawayWinners, + InlineQueryResultArticle, InputChecklist, InputChecklistTask, InputPaidMediaPhoto, + InputTextMessageContent, Invoice, LinkPreviewOptions, + LivePhoto, Location, + ManagedBotCreated, Message, MessageAutoDeleteTimerChanged, MessageEntity, @@ -67,6 +71,7 @@ PhotoSize, Poll, PollOption, + PollOptionAdded, ProximityAlertTriggered, RefundedPayment, ReplyParameters, @@ -102,6 +107,7 @@ WebAppData, ) from telegram._directmessagestopic import DirectMessagesTopic +from telegram._poll import PollOptionDeleted from telegram._utils.datetime import UTC from telegram._utils.defaultvalue import DEFAULT_NONE from telegram._utils.types import ODVInput @@ -128,6 +134,7 @@ def message(bot): chat=copy(MessageTestBase.chat), from_user=copy(MessageTestBase.from_user), business_connection_id="123456789", + guest_query_id="706654132", ) message.set_bot(bot) message._unfreeze() @@ -210,13 +217,18 @@ def message(bot): "poll": Poll( id="abc", question="What is this?", - options=[PollOption(text="a", voter_count=1), PollOption(text="b", voter_count=2)], + options=[ + PollOption(text="a", voter_count=1, persistent_id="persistent_id_a"), + PollOption(text="b", voter_count=2, persistent_id="persistent_id_b"), + ], is_closed=False, total_voter_count=0, is_anonymous=False, type=Poll.REGULAR, allows_multiple_answers=True, explanation_entities=[], + allows_revoting=True, + members_only=True, ) }, { @@ -436,6 +448,14 @@ def message(bot): {"chat_owner_changed": ChatOwnerChanged(new_owner=User(4, "Snow", False))}, {"chat_owner_left": ChatOwnerLeft(new_owner=User(5, "Crash", False))}, {"sender_tag": "This is a tag"}, + {"poll_option_added": PollOptionAdded(option_persistent_id="abc", option_text="this")}, + {"poll_option_deleted": PollOptionDeleted(option_persistent_id="abc", option_text="this")}, + {"reply_to_poll_option_id": "3123"}, + {"managed_bot_created": ManagedBotCreated(bot=User(6, "ManagedBot", True))}, + {"guest_bot_caller_user": User(10, "hm", False)}, + {"guest_bot_caller_chat": Chat(14, "om")}, + {"guest_query_id": "This is a guest_query_id"}, + {"live_photo": LivePhoto("file_id", "file_unique_id", 12, 12, 5)}, ], ids=[ "reply", @@ -530,6 +550,14 @@ def message(bot): "chat_owner_changed", "chat_owner_left", "sender_tag", + "poll_option_added", + "poll_option_deleted", + "reply_to_poll_option_id", + "managed_bot_created", + "guest_bot_caller_user", + "guest_bot_caller_chat", + "guest_query_id", + "live_photo", ], ) def message_params(bot, request): @@ -1475,6 +1503,7 @@ def test_effective_attachment(self, message_params): "document", "game", "invoice", + "live_photo", "location", "paid_media", "passport_data", @@ -1998,6 +2027,54 @@ async def make_assertion(*_, **kwargs): message, message.reply_photo, "send_photo", ["test_photo"], monkeypatch ) + async def test_reply_live_photo(self, monkeypatch, message): + async def make_assertion(*_, **kwargs): + id_ = kwargs["chat_id"] == message.chat_id + live_photo = kwargs["live_photo"] == "test_live_photo" + photo = kwargs["photo"] == "test_photo" + return id_ and live_photo and photo + + assert check_shortcut_signature( + Message.reply_live_photo, + Bot.send_live_photo, + [ + "chat_id", + "reply_to_message_id", + "business_connection_id", + "direct_messages_topic_id", + ], + ["do_quote", "reply_to_message_id"], + annotation_overrides={"message_thread_id": (ODVInput[int], DEFAULT_NONE)}, + ) + assert await check_shortcut_call( + message.reply_live_photo, + message.get_bot(), + "send_live_photo", + skip_params=["reply_to_message_id"], + shortcut_kwargs=["business_connection_id", "direct_messages_topic_id"], + ) + assert await check_defaults_handling( + message.reply_live_photo, message.get_bot(), no_default_kwargs={"message_thread_id"} + ) + + monkeypatch.setattr(message.get_bot(), "send_live_photo", make_assertion) + assert await message.reply_live_photo(live_photo="test_live_photo", photo="test_photo") + await self.check_quote_parsing( + message, + message.reply_live_photo, + "send_live_photo", + ["test_live_photo", "test_photo"], + monkeypatch, + ) + + await self.check_thread_id_parsing( + message, + message.reply_live_photo, + "send_live_photo", + ["test_live_photo", "test_photo"], + monkeypatch, + ) + async def test_reply_audio(self, monkeypatch, message): async def make_assertion(*_, **kwargs): id_ = kwargs["chat_id"] == message.chat_id @@ -3408,3 +3485,54 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(message.get_bot(), "decline_suggested_post", make_assertion) assert await message.decline_suggested_post(comment="some comment") + + async def test_delete_reaction(self, monkeypatch, message): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == message.chat_id + and kwargs["message_id"] == message.message_id + and kwargs["user_id"] == 23 + and kwargs["actor_chat_id"] == 12 + ) + + assert check_shortcut_signature( + Message.delete_reaction, + Bot.delete_message_reaction, + ["chat_id", "message_id"], + [], + ) + assert await check_shortcut_call( + message.delete_reaction, + message.get_bot(), + "delete_message_reaction", + shortcut_kwargs=["chat_id", "message_id"], + ) + assert await check_defaults_handling(message.delete_reaction, message.get_bot()) + + monkeypatch.setattr(message.get_bot(), "delete_message_reaction", make_assertion) + assert await message.delete_reaction(user_id=23, actor_chat_id=12) + + async def test_answer_guest_query(self, monkeypatch, message): + iqra = InlineQueryResultArticle( + id="iqra_id", title="title", input_message_content=InputTextMessageContent("content") + ) + + async def make_assertion(*_, **kwargs): + return kwargs["guest_query_id"] == message.guest_query_id and kwargs["result"] == iqra + + assert check_shortcut_signature( + Message.answer_guest_query, + Bot.answer_guest_query, + ["guest_query_id"], + [], + ) + assert await check_shortcut_call( + message.answer_guest_query, + message.get_bot(), + "answer_guest_query", + shortcut_kwargs=["guest_query_id"], + ) + assert await check_defaults_handling(message.answer_guest_query, message.get_bot()) + + monkeypatch.setattr(message.get_bot(), "answer_guest_query", make_assertion) + assert await message.answer_guest_query(result=iqra) diff --git a/tests/test_official/exceptions.py b/tests/test_official/exceptions.py index 1f7a86f0ebc..d063909db9d 100644 --- a/tests/test_official/exceptions.py +++ b/tests/test_official/exceptions.py @@ -18,7 +18,20 @@ # along with this program. If not, see [http://www.gnu.org/licenses/]. """This module contains exceptions to our API compared to the official API.""" -from telegram import Animation, Audio, Document, Gift, PhotoSize, Sticker, Video, VideoNote, Voice +from collections.abc import Sequence + +from telegram import ( + Animation, + Audio, + Document, + Gift, + LivePhoto, + PhotoSize, + Sticker, + Video, + VideoNote, + Voice, +) from tests.test_official.helpers import _get_params_base IGNORED_OBJECTS = ("ResponseParameters",) @@ -39,6 +52,7 @@ class ParamTypeCheckingExceptions: ADDITIONAL_TYPES = { r"send_\w*": { "photo$": PhotoSize, + "live_photo": LivePhoto, "video$": Video, "video_note": VideoNote, "audio": Audio, @@ -65,13 +79,17 @@ class ParamTypeCheckingExceptions: ("keyboard", True): "KeyboardButton", # + sequence[sequence[str]] ("reaction", False): "ReactionType", # + str ("options", False): "InputPollOption", # + str + ("correct_option_ids", False): "Sequence[typing.Literal[", } # Special cases for other parameters that accept more types than the official API, and are # too complex to compare/predict with official API # structure: class/method_name: {param_name: reduced form of annotation} COMPLEX_TYPES = { - "send_poll": {"correct_option_id": int}, # actual: Literal + "send_poll": { + # "correct_option_id": int, + "correct_option_ids": Sequence[int] + }, "get_file": { "file_id": str, # actual: Union[str, objs_with_file_id_attr] }, @@ -86,6 +104,7 @@ class ParamTypeCheckingExceptions: }, "Input(Paid)?Media.*": { "media": str, # actual: Union[str, InputMedia*, FileInput] + "photo": str, # actual: Union[str, FileInput] # see also https://github.com/tdlib/telegram-bot-api/issues/707 "thumbnail": str, # actual: Union[str, FileInput] "cover": str, # actual: Union[str, FileInput] @@ -133,7 +152,7 @@ class ParamTypeCheckingExceptions: "send_venue": {"venue"}, "answer_inline_query": {"current_offset"}, "send_media_group": {"caption", "parse_mode", "caption_entities"}, - "send_(animation|audio|document|photo|video(_note)?|voice)": {"filename"}, + "send_(animation|audio|document|photo|video(_note)?|voice|live_photo)": {"filename"}, "InlineQueryResult": {"id", "type"}, # attributes common to all subclasses "ChatMember": {"user", "status"}, # attributes common to all subclasses "BotCommandScope": {"type"}, # attributes common to all subclasses @@ -141,8 +160,13 @@ class ParamTypeCheckingExceptions: "PassportFile": {"credentials"}, "EncryptedPassportElement": {"credentials"}, "PassportElementError": {"source", "type", "message"}, + "InputPoll(Option)?Media": {"media_type"}, "InputMedia": {"caption", "caption_entities", "media", "media_type", "parse_mode"}, - "InputMedia(Animation|Audio|Document|Photo|Video|VideoNote|Voice)": {"filename"}, + "InputMedia(Animation|Audio|Document|Photo|Sticker|Video|VideoNote|Voice)": { + "filename", + # tags: deprecated NEXT.VERSION + "filename_depr", + }, "InputFile": {"attach", "filename", "obj", "read_file_handle"}, "MaybeInaccessibleMessage": {"date", "message_id", "chat"}, # attributes common to all subcls "ChatBoostSource": {"source"}, # attributes common to all subclasses @@ -159,6 +183,12 @@ class ParamTypeCheckingExceptions: "InputStoryContent": {"type"}, # attributes common to all subclasses "StoryAreaType": {"type"}, # attributes common to all subclasses "InputProfilePhoto": {"type"}, # attributes common to all subclasses + "InputPollOptionMedia": {"args", "kwargs"}, # UnionType's __init__ signature + "InputPollMedia": {"args", "kwargs"}, # UnionType's __init__ signature + # backwards compatibility for api 10.0 changes + # tags: deprecated NEXT.VERSION, bot api 10.0 + "Poll": {"correct_option_id"}, + "send_poll": {"correct_option_id"}, } @@ -214,7 +244,13 @@ def ignored_param_requirements(object_name: str) -> set[str]: # Arguments that are optional arguments for now for backwards compatibility -BACKWARDS_COMPAT_KWARGS: dict[str, set[str]] = {} +BACKWARDS_COMPAT_KWARGS: dict[str, set[str]] = { + "PollOption": {"persistent_id"}, + "PollAnswer": {"option_persistent_ids"}, + "Poll": {"allows_revoting", "members_only"}, + "ChatMemberRestricted": {"can_react_to_messages"}, + "send_poll": {"correct_option_id"}, +} def backwards_compat_kwargs(object_name: str) -> set[str]: diff --git a/tests/test_official/helpers.py b/tests/test_official/helpers.py index 2257adb942a..eca39bcf127 100644 --- a/tests/test_official/helpers.py +++ b/tests/test_official/helpers.py @@ -109,6 +109,7 @@ def cached_type_hints(obj: Any, is_class: bool) -> dict[str, Any]: @functools.cache def resolve_forward_refs_in_type(obj: type) -> type: """Resolves forward references in a type hint.""" + # TODO: Refactor test_official to properly use annotationlib return _eval_type(obj, localns=tg_objects, globalns=None) diff --git a/tests/test_official/test_official.py b/tests/test_official/test_official.py index 2c3fd03d6b1..70ec78a1912 100644 --- a/tests/test_official/test_official.py +++ b/tests/test_official/test_official.py @@ -17,10 +17,14 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import inspect +import platform from typing import TYPE_CHECKING import pytest +if platform.python_version_tuple() >= ("3", "15"): + pytest.skip("Running on Python version > 3.15 is not supported yet!", allow_module_level=True) + import telegram from tests.auxil.envvars import RUN_TEST_OFFICIAL from tests.test_official.arg_type_checker import ( diff --git a/tests/test_paidmedia.py b/tests/test_paidmedia.py index 536fcc1f2ad..b6efb1b01a8 100644 --- a/tests/test_paidmedia.py +++ b/tests/test_paidmedia.py @@ -24,8 +24,10 @@ from telegram import ( Dice, + LivePhoto, PaidMedia, PaidMediaInfo, + PaidMediaLivePhoto, PaidMediaPhoto, PaidMediaPreview, PaidMediaPurchased, @@ -64,6 +66,14 @@ class PaidMediaTestBase: file_unique_id="file_unique_id", ), ) + live_photo = LivePhoto( + file_id="live_photo_file_id", + file_unique_id="live_photo_file_unique_id", + width=640, + height=480, + duration=dtm.timedelta(seconds=60), + photo=photo, + ) class TestPaidMediaWithoutRequest(PaidMediaTestBase): @@ -89,6 +99,7 @@ def test_de_json(self, offline_bot): ("photo", PaidMediaPhoto), ("video", PaidMediaVideo), ("preview", PaidMediaPreview), + ("live_photo", PaidMediaLivePhoto), ], ) def test_de_json_subclass(self, offline_bot, pm_type, subclass): @@ -99,6 +110,7 @@ def test_de_json_subclass(self, offline_bot, pm_type, subclass): "width": self.width, "height": self.height, "duration": int(self.duration.total_seconds()), + "live_photo": self.live_photo.to_dict(), } pm = PaidMedia.de_json(json_dict, offline_bot) @@ -226,6 +238,56 @@ def test_equality(self, paid_media_video): assert hash(a) != hash(d) +@pytest.fixture +def paid_media_live_photo(): + return PaidMediaLivePhoto( + live_photo=TestPaidMediaLivePhotoWithoutRequest.live_photo, + ) + + +class TestPaidMediaLivePhotoWithoutRequest(PaidMediaTestBase): + type = PaidMediaType.LIVE_PHOTO + + def test_slot_behaviour(self, paid_media_live_photo): + inst = paid_media_live_photo + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "live_photo": self.live_photo.to_dict(), + } + pmlp = PaidMediaLivePhoto.de_json(json_dict, offline_bot) + assert pmlp.live_photo == self.live_photo + assert pmlp.api_kwargs == {} + + def test_to_dict(self, paid_media_live_photo): + assert paid_media_live_photo.to_dict() == { + "type": self.type, + "live_photo": paid_media_live_photo.live_photo.to_dict(), + } + + def test_equality(self, paid_media_live_photo): + a = paid_media_live_photo + b = PaidMediaLivePhoto( + live_photo=deepcopy(self.live_photo), + ) + c = PaidMediaLivePhoto( + live_photo=LivePhoto("test", "test_unique", 640, 480, 60), + ) + d = Dice(5, "test") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) + + assert a != d + assert hash(a) != hash(d) + + @pytest.fixture def paid_media_preview(): return PaidMediaPreview( diff --git a/tests/test_poll.py b/tests/test_poll.py index 2edc93d3d9f..10d39a2fe98 100644 --- a/tests/test_poll.py +++ b/tests/test_poll.py @@ -19,7 +19,29 @@ import pytest -from telegram import Chat, InputPollOption, MessageEntity, Poll, PollAnswer, PollOption, User +from telegram import ( + Animation, + Audio, + Chat, + Document, + InputMediaPhoto, + InputPollOption, + LivePhoto, + Location, + MaybeInaccessibleMessage, + MessageEntity, + PhotoSize, + Poll, + PollAnswer, + PollMedia, + PollOption, + PollOptionAdded, + PollOptionDeleted, + Sticker, + User, + Venue, + Video, +) from telegram._utils.datetime import UTC, to_timestamp from telegram.constants import PollType from telegram.warnings import PTBDeprecationWarning @@ -32,6 +54,7 @@ def input_poll_option(): text=InputPollOptionTestBase.text, text_parse_mode=InputPollOptionTestBase.text_parse_mode, text_entities=InputPollOptionTestBase.text_entities, + media=InputPollOptionTestBase.media, ) out._unfreeze() return out @@ -44,6 +67,7 @@ class InputPollOptionTestBase: MessageEntity(0, 4, MessageEntity.BOLD), MessageEntity(5, 7, MessageEntity.ITALIC), ] + media = InputMediaPhoto("media") class TestInputPollOptionWithoutRequest(InputPollOptionTestBase): @@ -54,6 +78,7 @@ def test_slot_behaviour(self, input_poll_option): "duplicate slot" ) + # tags: deprecated NEXT.VERSION def test_de_json(self): json_dict = { "text": self.text, @@ -67,6 +92,16 @@ def test_de_json(self): assert input_poll_option.text_parse_mode == self.text_parse_mode assert input_poll_option.text_entities == tuple(self.text_entities) + def test_de_json_deprecated(self, recwarn): + InputPollOption.de_json({"text": self.text}, None) + + assert len(recwarn) == 1 + assert "`InputPollOption.de_json` is deprecated" in str(recwarn[0].message) + assert "The `media` field will not be included for deserialization" in str( + recwarn[0].message + ) + assert recwarn[0].category is PTBDeprecationWarning + def test_to_dict(self, input_poll_option): input_poll_option_dict = input_poll_option.to_dict() @@ -76,6 +111,7 @@ def test_to_dict(self, input_poll_option): assert input_poll_option_dict["text_entities"] == [ e.to_dict() for e in input_poll_option.text_entities ] + assert input_poll_option_dict["media"] == input_poll_option.media.to_dict() # Test that the default-value parameter is handled correctly input_poll_option = InputPollOption("text") @@ -87,7 +123,18 @@ def test_equality(self): b = InputPollOption("text", self.text_parse_mode) c = InputPollOption("text", text_entities=self.text_entities) d = InputPollOption("different_text") - e = Poll(123, "question", ["O1", "O2"], 1, False, True, Poll.REGULAR, True) + e = Poll( + 123, + "question", + ["O1", "O2"], + 1, + False, + True, + Poll.REGULAR, + True, + allows_revoting=True, + members_only=True, + ) assert a == b assert hash(a) == hash(b) @@ -102,12 +149,123 @@ def test_equality(self): assert hash(a) != hash(e) +@pytest.fixture(scope="module") +def poll_media(): + return PollMedia( + animation=PollMediaTestBase.animation, + audio=PollMediaTestBase.audio, + document=PollMediaTestBase.document, + live_photo=PollMediaTestBase.live_photo, + location=PollMediaTestBase.location, + photo=PollMediaTestBase.photo, + sticker=PollMediaTestBase.sticker, + venue=PollMediaTestBase.venue, + video=PollMediaTestBase.video, + ) + + +class PollMediaTestBase: + animation = Animation("blah", "unique_id", 320, 180, 1) + audio = Audio(file_id="file_id", file_unique_id="file_unique_id", duration=30) + document = Document("file_id", "file_unique_id", "file_name", 42) + location = Location(123, 456) + photo = (PhotoSize("file_id", "file_unique_id", 1, 1),) + sticker = Sticker("file_id", "file_unique_id", 512, 512, False, False, "regular") + venue = Venue(location=Location(123, 456), title="title", address="address") + video = Video( + file_id="video_file_id", + width=640, + height=480, + file_unique_id="file_unique_id", + duration=dtm.timedelta(seconds=60), + ) + live_photo = LivePhoto( + file_id="video_file_id", + file_unique_id="file_unique_id", + width=640, + height=480, + duration=dtm.timedelta(seconds=60), + mime_type="video/mp4", + file_size=326534, + ) + + +class TestPollMediaWithoutRequest(PollMediaTestBase): + def test_slot_behaviour(self, poll_media): + for attr in poll_media.__slots__: + assert getattr(poll_media, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(poll_media)) == len(set(mro_slots(poll_media))), "duplicate slot" + + def test_de_json(self): + json_dict = { + "animation": self.animation.to_dict(), + "audio": self.audio.to_dict(), + "document": self.document.to_dict(), + "live_photo": self.live_photo.to_dict(), + "location": self.location.to_dict(), + "photo": [photo.to_dict() for photo in self.photo], + "sticker": self.sticker.to_dict(), + "venue": self.venue.to_dict(), + "video": self.video.to_dict(), + } + poll_media = PollMedia.de_json(json_dict, None) + + assert poll_media.api_kwargs == {} + assert poll_media.animation == self.animation + assert poll_media.audio == self.audio + assert poll_media.document == self.document + assert poll_media.live_photo == self.live_photo + assert poll_media.location == self.location + assert poll_media.photo == self.photo + assert poll_media.sticker == self.sticker + assert poll_media.venue == self.venue + assert poll_media.video == self.video + + def test_to_dict(self, poll_media): + poll_media_dict = poll_media.to_dict() + + assert isinstance(poll_media_dict, dict) + assert poll_media_dict["animation"] == poll_media.animation.to_dict() + assert poll_media_dict["audio"] == poll_media.audio.to_dict() + assert poll_media_dict["document"] == poll_media.document.to_dict() + assert poll_media_dict["live_photo"] == poll_media.live_photo.to_dict() + assert poll_media_dict["location"] == poll_media.location.to_dict() + assert poll_media_dict["photo"] == [photo.to_dict() for photo in poll_media.photo] + assert poll_media_dict["sticker"] == poll_media.sticker.to_dict() + assert poll_media_dict["venue"] == poll_media.venue.to_dict() + assert poll_media_dict["video"] == poll_media.video.to_dict() + + def test_equality(self): + a = PollMedia(photo=self.photo) + b = PollMedia(photo=self.photo) + c = PollMedia(photo=(PhotoSize("file_id", "other_file_unique_id", 1, 1),)) + d = PollMedia(video=self.video) + e = PollOption("text", 1, persistent_id="persistent_id") + + assert a == b + assert hash(a) == hash(b) + + assert a != d + assert hash(a) != hash(d) + + assert a != c + assert hash(a) != hash(c) + + assert a != e + assert hash(a) != hash(e) + + @pytest.fixture(scope="module") def poll_option(): out = PollOption( text=PollOptionTestBase.text, voter_count=PollOptionTestBase.voter_count, text_entities=PollOptionTestBase.text_entities, + added_by_user=PollOptionTestBase.added_by_user, + added_by_chat=PollOptionTestBase.added_by_chat, + addition_date=PollOptionTestBase.addition_date, + persistent_id=PollOptionTestBase.persistent_id, + media=PollOptionTestBase.media, ) out._unfreeze() return out @@ -120,6 +278,11 @@ class PollOptionTestBase: MessageEntity(MessageEntity.BOLD, 0, 4), MessageEntity(MessageEntity.ITALIC, 5, 6), ] + added_by_user = User(1, "test_user", False) + added_by_chat = Chat(1, "test_chat") + addition_date = dtm.datetime.now(dtm.timezone.utc) + persistent_id = "persistent_id" + media = PollMedia(location=Location(123, 456)) class TestPollOptionWithoutRequest(PollOptionTestBase): @@ -129,26 +292,27 @@ def test_slot_behaviour(self, poll_option): assert len(mro_slots(poll_option)) == len(set(mro_slots(poll_option))), "duplicate slot" def test_de_json(self): - json_dict = {"text": self.text, "voter_count": self.voter_count} - poll_option = PollOption.de_json(json_dict, None) - assert poll_option.api_kwargs == {} - - assert poll_option.text == self.text - assert poll_option.voter_count == self.voter_count - - def test_de_json_all(self): json_dict = { "text": self.text, "voter_count": self.voter_count, "text_entities": [e.to_dict() for e in self.text_entities], + "added_by_user": self.added_by_user.to_dict(), + "added_by_chat": self.added_by_chat.to_dict(), + "addition_date": to_timestamp(self.addition_date), + "persistent_id": self.persistent_id, + "media": self.media.to_dict(), } poll_option = PollOption.de_json(json_dict, None) - assert poll_option.api_kwargs == {} assert poll_option.text == self.text assert poll_option.voter_count == self.voter_count assert poll_option.text_entities == tuple(self.text_entities) + assert poll_option.added_by_user == self.added_by_user + assert poll_option.added_by_chat == self.added_by_chat + assert abs((poll_option.addition_date - self.addition_date).total_seconds()) < 1 + assert poll_option.persistent_id == self.persistent_id + assert poll_option.media == self.media def test_to_dict(self, poll_option): poll_option_dict = poll_option.to_dict() @@ -159,6 +323,11 @@ def test_to_dict(self, poll_option): assert poll_option_dict["text_entities"] == [ e.to_dict() for e in poll_option.text_entities ] + assert poll_option_dict["added_by_user"] == poll_option.added_by_user.to_dict() + assert poll_option_dict["added_by_chat"] == poll_option.added_by_chat.to_dict() + assert poll_option_dict["addition_date"] == to_timestamp(poll_option.addition_date) + assert poll_option_dict["persistent_id"] == poll_option.persistent_id + assert poll_option_dict["media"] == poll_option.media.to_dict() def test_parse_entity(self, poll_option): entity = MessageEntity(MessageEntity.BOLD, 0, 4) @@ -174,12 +343,29 @@ def test_parse_entities(self, poll_option): assert poll_option.parse_entities(MessageEntity.BOLD) == {entity: "test"} assert poll_option.parse_entities() == {entity: "test", entity_2: "option"} + def test_persistent_id_required_workaround(self): + # tags: deprecated NEXT.VERSION, bot api 9.6 + with pytest.raises(TypeError, match="`persistent_id` is a required"): + PollOption(self.text, self.voter_count) + def test_equality(self): - a = PollOption("text", 1) - b = PollOption("text", 1) - c = PollOption("text_1", 1) - d = PollOption("text", 2) - e = Poll(123, "question", ["O1", "O2"], 1, False, True, Poll.REGULAR, True) + a = PollOption("text", 1, persistent_id="persistent_id") + b = PollOption("text", 1, persistent_id="persistent_id") + c = PollOption("other_text", 1, persistent_id="persistent_id") + d = PollOption("text", 1 + 9, persistent_id="persistent_id") + e = PollOption("text", 1, persistent_id="other_persistent_id") + f = Poll( + 123, + "question", + ["O1", "O2"], + 1, + False, + True, + Poll.REGULAR, + True, + allows_revoting=True, + members_only=True, + ) assert a == b assert hash(a) == hash(b) @@ -193,6 +379,9 @@ def test_equality(self): assert a != e assert hash(a) != hash(e) + assert a != f + assert hash(a) != hash(f) + @pytest.fixture(scope="module") def poll_answer(): @@ -201,6 +390,7 @@ def poll_answer(): PollAnswerTestBase.option_ids, PollAnswerTestBase.user, PollAnswerTestBase.voter_chat, + PollAnswerTestBase.option_persistent_ids, ) @@ -209,6 +399,7 @@ class PollAnswerTestBase: option_ids = [2] user = User(1, "", False) voter_chat = Chat(1, "") + option_persistent_ids = ["123"] class TestPollAnswerWithoutRequest(PollAnswerTestBase): @@ -218,6 +409,7 @@ def test_de_json(self): "option_ids": self.option_ids, "user": self.user.to_dict(), "voter_chat": self.voter_chat.to_dict(), + "option_persistent_ids": self.option_persistent_ids, } poll_answer = PollAnswer.de_json(json_dict, None) assert poll_answer.api_kwargs == {} @@ -226,6 +418,7 @@ def test_de_json(self): assert poll_answer.option_ids == tuple(self.option_ids) assert poll_answer.user == self.user assert poll_answer.voter_chat == self.voter_chat + assert poll_answer.option_persistent_ids == tuple(self.option_persistent_ids) def test_to_dict(self, poll_answer): poll_answer_dict = poll_answer.to_dict() @@ -235,14 +428,22 @@ def test_to_dict(self, poll_answer): assert poll_answer_dict["option_ids"] == list(poll_answer.option_ids) assert poll_answer_dict["user"] == poll_answer.user.to_dict() assert poll_answer_dict["voter_chat"] == poll_answer.voter_chat.to_dict() + assert poll_answer_dict["option_persistent_ids"] == list(poll_answer.option_persistent_ids) + + def test_persistent_id_required_workaround(self): + # tags: deprecated NEXT.VERSION, bot api 9.6 + with pytest.raises(TypeError, match="`option_persistent_ids` is a required"): + PollAnswer(poll_id=123, option_ids=[2], user=self.user, voter_chat=self.voter_chat) def test_equality(self): - a = PollAnswer(123, [2], self.user, self.voter_chat) - b = PollAnswer(123, [2], self.user, Chat(1, "")) - c = PollAnswer(123, [2], User(1, "first", False), self.voter_chat) - d = PollAnswer(123, [1, 2], self.user, self.voter_chat) - e = PollAnswer(456, [2], self.user, self.voter_chat) - f = PollOption("Text", 1) + a = PollAnswer(123, [2], self.user, self.voter_chat, option_persistent_ids=["2"]) + b = PollAnswer(123, [2], self.user, Chat(1, ""), option_persistent_ids=["2"]) + c = PollAnswer( + 123, [2], User(1, "first", False), self.voter_chat, option_persistent_ids=["2"] + ) + d = PollAnswer(123, [1, 2], self.user, self.voter_chat, option_persistent_ids=["1", "2"]) + e = PollAnswer(456, [2], self.user, self.voter_chat, option_persistent_ids=["2"]) + f = PollOption("Text", 1, persistent_id="persistent_id") assert a == b assert hash(a) == hash(b) @@ -276,6 +477,14 @@ def poll(): open_period=PollTestBase.open_period, close_date=PollTestBase.close_date, question_entities=PollTestBase.question_entities, + allows_revoting=PollTestBase.allows_revoting, + members_only=PollTestBase.members_only, + correct_option_ids=PollTestBase.correct_option_ids, + description=PollTestBase.description, + description_entities=PollTestBase.description_entities, + country_codes=PollTestBase.country_codes, + media=PollTestBase.media, + explanation_media=PollTestBase.explanation_media, ) poll._unfreeze() return poll @@ -284,12 +493,16 @@ def poll(): class PollTestBase: id_ = "id" question = "Test Question?" - options = [PollOption("test", 10), PollOption("test2", 11)] + options = [ + PollOption("test", 10, persistent_id="persistent_id"), + PollOption("test2", 11, persistent_id="persistent_id_2"), + ] total_voter_count = 0 is_closed = True is_anonymous = False type = Poll.REGULAR allows_multiple_answers = True + members_only = True explanation = ( b"\\U0001f469\\u200d\\U0001f469\\u200d\\U0001f467" b"\\u200d\\U0001f467\\U0001f431http://google.com" @@ -301,6 +514,13 @@ class PollTestBase: MessageEntity(MessageEntity.BOLD, 0, 4), MessageEntity(MessageEntity.ITALIC, 5, 8), ] + allows_revoting = True + correct_option_ids = [1, 2] + description = "description" + description_entities = [MessageEntity(MessageEntity.ITALIC, 0, 11)] + country_codes = ["AB", "CD"] + media = PollMedia(document=Document("file_id", "file_unique_id", "file_name", 42)) + explanation_media = PollMedia(animation=Animation("blah", "unique_id", 320, 180, 1)) class TestPollWithoutRequest(PollTestBase): @@ -319,6 +539,14 @@ def test_de_json(self, offline_bot): "open_period": int(self.open_period.total_seconds()), "close_date": to_timestamp(self.close_date), "question_entities": [e.to_dict() for e in self.question_entities], + "allows_revoting": self.allows_revoting, + "members_only": self.members_only, + "correct_option_ids": self.correct_option_ids, + "description": self.description, + "description_entities": [e.to_dict() for e in self.description_entities], + "country_codes": self.country_codes, + "media": self.media.to_dict(), + "explanation_media": self.explanation_media.to_dict(), } poll = Poll.de_json(json_dict, offline_bot) assert poll.api_kwargs == {} @@ -335,12 +563,20 @@ def test_de_json(self, offline_bot): assert poll.is_anonymous == self.is_anonymous assert poll.type == self.type assert poll.allows_multiple_answers == self.allows_multiple_answers + assert poll.members_only == self.members_only assert poll.explanation == self.explanation assert poll.explanation_entities == tuple(self.explanation_entities) assert poll._open_period == self.open_period assert abs(poll.close_date - self.close_date) < dtm.timedelta(seconds=1) assert to_timestamp(poll.close_date) == to_timestamp(self.close_date) assert poll.question_entities == tuple(self.question_entities) + assert poll.allows_revoting == self.allows_revoting + assert poll.correct_option_ids == tuple(self.correct_option_ids) + assert poll.description == self.description + assert poll.description_entities == tuple(self.description_entities) + assert poll.country_codes == tuple(self.country_codes) + assert poll.media == self.media + assert poll.explanation_media == self.explanation_media def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): json_dict = { @@ -357,6 +593,14 @@ def test_de_json_localization(self, tz_bot, offline_bot, raw_bot): "open_period": int(self.open_period.total_seconds()), "close_date": to_timestamp(self.close_date), "question_entities": [e.to_dict() for e in self.question_entities], + "allows_revoting": self.allows_revoting, + "members_only": self.members_only, + "correct_option_ids": self.correct_option_ids, + "description": self.description, + "description_entities": [e.to_dict() for e in self.description_entities], + "country_codes": self.country_codes, + "media": self.media.to_dict(), + "explanation_media": self.explanation_media.to_dict(), } poll_raw = Poll.de_json(json_dict, raw_bot) @@ -385,11 +629,21 @@ def test_to_dict(self, poll): assert poll_dict["is_anonymous"] == poll.is_anonymous assert poll_dict["type"] == poll.type assert poll_dict["allows_multiple_answers"] == poll.allows_multiple_answers + assert poll_dict["members_only"] == poll.members_only assert poll_dict["explanation"] == poll.explanation assert poll_dict["explanation_entities"] == [poll.explanation_entities[0].to_dict()] assert poll_dict["open_period"] == int(self.open_period.total_seconds()) assert poll_dict["close_date"] == to_timestamp(poll.close_date) assert poll_dict["question_entities"] == [e.to_dict() for e in poll.question_entities] + assert poll_dict["allows_revoting"] == poll.allows_revoting + assert poll_dict["correct_option_ids"] == list(poll.correct_option_ids) + assert poll_dict["description"] == poll.description + assert poll_dict["description_entities"] == [ + e.to_dict() for e in poll.description_entities + ] + assert poll_dict["country_codes"] == list(poll.country_codes) + assert poll_dict["media"] == poll.media.to_dict() + assert poll_dict["explanation_media"] == poll.explanation_media.to_dict() def test_time_period_properties(self, PTB_TIMEDELTA, poll): if PTB_TIMEDELTA: @@ -409,11 +663,96 @@ def test_time_period_int_deprecated(self, recwarn, PTB_TIMEDELTA, poll): assert "`open_period` will be of type `datetime.timedelta`" in str(recwarn[0].message) assert recwarn[0].category is PTBDeprecationWarning + def test_correct_option_id_deprecated(self, recwarn, poll): + poll.correct_option_id + + assert len(recwarn) == 1 + assert "The attribute `correct_option_id` is deprecated" in str(recwarn[0].message) + assert recwarn[0].category is PTBDeprecationWarning + + poll = Poll( + PollTestBase.id_, + PollTestBase.question, + PollTestBase.options, + PollTestBase.total_voter_count, + PollTestBase.is_closed, + PollTestBase.is_anonymous, + PollTestBase.type, + PollTestBase.allows_multiple_answers, + correct_option_id=1, + allows_revoting=PollTestBase.allows_revoting, + members_only=PollTestBase.members_only, + ) + assert poll.correct_option_ids == (1,) + + def test_allows_revoting_required_workaround(self): + # tags: deprecated NEXT.VERSION, bot api 9.6 + with pytest.raises(TypeError, match="`allows_revoting` is a required"): + Poll( + self.id_, + self.question, + self.options, + self.total_voter_count, + self.is_closed, + self.is_anonymous, + self.type, + self.allows_multiple_answers, + members_only=self.members_only, + ) + + def test_members_only_required_workaround(self): + # tags: deprecated NEXT.VERSION, bot api 10.0 + with pytest.raises(TypeError, match="`members_only` is a required"): + Poll( + self.id_, + self.question, + self.options, + self.total_voter_count, + self.is_closed, + self.is_anonymous, + self.type, + self.allows_multiple_answers, + allows_revoting=self.allows_revoting, + ) + def test_equality(self): - a = Poll(123, "question", ["O1", "O2"], 1, False, True, Poll.REGULAR, True) - b = Poll(123, "question", ["o1", "o2"], 1, True, False, Poll.REGULAR, True) - c = Poll(456, "question", ["o1", "o2"], 1, True, False, Poll.REGULAR, True) - d = PollOption("Text", 1) + a = Poll( + 123, + "question", + ["O1", "O2"], + 1, + False, + True, + Poll.REGULAR, + True, + allows_revoting=True, + members_only=True, + ) + b = Poll( + 123, + "question", + ["o1", "o2"], + 1, + True, + False, + Poll.REGULAR, + True, + allows_revoting=False, + members_only=False, + ) + c = Poll( + 456, + "question", + ["o1", "o2"], + 1, + True, + False, + Poll.REGULAR, + True, + allows_revoting=True, + members_only=True, + ) + d = PollOption("Text", 1, persistent_id="persistent_id") assert a == b assert hash(a) == hash(b) @@ -434,6 +773,8 @@ def test_enum_init(self): is_closed=False, is_anonymous=False, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ) assert poll.type == "foo" poll = Poll( @@ -445,6 +786,8 @@ def test_enum_init(self): is_closed=False, is_anonymous=False, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ) assert poll.type is PollType.QUIZ @@ -458,12 +801,14 @@ def test_parse_explanation_entity(self, poll): Poll( "id", "question", - [PollOption("text", voter_count=0)], + [PollOption("text", voter_count=0, persistent_id="persistent_id")], total_voter_count=0, is_closed=False, is_anonymous=False, type=Poll.QUIZ, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ).parse_explanation_entity(entity) def test_parse_explanation_entities(self, poll): @@ -478,12 +823,14 @@ def test_parse_explanation_entities(self, poll): Poll( "id", "question", - [PollOption("text", voter_count=0)], + [PollOption("text", voter_count=0, persistent_id="persistent_id")], total_voter_count=0, is_closed=False, is_anonymous=False, type=Poll.QUIZ, allows_multiple_answers=False, + allows_revoting=True, + members_only=True, ).parse_explanation_entities() def test_parse_question_entity(self, poll): @@ -499,3 +846,285 @@ def test_parse_question_entities(self, poll): assert poll.parse_question_entities(MessageEntity.ITALIC) == {entity: "Question"} assert poll.parse_question_entities() == {entity: "Question", entity_2: "Test"} + + def test_parse_description_entity(self, poll): + entity = MessageEntity(MessageEntity.ITALIC, 0, 11) + poll.description_entities = [entity] + + assert poll.parse_description_entity(entity) == "description" + with pytest.raises(RuntimeError, match="Poll has no"): + Poll( + "id", + "question", + [PollOption("text", voter_count=0, persistent_id="persistent_id")], + total_voter_count=0, + is_closed=False, + is_anonymous=False, + type=Poll.QUIZ, + allows_multiple_answers=False, + allows_revoting=True, + members_only=True, + ).parse_description_entity(entity) + + def test_parse_description_entities(self, poll): + entity = MessageEntity(MessageEntity.ITALIC, 0, 11) + entity_2 = MessageEntity(MessageEntity.BOLD, 0, 4) + poll.description_entities = [entity_2, entity] + + assert poll.parse_description_entities(MessageEntity.ITALIC) == {entity: "description"} + assert poll.parse_description_entities() == {entity: "description", entity_2: "desc"} + with pytest.raises(RuntimeError, match="Poll has no"): + Poll( + "id", + "question", + [PollOption("text", voter_count=0, persistent_id="persistent_id")], + total_voter_count=0, + is_closed=False, + is_anonymous=False, + type=Poll.QUIZ, + allows_multiple_answers=False, + allows_revoting=True, + members_only=True, + ).parse_description_entities() + + +@pytest.fixture(scope="module") +def poll_option_added(): + p = PollOptionAdded( + poll_message=PollOptionAddedTestBase.poll_message, + option_persistent_id=PollOptionAddedTestBase.option_persistent_id, + option_text=PollOptionAddedTestBase.option_text, + option_text_entities=PollOptionAddedTestBase.option_text_entities, + ) + p._unfreeze() + return p + + +class PollOptionAddedTestBase: + poll_message = MaybeInaccessibleMessage( + message_id=1, + date=dtm.datetime.now(dtm.timezone.utc), + chat=Chat(1, "test_chat"), + ) + option_persistent_id = "123" + option_text = "test option" + option_text_entities = [ + MessageEntity(MessageEntity.BOLD, 0, 4), + MessageEntity(MessageEntity.ITALIC, 5, 6), + ] + + +class TestPollOptionAddedWithoutRequest(PollOptionAddedTestBase): + def test_slot_behaviour(self, poll_option_added): + for attr in poll_option_added.__slots__: + assert getattr(poll_option_added, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(poll_option_added)) == len(set(mro_slots(poll_option_added))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "poll_message": self.poll_message.to_dict(), + "option_persistent_id": self.option_persistent_id, + "option_text": self.option_text, + "option_text_entities": [e.to_dict() for e in self.option_text_entities], + } + poll_option_added = PollOptionAdded.de_json(json_dict, offline_bot) + assert poll_option_added.api_kwargs == {} + + assert poll_option_added.poll_message == self.poll_message + assert poll_option_added.option_persistent_id == self.option_persistent_id + assert poll_option_added.option_text == self.option_text + assert poll_option_added.option_text_entities == tuple(self.option_text_entities) + + def test_to_dict(self, poll_option_added): + poll_option_added_dict = poll_option_added.to_dict() + + assert isinstance(poll_option_added_dict, dict) + assert poll_option_added_dict["poll_message"] == poll_option_added.poll_message.to_dict() + assert ( + poll_option_added_dict["option_persistent_id"] + == poll_option_added.option_persistent_id + ) + assert poll_option_added_dict["option_text"] == poll_option_added.option_text + assert poll_option_added_dict["option_text_entities"] == [ + e.to_dict() for e in poll_option_added.option_text_entities + ] + + def test_parse_option_text_entity(self, poll_option_added): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + poll_option_added.option_text_entities = [entity] + + assert poll_option_added.parse_option_text_entity(entity) == "test" + + def test_parse_option_text_entities(self, poll_option_added): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + entity_2 = MessageEntity(MessageEntity.ITALIC, 5, 6) + poll_option_added.option_text_entities = [entity, entity_2] + + assert poll_option_added.parse_option_text_entities(MessageEntity.BOLD) == {entity: "test"} + assert poll_option_added.parse_option_text_entities() == { + entity: "test", + entity_2: "option", + } + + def test_equality(self): + a = PollOptionAdded( + poll_message=self.poll_message, + option_persistent_id=self.option_persistent_id, + option_text=self.option_text, + option_text_entities=self.option_text_entities, + ) + b = PollOptionAdded( + poll_message=self.poll_message, + option_persistent_id=self.option_persistent_id, + option_text=self.option_text, + option_text_entities=self.option_text_entities, + ) + c = PollOptionAdded( + poll_message=MaybeInaccessibleMessage( + 2, dtm.datetime.now(dtm.timezone.utc), Chat(1, "test_chat") + ), + option_persistent_id=self.option_persistent_id, + option_text=self.option_text, + option_text_entities=self.option_text_entities, + ) + + d = PollOptionAdded( + poll_message=self.poll_message, + option_persistent_id="different_id", + option_text=self.option_text, + option_text_entities=self.option_text_entities, + ) + + assert a == b + assert hash(a) == hash(b) + + assert a == c + assert hash(a) == hash(c) + + assert a != d + assert hash(a) != hash(d) + + +@pytest.fixture(scope="module") +def poll_option_deleted(): + p = PollOptionDeleted( + poll_message=PollOptionDeletedTestBase.poll_message, + option_persistent_id=PollOptionDeletedTestBase.option_persistent_id, + option_text=PollOptionDeletedTestBase.option_text, + option_text_entities=PollOptionDeletedTestBase.option_text_entities, + ) + p._unfreeze() + return p + + +class PollOptionDeletedTestBase: + poll_message = MaybeInaccessibleMessage( + message_id=1, + date=dtm.datetime.now(dtm.timezone.utc), + chat=Chat(1, "test_chat"), + ) + option_persistent_id = "123" + option_text = "test option" + option_text_entities = [ + MessageEntity(MessageEntity.BOLD, 0, 4), + MessageEntity(MessageEntity.ITALIC, 5, 6), + ] + + +class TestPollOptionDeletedWithoutRequest(PollOptionDeletedTestBase): + def test_slot_behaviour(self, poll_option_deleted): + for attr in poll_option_deleted.__slots__: + assert getattr(poll_option_deleted, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(poll_option_deleted)) == len(set(mro_slots(poll_option_deleted))), ( + "duplicate slot" + ) + + def test_de_json(self, offline_bot): + json_dict = { + "poll_message": self.poll_message.to_dict(), + "option_persistent_id": self.option_persistent_id, + "option_text": self.option_text, + "option_text_entities": [e.to_dict() for e in self.option_text_entities], + } + poll_option_deleted = PollOptionDeleted.de_json(json_dict, offline_bot) + assert poll_option_deleted.api_kwargs == {} + + assert poll_option_deleted.poll_message == self.poll_message + assert poll_option_deleted.option_persistent_id == self.option_persistent_id + assert poll_option_deleted.option_text == self.option_text + assert poll_option_deleted.option_text_entities == tuple(self.option_text_entities) + + def test_to_dict(self, poll_option_deleted): + poll_option_deleted_dict = poll_option_deleted.to_dict() + + assert isinstance(poll_option_deleted_dict, dict) + assert ( + poll_option_deleted_dict["poll_message"] == poll_option_deleted.poll_message.to_dict() + ) + assert ( + poll_option_deleted_dict["option_persistent_id"] + == poll_option_deleted.option_persistent_id + ) + assert poll_option_deleted_dict["option_text"] == poll_option_deleted.option_text + assert poll_option_deleted_dict["option_text_entities"] == [ + e.to_dict() for e in poll_option_deleted.option_text_entities + ] + + def test_parse_option_text_entity(self, poll_option_deleted): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + poll_option_deleted.option_text_entities = [entity] + + assert poll_option_deleted.parse_option_text_entity(entity) == "test" + + def test_parse_option_text_entities(self, poll_option_deleted): + entity = MessageEntity(MessageEntity.BOLD, 0, 4) + entity_2 = MessageEntity(MessageEntity.ITALIC, 5, 6) + poll_option_deleted.option_text_entities = [entity, entity_2] + + assert poll_option_deleted.parse_option_text_entities(MessageEntity.BOLD) == { + entity: "test" + } + assert poll_option_deleted.parse_option_text_entities() == { + entity: "test", + entity_2: "option", + } + + def test_equality(self): + a = PollOptionDeleted( + poll_message=self.poll_message, + option_persistent_id=self.option_persistent_id, + option_text=self.option_text, + option_text_entities=self.option_text_entities, + ) + b = PollOptionDeleted( + poll_message=self.poll_message, + option_persistent_id=self.option_persistent_id, + option_text=self.option_text, + option_text_entities=self.option_text_entities, + ) + c = PollOptionDeleted( + poll_message=MaybeInaccessibleMessage( + 2, dtm.datetime.now(dtm.timezone.utc), Chat(1, "test_chat") + ), + option_persistent_id=self.option_persistent_id, + option_text=self.option_text, + option_text_entities=self.option_text_entities, + ) + + d = PollOptionDeleted( + poll_message=self.poll_message, + option_persistent_id="different_id", + option_text=self.option_text, + option_text_entities=self.option_text_entities, + ) + + 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_preparedkeyboardbutton.py b/tests/test_preparedkeyboardbutton.py new file mode 100644 index 00000000000..9b62be8445a --- /dev/null +++ b/tests/test_preparedkeyboardbutton.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +import pytest + +from telegram import PreparedKeyboardButton +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def prepared_keyboard_button(): + return PreparedKeyboardButton(PreparedKeyboardButtonTestBase.id) + + +class PreparedKeyboardButtonTestBase: + id = "4" + + +class TestPreparedKeyboardButtonWithoutRequest(PreparedKeyboardButtonTestBase): + def test_slot_behaviour(self, prepared_keyboard_button): + inst = prepared_keyboard_button + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_de_json(self, offline_bot): + json_dict = { + "id": self.id, + } + prepared_keyboard_button = PreparedKeyboardButton.de_json(json_dict, offline_bot) + assert prepared_keyboard_button.api_kwargs == {} + assert prepared_keyboard_button.id == self.id + + def test_to_dict(self, prepared_keyboard_button): + prepared_keyboard_button_dict = prepared_keyboard_button.to_dict() + assert prepared_keyboard_button_dict["id"] == self.id + + def test_equality(self): + a = PreparedKeyboardButton(self.id) + b = PreparedKeyboardButton(self.id) + c = PreparedKeyboardButton("5") + + assert a == b + assert hash(a) == hash(b) + + assert a != c + assert hash(a) != hash(c) diff --git a/tests/test_reply.py b/tests/test_reply.py index 16e9da49957..fa544845522 100644 --- a/tests/test_reply.py +++ b/tests/test_reply.py @@ -29,6 +29,7 @@ ExternalReplyInfo, Giveaway, LinkPreviewOptions, + LivePhoto, MessageEntity, MessageOriginUser, PaidMediaInfo, @@ -50,6 +51,7 @@ def external_reply_info(): giveaway=ExternalReplyInfoTestBase.giveaway, paid_media=ExternalReplyInfoTestBase.paid_media, checklist=ExternalReplyInfoTestBase.checklist, + live_photo=ExternalReplyInfoTestBase.live_photo, ) @@ -73,6 +75,16 @@ class ExternalReplyInfoTestBase: ChecklistTask(text="Item 2", id=2), ], ) + live_photo = LivePhoto( + file_id="file_id", + file_unique_id="file_unique_id", + width=100, + height=100, + duration=dtm.timedelta(seconds=10), + photo=[], + mime_type="image/jpeg", + file_size=1024, + ) class TestExternalReplyInfoWithoutRequest(ExternalReplyInfoTestBase): @@ -92,6 +104,7 @@ def test_de_json(self, offline_bot): "giveaway": self.giveaway.to_dict(), "paid_media": self.paid_media.to_dict(), "checklist": self.checklist.to_dict(), + "live_photo": self.live_photo.to_dict(), } external_reply_info = ExternalReplyInfo.de_json(json_dict, offline_bot) @@ -104,6 +117,7 @@ def test_de_json(self, offline_bot): assert external_reply_info.giveaway == self.giveaway assert external_reply_info.paid_media == self.paid_media assert external_reply_info.checklist == self.checklist + assert external_reply_info.live_photo == self.live_photo def test_to_dict(self, external_reply_info): ext_reply_info_dict = external_reply_info.to_dict() @@ -116,6 +130,7 @@ def test_to_dict(self, external_reply_info): assert ext_reply_info_dict["giveaway"] == self.giveaway.to_dict() assert ext_reply_info_dict["paid_media"] == self.paid_media.to_dict() assert ext_reply_info_dict["checklist"] == self.checklist.to_dict() + assert ext_reply_info_dict["live_photo"] == self.live_photo.to_dict() def test_equality(self, external_reply_info): a = external_reply_info @@ -219,6 +234,7 @@ def reply_parameters(): quote_entities=ReplyParametersTestBase.quote_entities, quote_position=ReplyParametersTestBase.quote_position, checklist_task_id=ReplyParametersTestBase.checklist_task_id, + poll_option_id=ReplyParametersTestBase.poll_option_id, ) @@ -234,6 +250,7 @@ class ReplyParametersTestBase: ] quote_position = 5 checklist_task_id = 9 + poll_option_id = "213" class TestReplyParametersWithoutRequest(ReplyParametersTestBase): @@ -254,6 +271,7 @@ def test_de_json(self, offline_bot): "quote_entities": [entity.to_dict() for entity in self.quote_entities], "quote_position": self.quote_position, "checklist_task_id": self.checklist_task_id, + "poll_option_id": self.poll_option_id, } reply_parameters = ReplyParameters.de_json(json_dict, offline_bot) @@ -267,6 +285,7 @@ def test_de_json(self, offline_bot): assert reply_parameters.quote_entities == tuple(self.quote_entities) assert reply_parameters.quote_position == self.quote_position assert reply_parameters.checklist_task_id == self.checklist_task_id + assert reply_parameters.poll_option_id == self.poll_option_id def test_to_dict(self, reply_parameters): reply_parameters_dict = reply_parameters.to_dict() @@ -285,6 +304,7 @@ def test_to_dict(self, reply_parameters): ] assert reply_parameters_dict["quote_position"] == self.quote_position assert reply_parameters_dict["checklist_task_id"] == self.checklist_task_id + assert reply_parameters_dict["poll_option_id"] == self.poll_option_id def test_equality(self, reply_parameters): a = reply_parameters diff --git a/tests/test_sentguestmessage.py b/tests/test_sentguestmessage.py new file mode 100644 index 00000000000..0208342fb57 --- /dev/null +++ b/tests/test_sentguestmessage.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2026 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. + +import pytest + +from telegram import SentGuestMessage +from tests.auxil.slots import mro_slots + + +@pytest.fixture(scope="module") +def sent_guest_message(): + return SentGuestMessage(inline_message_id=SentGuestMessageTestBase.inline_message_id) + + +class SentGuestMessageTestBase: + inline_message_id = "123" + + +class TestSentGuestMessageWithoutRequest(SentGuestMessageTestBase): + def test_slot_behaviour(self, sent_guest_message): + inst = sent_guest_message + for attr in inst.__slots__: + assert getattr(inst, attr, "err") != "err", f"got extra slot '{attr}'" + assert len(mro_slots(inst)) == len(set(mro_slots(inst))), "duplicate slot" + + def test_to_dict(self, sent_guest_message): + sent_guest_message_dict = sent_guest_message.to_dict() + + assert isinstance(sent_guest_message_dict, dict) + assert sent_guest_message_dict["inline_message_id"] == self.inline_message_id + + def test_de_json(self, offline_bot): + data = {"inline_message_id": self.inline_message_id} + m = SentGuestMessage.de_json(data, None) + assert m.api_kwargs == {} + assert m.inline_message_id == self.inline_message_id + + def test_equality(self): + a = SentGuestMessage(self.inline_message_id) + b = SentGuestMessage(self.inline_message_id) + c = SentGuestMessage("not_inline_message_id") + + assert a == b + assert hash(a) == hash(b) + assert a is not b + + assert a != c + assert hash(a) != hash(c) diff --git a/tests/test_update.py b/tests/test_update.py index b74234b9435..cbaa1d4b66a 100644 --- a/tests/test_update.py +++ b/tests/test_update.py @@ -38,6 +38,7 @@ ChosenInlineResult, InaccessibleMessage, InlineQuery, + ManagedBotUpdated, Message, MessageReactionCountUpdated, MessageReactionUpdated, @@ -151,6 +152,18 @@ paid_media_payload="payload", ) +managed_bot = ManagedBotUpdated( + user=User(1, "creator", True), + bot=User(2, "bot", True), +) +guest_message = Message( + 1, + dtm.datetime.utcnow(), + Chat(1, ""), + User(1, "", False), + sender_chat=Chat(1, ""), +) + params = [ {"message": message}, @@ -166,17 +179,31 @@ ) }, {"pre_checkout_query": PreCheckoutQuery("id", User(1, "", False), "", 0, "")}, - {"poll": Poll("id", "?", [PollOption(".", 1)], False, False, False, Poll.REGULAR, True)}, { - "poll_answer": PollAnswer( + "poll": Poll( "id", - [1], - User( + "?", + [PollOption(text=".", voter_count=1, persistent_id="persistent_id")], + False, + False, + False, + Poll.REGULAR, + True, + allows_revoting=True, + members_only=True, + ) + }, + { + "poll_answer": PollAnswer( + poll_id="id", + option_ids=[1], + option_persistent_ids=["1"], + user=User( 1, "", False, ), - Chat(1, ""), + voter_chat=Chat(1, ""), ) }, {"my_chat_member": chat_member_updated}, @@ -191,6 +218,8 @@ {"business_message": business_message}, {"edited_business_message": business_message}, {"purchased_paid_media": purchased_paid_media}, + {"managed_bot": managed_bot}, + {"guest_message": guest_message}, # Must be last to conform with `ids` below! {"callback_query": CallbackQuery(1, User(1, "", False), "chat")}, ] @@ -219,6 +248,8 @@ "business_message", "edited_business_message", "purchased_paid_media", + "managed_bot", + "guest_message", ) ids = (*all_types, "callback_query_without_message") @@ -300,6 +331,7 @@ def test_effective_chat(self, update): or update.poll_answer is not None or update.business_connection is not None or update.purchased_paid_media is not None + or update.managed_bot is not None ): assert chat.id == 1 else: @@ -324,7 +356,7 @@ def test_effective_user(self, update): def test_effective_sender_non_anonymous(self, update): update = deepcopy(update) # Simulate 'Remain anonymous' being turned off - if message := (update.message or update.edited_message): + if message := (update.message or update.edited_message or update.guest_message): message._unfreeze() message.sender_chat = None elif reaction := (update.message_reaction): @@ -357,7 +389,7 @@ def test_effective_sender_non_anonymous(self, update): def test_effective_sender_anonymous(self, update): update = deepcopy(update) # Simulate 'Remain anonymous' being turned on - if message := (update.message or update.edited_message): + if message := (update.message or update.edited_message or update.guest_message): message._unfreeze() message.from_user = None elif reaction := (update.message_reaction): @@ -383,6 +415,7 @@ def test_effective_sender_anonymous(self, update): or update.edited_channel_post or update.message_reaction or update.poll_answer + or update.guest_message ): assert isinstance(sender, Chat) else: @@ -393,6 +426,18 @@ def test_effective_sender_anonymous(self, update): cached = update.effective_sender assert cached is sender + def test_effective_sender_signed_channel_post(self): + # channel_post with signatures can have its from_user + user = User(1, "", False) + post = Message( + 1, dtm.datetime.utcnow(), Chat(1, ""), author_signature="", from_user=user, text="Text" + ) + update = Update(update_id=1, channel_post=post) + assert update.effective_sender == update.effective_user == user + + update = Update(update_id=2, edited_channel_post=post) + assert update.effective_sender == update.effective_user == user + def test_effective_message(self, update): # Test that it's sometimes None per docstring eff_message = update.effective_message @@ -414,6 +459,7 @@ def test_effective_message(self, update): or update.deleted_business_messages is not None or update.business_connection is not None or update.purchased_paid_media is not None + or update.managed_bot is not None ): assert eff_message.message_id == message.message_id else: diff --git a/tests/test_user.py b/tests/test_user.py index ba237ea476d..197dbbe3967 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -46,6 +46,8 @@ def json_dict(): "has_main_web_app": UserTestBase.has_main_web_app, "has_topics_enabled": UserTestBase.has_topics_enabled, "allows_users_to_create_topics": UserTestBase.allows_users_to_create_topics, + "can_manage_bots": UserTestBase.can_manage_bots, + "supports_guest_queries": UserTestBase.supports_guest_queries, } @@ -67,6 +69,8 @@ def user(bot): has_main_web_app=UserTestBase.has_main_web_app, has_topics_enabled=UserTestBase.has_topics_enabled, allows_users_to_create_topics=UserTestBase.allows_users_to_create_topics, + can_manage_bots=UserTestBase.can_manage_bots, + supports_guest_queries=UserTestBase.supports_guest_queries, ) user.set_bot(bot) user._unfreeze() @@ -89,6 +93,8 @@ class UserTestBase: has_main_web_app = False has_topics_enabled = False allows_users_to_create_topics = False + can_manage_bots = True + supports_guest_queries = False class TestUserWithoutRequest(UserTestBase): @@ -116,6 +122,8 @@ def test_de_json(self, json_dict, offline_bot): assert user.has_main_web_app == self.has_main_web_app assert user.has_topics_enabled == self.has_topics_enabled assert user.allows_users_to_create_topics == self.allows_users_to_create_topics + assert user.can_manage_bots == self.can_manage_bots + assert user.supports_guest_queries == self.supports_guest_queries def test_to_dict(self, user): user_dict = user.to_dict() @@ -136,6 +144,8 @@ def test_to_dict(self, user): assert user_dict["has_main_web_app"] == user.has_main_web_app assert user_dict["has_topics_enabled"] == user.has_topics_enabled assert user_dict["allows_users_to_create_topics"] == user.allows_users_to_create_topics + assert user_dict["can_manage_bots"] == user.can_manage_bots + assert user_dict["supports_guest_queries"] == user.supports_guest_queries def test_equality(self): a = User(self.id_, self.first_name, self.is_bot, self.last_name) @@ -286,6 +296,21 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(user.get_bot(), "send_photo", make_assertion) assert await user.send_photo("test_photo") + async def test_instance_method_send_live_photo(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["chat_id"] == user.id + and kwargs["live_photo"] == "test_live_photo" + and kwargs["photo"] == "test_photo" + ) + + assert check_shortcut_signature(User.send_live_photo, Bot.send_live_photo, ["chat_id"], []) + assert await check_shortcut_call(user.send_live_photo, user.get_bot(), "send_live_photo") + assert await check_defaults_handling(user.send_live_photo, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "send_live_photo", make_assertion) + assert await user.send_live_photo("test_live_photo", "test_photo") + async def test_instance_method_send_media_group(self, monkeypatch, user): async def make_assertion(*_, **kwargs): return kwargs["chat_id"] == user.id and kwargs["media"] == "test_media_group" @@ -906,3 +931,130 @@ async def make_assertion(*_, **kwargs): monkeypatch.setattr(user.get_bot(), "set_chat_member_tag", make_assertion) assert await user.set_chat_member_tag(chat_id="chat_id", tag="tag") + + async def test_instance_method_replace_token(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return kwargs["user_id"] == user.id + + assert check_shortcut_signature( + user.replace_token, Bot.replace_managed_bot_token, ["user_id"], [] + ) + assert await check_shortcut_call( + user.replace_token, user.get_bot(), "replace_managed_bot_token" + ) + assert await check_defaults_handling(user.replace_token, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "replace_managed_bot_token", make_assertion) + assert await user.replace_token() + + async def test_instance_method_get_managed_bot_access_settings(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return kwargs["user_id"] == user.id + + assert check_shortcut_signature( + user.get_managed_bot_access_settings, + Bot.get_managed_bot_access_settings, + ["user_id"], + [], + ) + assert await check_shortcut_call( + user.get_managed_bot_access_settings, + user.get_bot(), + "get_managed_bot_access_settings", + ) + assert await check_defaults_handling(user.get_managed_bot_access_settings, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "get_managed_bot_access_settings", make_assertion) + assert await user.get_managed_bot_access_settings() + + async def test_instance_method_set_managed_bot_access_settings(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["user_id"] == user.id + and kwargs["is_access_restricted"] is True + and kwargs["added_user_ids"] == [123] + ) + + assert check_shortcut_signature( + user.set_managed_bot_access_settings, + Bot.set_managed_bot_access_settings, + ["user_id"], + [], + ) + assert await check_shortcut_call( + user.set_managed_bot_access_settings, + user.get_bot(), + "set_managed_bot_access_settings", + ) + assert await check_defaults_handling(user.set_managed_bot_access_settings, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "set_managed_bot_access_settings", make_assertion) + assert await user.set_managed_bot_access_settings( + is_access_restricted=True, + added_user_ids=[123], + ) + + async def test_instance_method_get_personal_chat_messages(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return kwargs["user_id"] == user.id and kwargs["limit"] == 2 + + assert check_shortcut_signature( + user.get_personal_chat_messages, + Bot.get_user_personal_chat_messages, + ["user_id"], + [], + ) + assert await check_shortcut_call( + user.get_personal_chat_messages, + user.get_bot(), + "get_user_personal_chat_messages", + ) + assert await check_defaults_handling(user.get_personal_chat_messages, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "get_user_personal_chat_messages", make_assertion) + assert await user.get_personal_chat_messages(limit=2) + + async def test_instance_method_delete_reaction(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["user_id"] == user.id + and kwargs["chat_id"] == 1234 + and kwargs["message_id"] == 123 + and kwargs["actor_chat_id"] == 42 + ) + + assert check_shortcut_signature( + user.delete_reaction, Bot.delete_message_reaction, ["user_id"], [] + ) + assert await check_shortcut_call( + user.delete_reaction, + user.get_bot(), + "delete_message_reaction", + shortcut_kwargs=["user_id"], + ) + assert await check_defaults_handling(user.delete_reaction, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "delete_message_reaction", make_assertion) + assert await user.delete_reaction(chat_id=1234, message_id=123, actor_chat_id=42) + + async def test_instance_method_delete_all_reactions(self, monkeypatch, user): + async def make_assertion(*_, **kwargs): + return ( + kwargs["user_id"] == user.id + and kwargs["chat_id"] == 1234 + and kwargs["actor_chat_id"] == 42 + ) + + assert check_shortcut_signature( + user.delete_all_reactions, Bot.delete_all_message_reactions, ["user_id"], [] + ) + assert await check_shortcut_call( + user.delete_all_reactions, + user.get_bot(), + "delete_all_message_reactions", + shortcut_kwargs=["user_id"], + ) + assert await check_defaults_handling(user.delete_all_reactions, user.get_bot()) + + monkeypatch.setattr(user.get_bot(), "delete_all_message_reactions", make_assertion) + assert await user.delete_all_reactions(chat_id=1234, actor_chat_id=42) diff --git a/uv.lock b/uv.lock index 1dcaef13739..456da08c6e4 100644 --- a/uv.lock +++ b/uv.lock @@ -2,13 +2,18 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.14'", + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", "python_full_version == '3.13.*'", "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", "python_full_version < '3.11'", ] +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P7D" + [[package]] name = "accessible-pygments" version = "0.0.5" @@ -495,62 +500,62 @@ toml = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "48.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, - { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, - { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, - { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, - { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, - { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, - { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" }, + { url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, ] [[package]] @@ -579,7 +584,8 @@ name = "docutils" version = "0.22.4" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14'", + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", "python_full_version == '3.13.*'", "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", @@ -975,7 +981,7 @@ wheels = [ [[package]] name = "mypy" -version = "1.19.1" +version = "1.20.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, @@ -984,39 +990,51 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, - { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, - { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, - { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, - { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, - { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, - { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, - { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/97/ce2502df2cecf2ef997b6c6527c4a223b92feb9e7b790cdc8dcd683f3a8a/mypy-1.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cf5a4db6dca263010e2c7bff081c89383c72d187ba2cf4c44759aac970e2f0c4", size = 14457059, upload-time = "2026-04-21T17:06:14.935Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/417ee60b822cc80c0f3dc9f495ad7fd8dbb8d8b2cf4baf22d4046d25d01d/mypy-1.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7b0e817b518bff7facd7f85ea05b643ad8bdcce684cf29784987b0a7c8e1f997", size = 13346816, upload-time = "2026-04-21T17:10:41.433Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/e20951978702df58379d0bcc2e8f7ccdca4e78cd7dc66dd3ddbf9b29d517/mypy-1.20.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97d7b9a485b40f8ca425460e89bf1da2814625b2da627c0dcc6aa46c92631d14", size = 13772593, upload-time = "2026-04-21T17:08:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/63/a5/5441a13259ec516c56fd5de0fd96a69a9590ae6c5e5d3e5174aa84b97973/mypy-1.20.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e1c12f6d2db3d78b909b5f77513c11eb7f2dd2782b96a3ab6dffc7d44575c99", size = 14656635, upload-time = "2026-04-21T17:09:54.042Z" }, + { url = "https://files.pythonhosted.org/packages/3b/51/b89c69157c5e1f19fd125a65d991166a26906e7902f026f00feebbcfa2b9/mypy-1.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89dce27e142d25ffbc154c1819383b69f2e9234dc4ed4766f42e0e8cb264ab5c", size = 14943278, upload-time = "2026-04-21T17:09:15.599Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/6b0eeecfe96d7cce1d71c66b8e03cb304aa70ec11f1955dc1d6b46aca3c3/mypy-1.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:f376e37f9bf2a946872fc5fd1199c99310748e3c26c7a26683f13f8bdb756cbd", size = 10851915, upload-time = "2026-04-21T17:06:03.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/6593dc88545d75fb96416184be5392da5e2a8e8c2802a8597913e16ae25c/mypy-1.20.2-cp310-cp310-win_arm64.whl", hash = "sha256:6e2b469efd811707bc530fd1effef0f5d6eebcb7fe376affae69025da4b979a2", size = 9786676, upload-time = "2026-04-21T17:07:02.035Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, + { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, + { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, + { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, + { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, + { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, + { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, + { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, ] [[package]] @@ -1121,7 +1139,8 @@ name = "pydantic" version = "2.13.0b2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14'", + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", ] dependencies = [ { name = "annotated-types", marker = "python_full_version >= '3.14'" }, @@ -1261,7 +1280,8 @@ name = "pydantic-core" version = "2.42.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14'", + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", ] dependencies = [ { name = "typing-extensions", marker = "python_full_version >= '3.14'" }, @@ -1423,7 +1443,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1434,9 +1454,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] [[package]] @@ -1622,22 +1642,22 @@ all = [ { name = "chango", marker = "python_full_version >= '3.12'", specifier = "~=0.6.0" }, { name = "flaky", specifier = ">=3.8.1" }, { name = "furo", specifier = "==2025.12.19" }, - { name = "mypy", specifier = "==1.19.1" }, + { name = "mypy", specifier = "==1.20.2" }, { name = "prek" }, { name = "pydantic", marker = "python_full_version >= '3.14'", specifier = ">=2.12.0a1" }, { name = "pylint", specifier = "==4.0.5" }, - { name = "pytest", specifier = "==9.0.2" }, + { name = "pytest", specifier = "==9.0.3" }, { name = "pytest-asyncio", specifier = "==0.21.2" }, { name = "pytest-cov" }, { name = "pytest-xdist", specifier = "==3.8.0" }, { name = "pytz" }, - { name = "ruff", specifier = "==0.15.6" }, + { name = "ruff", specifier = "==0.15.15" }, { name = "sphinx", marker = "python_full_version >= '3.12'", specifier = "==9.1.0" }, { name = "sphinx-build-compatibility", git = "https://github.com/readthedocs/sphinx-build-compatibility.git?rev=58aabc5f207c6c2421f23d3578adc0b14af57047" }, { name = "sphinx-copybutton", specifier = "==0.5.2" }, { name = "sphinx-inline-tabs", specifier = "==2025.12.21.14" }, { name = "sphinx-paramlinks", specifier = "==0.6.0" }, - { name = "sphinxcontrib-mermaid", specifier = "==2.0.1" }, + { name = "sphinxcontrib-mermaid", specifier = "==2.0.2" }, { name = "tzdata" }, ] docs = [ @@ -1649,19 +1669,19 @@ docs = [ { name = "sphinx-copybutton", specifier = "==0.5.2" }, { name = "sphinx-inline-tabs", specifier = "==2025.12.21.14" }, { name = "sphinx-paramlinks", specifier = "==0.6.0" }, - { name = "sphinxcontrib-mermaid", specifier = "==2.0.1" }, + { name = "sphinxcontrib-mermaid", specifier = "==2.0.2" }, ] linting = [ - { name = "mypy", specifier = "==1.19.1" }, + { name = "mypy", specifier = "==1.20.2" }, { name = "prek" }, { name = "pylint", specifier = "==4.0.5" }, - { name = "ruff", specifier = "==0.15.6" }, + { name = "ruff", specifier = "==0.15.15" }, ] tests = [ { name = "beautifulsoup4" }, { name = "build" }, { name = "flaky", specifier = ">=3.8.1" }, - { name = "pytest", specifier = "==9.0.2" }, + { name = "pytest", specifier = "==9.0.3" }, { name = "pytest-asyncio", specifier = "==0.21.2" }, { name = "pytest-cov" }, { name = "pytest-xdist", specifier = "==3.8.0" }, @@ -1781,27 +1801,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, - { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, - { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, - { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, - { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, - { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, - { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, - { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, - { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, - { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, - { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, - { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, - { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, +version = "0.15.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, ] [[package]] @@ -1916,7 +1936,8 @@ name = "sphinx" version = "9.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.14'", + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", "python_full_version == '3.13.*'", "python_full_version == '3.12.*'", ] @@ -2048,7 +2069,7 @@ wheels = [ [[package]] name = "sphinxcontrib-mermaid" -version = "2.0.1" +version = "2.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, @@ -2057,9 +2078,9 @@ dependencies = [ { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/ae/999891de292919b66ea34f2c22fc22c9be90ab3536fbc0fca95716277351/sphinxcontrib_mermaid-2.0.1.tar.gz", hash = "sha256:a21a385a059a6cafd192aa3a586b14bf5c42721e229db67b459dc825d7f0a497", size = 19839, upload-time = "2026-03-05T14:10:41.901Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/3a1cc926da8c563c58ddc124a7b3fe5ccadcae96c96e3a6f8ac3653a210a/sphinxcontrib_mermaid-2.0.2.tar.gz", hash = "sha256:f09576c78ca93fa0e3034fd9c45aaffa7c44ab449de9c43b8b8d262afe52bc66", size = 19265, upload-time = "2026-05-05T13:59:02.959Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/46/25d64bcd7821c8d6f1080e1c43d5fcdfc442a18f759a230b5ccdc891093e/sphinxcontrib_mermaid-2.0.1-py3-none-any.whl", hash = "sha256:9dca7fbe827bad5e7e2b97c4047682cfd26e3e07398cfdc96c7a8842ae7f06e7", size = 14064, upload-time = "2026-03-05T14:10:40.533Z" }, + { url = "https://files.pythonhosted.org/packages/16/8d/93be7e0f7fa915a576859b3bfac7a7baa3303181c44d7db7eefbd3e8a69f/sphinxcontrib_mermaid-2.0.2-py3-none-any.whl", hash = "sha256:d862e514991279fb4816302c5cfe167d2557bf3ce7125ae0cb47dac80a0f46ce", size = 14094, upload-time = "2026-05-05T13:59:01.585Z" }, ] [[package]]