mirror of
https://github.com/python-telegram-bot/python-telegram-bot.git
synced 2026-06-21 08:35:28 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 186fd1b418 | |||
| 284786fdb8 | |||
| c7c56ad24e | |||
| ae17ce977e | |||
| 7e231183c4 | |||
| 8427346a0d | |||
| 632b989d90 | |||
| 76567ba635 | |||
| 2bd3f2a65a | |||
| 26a5006bf1 | |||
| 110e2df443 | |||
| 57546795c5 | |||
| 314f87ec44 | |||
| 4bbcd51ef5 |
@@ -10,7 +10,7 @@ assignees: ''
|
||||
<!--
|
||||
Hey there, you have a question? We are happy to answer. Please make sure no similar question was opened already.
|
||||
|
||||
The following template is a suggestion how you can report an issue you run into whilst using our library. If you just want to ask a question, feel free to delete everything; just make sure you have a describing title :)
|
||||
To make it easier for us to help you, please try to follow the template below as closely as possible.
|
||||
|
||||
Please mind that there is also a users' Telegram group at https://t.me/pythontelegrambotgroup for questions about the library. Questions asked there might be answered quicker than here. In case you are unable to join our group due to Telegram restrictions, you can use our IRC channel at https://webchat.freenode.net/?channels=##python-telegram-bot to participate in the group.
|
||||
-->
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
exit ${global_exit}
|
||||
env:
|
||||
JOB_INDEX: ${{ strategy.job-index }}
|
||||
BOTS: W3sidG9rZW4iOiAiNjk2MTg4NzMyOkFBR1Z3RUtmSEhsTmpzY3hFRE5LQXdraEdzdFpfa28xbUMwIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WldGaU1UUmxNbVF5TnpNeSIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIENQeXRob24gMi43IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19jcHl0aG9uXzI3X2JvdCJ9LCB7InRva2VuIjogIjY3MTQ2ODg4NjpBQUdQR2ZjaVJJQlVORmU4MjR1SVZkcTdKZTNfWW5BVE5HdyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOlpHWXdPVGxrTXpNeE4yWTIiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIFRyYXZpcyB1c2luZyBDUHl0aG9uIDMuNCIsICJib3RfdXNlcm5hbWUiOiAiQHB0Yl90cmF2aXNfY3B5dGhvbl8zNF9ib3QifSwgeyJ0b2tlbiI6ICI2MjkzMjY1Mzg6QUFGUnJaSnJCN29CM211ekdzR0pYVXZHRTVDUXpNNUNVNG8iLCAicGF5bWVudF9wcm92aWRlcl90b2tlbiI6ICIyODQ2ODUwNjM6VEVTVDpNbU01WVdKaFl6a3hNMlUxIiwgImJvdF9uYW1lIjogIlBUQiB0ZXN0cyBvbiBUcmF2aXMgdXNpbmcgQ1B5dGhvbiAzLjUiLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfdHJhdmlzX2NweXRob25fMzVfYm90In0sIHsidG9rZW4iOiAiNjQwMjA4OTQzOkFBRmhCalFwOXFtM1JUeFN6VXBZekJRakNsZS1Kano1aGNrIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WXpoa1pUZzFOamMxWXpWbCIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIENQeXRob24gMy42IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19jcHl0aG9uXzM2X2JvdCJ9LCB7InRva2VuIjogIjY5NTEwNDA4ODpBQUhmenlsSU9qU0lJUy1lT25JMjB5MkUyMEhvZEhzZnotMCIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOk9HUTFNRGd3WmpJd1pqRmwiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIFRyYXZpcyB1c2luZyBDUHl0aG9uIDMuNyIsICJib3RfdXNlcm5hbWUiOiAiQHB0Yl90cmF2aXNfY3B5dGhvbl8zN19ib3QifSwgeyJ0b2tlbiI6ICI2OTE0MjM1NTQ6QUFGOFdrakNaYm5IcVBfaTZHaFRZaXJGRWxackdhWU9oWDAiLCAicGF5bWVudF9wcm92aWRlcl90b2tlbiI6ICIyODQ2ODUwNjM6VEVTVDpZamM1TlRoaU1tUXlNV1ZoIiwgImJvdF9uYW1lIjogIlBUQiB0ZXN0cyBvbiBUcmF2aXMgdXNpbmcgUHlQeSAyLjciLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfdHJhdmlzX3B5cHlfMjdfYm90In0sIHsidG9rZW4iOiAiNjg0MzM5OTg0OkFBRk1nRUVqcDAxcjVyQjAwN3lDZFZOc2c4QWxOc2FVLWNjIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6TVRBek1UWTNNR1V5TmpnMCIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIFB5UHkgMy41IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19weXB5XzM1X2JvdCJ9LCB7InRva2VuIjogIjY5MDA5MTM0NzpBQUZMbVI1cEFCNVljcGVfbU9oN3pNNEpGQk9oMHozVDBUbyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOlpEaGxOekU1TURrd1lXSmkiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIEFwcFZleW9yIHVzaW5nIENQeXRob24gMy40IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX2FwcHZleW9yX2NweXRob25fMzRfYm90In0sIHsidG9rZW4iOiAiNjk0MzA4MDUyOkFBRUIyX3NvbkNrNTVMWTlCRzlBTy1IOGp4aVBTNTVvb0JBIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WW1aaVlXWm1NakpoWkdNeSIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gQXBwVmV5b3IgdXNpbmcgQ1B5dGhvbiAyLjciLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfYXBwdmV5b3JfY3B5dGhvbl8yN19ib3QifSwgeyJ0b2tlbiI6ICIxMDU1Mzk3NDcxOkFBRzE4bkJfUzJXQXd1SjNnN29oS0JWZ1hYY2VNbklPeVNjIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6TmpBd056QXpZalZpTkdOayIsICJuYW1lIjogIlBUQiB0ZXN0cyBbMF0iLCAidXNlcm5hbWUiOiAicHRiXzBfYm90In0sIHsidG9rZW4iOiAiMTA0NzMyNjc3MTpBQUY4bk90ODFGcFg4bGJidno4VWV3UVF2UmZUYkZmQnZ1SSIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOllUVTFOVEk0WkdSallqbGkiLCAibmFtZSI6ICJQVEIgdGVzdHMgWzFdIiwgInVzZXJuYW1lIjogInB0Yl8xX2JvdCJ9LCB7InRva2VuIjogIjk3MTk5Mjc0NTpBQUdPa09hVzBOSGpnSXY1LTlqUWJPajR2R3FkaFNGLVV1cyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOk5XWmtNV1ZoWWpsallqVTUiLCAibmFtZSI6ICJQVEIgdGVzdHMgWzJdIiwgInVzZXJuYW1lIjogInB0Yl8yX2JvdCJ9XQ==
|
||||
BOTS: W3sidG9rZW4iOiAiNjk2MTg4NzMyOkFBR1Z3RUtmSEhsTmpzY3hFRE5LQXdraEdzdFpfa28xbUMwIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WldGaU1UUmxNbVF5TnpNeSIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIENQeXRob24gMi43IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxMzkwOTgzOTk3IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19jcHl0aG9uXzI3X2JvdCJ9LCB7InRva2VuIjogIjY3MTQ2ODg4NjpBQUdQR2ZjaVJJQlVORmU4MjR1SVZkcTdKZTNfWW5BVE5HdyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOlpHWXdPVGxrTXpNeE4yWTIiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIFRyYXZpcyB1c2luZyBDUHl0aG9uIDMuNCIsICJzdXBlcl9ncm91cF9pZCI6ICItMTAwMTQ0NjAyMjUyMiIsICJib3RfdXNlcm5hbWUiOiAiQHB0Yl90cmF2aXNfY3B5dGhvbl8zNF9ib3QifSwgeyJ0b2tlbiI6ICI2MjkzMjY1Mzg6QUFGUnJaSnJCN29CM211ekdzR0pYVXZHRTVDUXpNNUNVNG8iLCAicGF5bWVudF9wcm92aWRlcl90b2tlbiI6ICIyODQ2ODUwNjM6VEVTVDpNbU01WVdKaFl6a3hNMlUxIiwgImJvdF9uYW1lIjogIlBUQiB0ZXN0cyBvbiBUcmF2aXMgdXNpbmcgQ1B5dGhvbiAzLjUiLCAic3VwZXJfZ3JvdXBfaWQiOiAiLTEwMDE0OTY5MTc3NTAiLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfdHJhdmlzX2NweXRob25fMzVfYm90In0sIHsidG9rZW4iOiAiNjQwMjA4OTQzOkFBRmhCalFwOXFtM1JUeFN6VXBZekJRakNsZS1Kano1aGNrIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WXpoa1pUZzFOamMxWXpWbCIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIENQeXRob24gMy42IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxMzMzODcxNDYxIiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19jcHl0aG9uXzM2X2JvdCJ9LCB7InRva2VuIjogIjY5NTEwNDA4ODpBQUhmenlsSU9qU0lJUy1lT25JMjB5MkUyMEhvZEhzZnotMCIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOk9HUTFNRGd3WmpJd1pqRmwiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIFRyYXZpcyB1c2luZyBDUHl0aG9uIDMuNyIsICJzdXBlcl9ncm91cF9pZCI6ICItMTAwMTQ3ODI5MzcxNCIsICJib3RfdXNlcm5hbWUiOiAiQHB0Yl90cmF2aXNfY3B5dGhvbl8zN19ib3QifSwgeyJ0b2tlbiI6ICI2OTE0MjM1NTQ6QUFGOFdrakNaYm5IcVBfaTZHaFRZaXJGRWxackdhWU9oWDAiLCAicGF5bWVudF9wcm92aWRlcl90b2tlbiI6ICIyODQ2ODUwNjM6VEVTVDpZamM1TlRoaU1tUXlNV1ZoIiwgImJvdF9uYW1lIjogIlBUQiB0ZXN0cyBvbiBUcmF2aXMgdXNpbmcgUHlQeSAyLjciLCAic3VwZXJfZ3JvdXBfaWQiOiAiLTEwMDEzNjM5MzI1NzMiLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfdHJhdmlzX3B5cHlfMjdfYm90In0sIHsidG9rZW4iOiAiNjg0MzM5OTg0OkFBRk1nRUVqcDAxcjVyQjAwN3lDZFZOc2c4QWxOc2FVLWNjIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6TVRBek1UWTNNR1V5TmpnMCIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIFB5UHkgMy41IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxNDA3ODM2NjA1IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19weXB5XzM1X2JvdCJ9LCB7InRva2VuIjogIjY5MDA5MTM0NzpBQUZMbVI1cEFCNVljcGVfbU9oN3pNNEpGQk9oMHozVDBUbyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOlpEaGxOekU1TURrd1lXSmkiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIEFwcFZleW9yIHVzaW5nIENQeXRob24gMy40IiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxMjc5NjAwMDI2IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX2FwcHZleW9yX2NweXRob25fMzRfYm90In0sIHsidG9rZW4iOiAiNjk0MzA4MDUyOkFBRUIyX3NvbkNrNTVMWTlCRzlBTy1IOGp4aVBTNTVvb0JBIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WW1aaVlXWm1NakpoWkdNeSIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gQXBwVmV5b3IgdXNpbmcgQ1B5dGhvbiAyLjciLCAic3VwZXJfZ3JvdXBfaWQiOiAiLTEwMDEyOTMwNzkxNjUiLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfYXBwdmV5b3JfY3B5dGhvbl8yN19ib3QifSwgeyJ0b2tlbiI6ICIxMDU1Mzk3NDcxOkFBRzE4bkJfUzJXQXd1SjNnN29oS0JWZ1hYY2VNbklPeVNjIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6TmpBd056QXpZalZpTkdOayIsICJuYW1lIjogIlBUQiB0ZXN0cyBbMF0iLCAic3VwZXJfZ3JvdXBfaWQiOiAiLTEwMDExODU1MDk2MzYiLCAidXNlcm5hbWUiOiAicHRiXzBfYm90In0sIHsidG9rZW4iOiAiMTA0NzMyNjc3MTpBQUY4bk90ODFGcFg4bGJidno4VWV3UVF2UmZUYkZmQnZ1SSIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOllUVTFOVEk0WkdSallqbGkiLCAibmFtZSI6ICJQVEIgdGVzdHMgWzFdIiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxNDg0Nzk3NjEyIiwgInVzZXJuYW1lIjogInB0Yl8xX2JvdCJ9LCB7InRva2VuIjogIjk3MTk5Mjc0NTpBQUdPa09hVzBOSGpnSXY1LTlqUWJPajR2R3FkaFNGLVV1cyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOk5XWmtNV1ZoWWpsallqVTUiLCAibmFtZSI6ICJQVEIgdGVzdHMgWzJdIiwgInN1cGVyX2dyb3VwX2lkIjogIi0xMDAxNDAyMjU1MDcwIiwgInVzZXJuYW1lIjogInB0Yl8yX2JvdCJ9XQ==
|
||||
TEST_BUILD: ${{ matrix.test-build }}
|
||||
TEST_PRE_COMMIT: ${{ matrix.test-pre-commit }}
|
||||
shell: bash --noprofile --norc {0}
|
||||
|
||||
@@ -18,6 +18,7 @@ The following wonderful people contributed directly or indirectly to this projec
|
||||
- `Alateas <https://github.com/alateas>`_
|
||||
- `Ales Dokshanin <https://github.com/alesdokshanin>`_
|
||||
- `Ambro17 <https://github.com/Ambro17>`_
|
||||
- `Andrej Zhilenkov <https://github.com/Andrej730>`_
|
||||
- `Anton Tagunov <https://github.com/anton-tagunov>`_
|
||||
- `Avanatiker <https://github.com/Avanatiker>`_
|
||||
- `Balduro <https://github.com/Balduro>`_
|
||||
@@ -26,6 +27,7 @@ The following wonderful people contributed directly or indirectly to this projec
|
||||
- `d-qoi <https://github.com/d-qoi>`_
|
||||
- `daimajia <https://github.com/daimajia>`_
|
||||
- `Daniel Reed <https://github.com/nmlorg>`_
|
||||
- `D David Livingston <https://github.com/daviddl9>`_
|
||||
- `Eana Hufwe <https://github.com/blueset>`_
|
||||
- `Ehsan Online <https://github.com/ehsanonline>`_
|
||||
- `Eli Gao <https://github.com/eligao>`_
|
||||
|
||||
+41
@@ -2,6 +2,47 @@
|
||||
Changelog
|
||||
=========
|
||||
|
||||
Version 12.7
|
||||
============
|
||||
*Released 2020-05-02*
|
||||
|
||||
**Major Changes:**
|
||||
|
||||
- Bot API 4.8 support. **Note:** The ``Dice`` object now has a second positional argument ``emoji``. This is relevant, if you instantiate ``Dice`` objects manually. (`#1917`_)
|
||||
|
||||
**New Features:**
|
||||
|
||||
- New method ``run_mothly`` for the ``JobQueue`` (`#1705`_)
|
||||
- ``Job.next_t`` now gives the datetime of the jobs next execution (`#1685`_)
|
||||
|
||||
**Minor changes, CI improvements, doc fixes or bug fixes:**
|
||||
|
||||
- Added ``tzinfo`` argument to ``helpers.from_timestamp`` (`#1621`_)
|
||||
- Stabalize CI (`#1919`_, `#1931`_)
|
||||
- Use ABCs ``@abstractmethod`` instead of raising ``NotImplementedError`` for ``Handler``, ``BasePersistence`` and ``BaseFilter`` (`#1905`_)
|
||||
- Doc fixes (`#1914`_, `#1902`_, `#1910`_)
|
||||
|
||||
.. _`#1902`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1902
|
||||
.. _`#1685`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1685
|
||||
.. _`#1910`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1910
|
||||
.. _`#1914`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1914
|
||||
.. _`#1931`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1931
|
||||
.. _`#1905`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1905
|
||||
.. _`#1919`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1919
|
||||
.. _`#1621`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1621
|
||||
.. _`#1705`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1705
|
||||
.. _`#1917`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1917
|
||||
|
||||
Version 12.6.1
|
||||
==============
|
||||
*Released 2020-04-11*
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
- Fix serialization of ``reply_markup`` in media messages (`#1889`_)
|
||||
|
||||
.. _`#1889`: https://github.com/python-telegram-bot/python-telegram-bot/pull/1889
|
||||
|
||||
Version 12.6
|
||||
============
|
||||
*Released 2020-04-10*
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ make the development of bots easy and straightforward. These classes are contain
|
||||
Telegram API support
|
||||
====================
|
||||
|
||||
All types and methods of the Telegram Bot API **4.7** are supported.
|
||||
All types and methods of the Telegram Bot API **4.8** are supported.
|
||||
|
||||
==========
|
||||
Installing
|
||||
|
||||
+2
-2
@@ -58,9 +58,9 @@ author = u'Leandro Toledo'
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = '12.6' # telegram.__version__[:3]
|
||||
version = '12.7' # telegram.__version__[:3]
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = '12.6' # telegram.__version__
|
||||
release = '12.7' # telegram.__version__
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
|
||||
@@ -23,13 +23,10 @@ try:
|
||||
except ImportError:
|
||||
import json
|
||||
|
||||
from abc import ABCMeta
|
||||
|
||||
|
||||
class TelegramObject(object):
|
||||
"""Base class for most telegram objects."""
|
||||
|
||||
__metaclass__ = ABCMeta
|
||||
_id_attrs = ()
|
||||
|
||||
def __str__(self):
|
||||
|
||||
+49
-3
@@ -163,7 +163,9 @@ class Bot(TelegramObject):
|
||||
|
||||
if reply_markup is not None:
|
||||
if isinstance(reply_markup, ReplyMarkup):
|
||||
data['reply_markup'] = reply_markup.to_dict()
|
||||
# We need to_json() instead of to_dict() here, because reply_markups may be
|
||||
# attached to media messages, which aren't json dumped by utils.request
|
||||
data['reply_markup'] = reply_markup.to_json()
|
||||
else:
|
||||
data['reply_markup'] = reply_markup
|
||||
|
||||
@@ -2101,7 +2103,7 @@ class Bot(TelegramObject):
|
||||
updates may be received for a short period of time.
|
||||
**kwargs (:obj:`dict`): Arbitrary keyword arguments.
|
||||
|
||||
Notes:
|
||||
Note:
|
||||
1. This method will not work if an outgoing webhook is set up.
|
||||
2. In order to avoid getting duplicate updates, recalculate offset after each
|
||||
server response.
|
||||
@@ -3404,6 +3406,8 @@ class Bot(TelegramObject):
|
||||
if contains_masks is not None:
|
||||
data['contains_masks'] = contains_masks
|
||||
if mask_position is not None:
|
||||
# We need to_json() instead of to_dict() here, because we're sending a media
|
||||
# message here, which isn't json dumped by utils.request
|
||||
data['mask_position'] = mask_position.to_json()
|
||||
data.update(kwargs)
|
||||
|
||||
@@ -3472,6 +3476,8 @@ class Bot(TelegramObject):
|
||||
if tgs_sticker is not None:
|
||||
data['tgs_sticker'] = tgs_sticker
|
||||
if mask_position is not None:
|
||||
# We need to_json() instead of to_dict() here, because we're sending a media
|
||||
# message here, which isn't json dumped by utils.request
|
||||
data['mask_position'] = mask_position.to_json()
|
||||
data.update(kwargs)
|
||||
|
||||
@@ -3627,6 +3633,10 @@ class Bot(TelegramObject):
|
||||
reply_to_message_id=None,
|
||||
reply_markup=None,
|
||||
timeout=None,
|
||||
explanation=None,
|
||||
explanation_parse_mode=DEFAULT_NONE,
|
||||
open_period=None,
|
||||
close_date=None,
|
||||
**kwargs):
|
||||
"""
|
||||
Use this method to send a native poll.
|
||||
@@ -3644,6 +3654,18 @@ class Bot(TelegramObject):
|
||||
answers, ignored for polls in quiz mode, defaults to False.
|
||||
correct_option_id (:obj:`int`, optional): 0-based identifier of the correct answer
|
||||
option, required for polls in quiz mode.
|
||||
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-200 characters with at most
|
||||
2 line feeds after entities parsing.
|
||||
explanation_parse_mode (:obj:`str`, optional): Mode for parsing entities in the
|
||||
explanation. See the constants in :class:`telegram.ParseMode` for the available
|
||||
modes.
|
||||
open_period (:obj:`int`, optional): Amount of time in seconds the poll will be active
|
||||
after creation, 5-600. Can't be used together with :attr:`close_date`.
|
||||
close_date (:obj:`int` | :obj:`datetime.datetime`, optional): Point in time (Unix
|
||||
timestamp) when the poll will be automatically closed. Must be at least 5 and no
|
||||
more than 600 seconds in the future. Can't be used together with
|
||||
:attr:`open_period`.
|
||||
is_closed (:obj:`bool`, optional): Pass True, if the poll needs to be immediately
|
||||
closed. This can be useful for poll preview.
|
||||
disable_notification (:obj:`bool`, optional): Sends the message silently. Users will
|
||||
@@ -3673,6 +3695,12 @@ class Bot(TelegramObject):
|
||||
'options': options
|
||||
}
|
||||
|
||||
if explanation_parse_mode == DEFAULT_NONE:
|
||||
if self.defaults:
|
||||
explanation_parse_mode = self.defaults.parse_mode
|
||||
else:
|
||||
explanation_parse_mode = None
|
||||
|
||||
if not is_anonymous:
|
||||
data['is_anonymous'] = is_anonymous
|
||||
if type:
|
||||
@@ -3683,6 +3711,16 @@ class Bot(TelegramObject):
|
||||
data['correct_option_id'] = correct_option_id
|
||||
if is_closed:
|
||||
data['is_closed'] = is_closed
|
||||
if explanation:
|
||||
data['explanation'] = explanation
|
||||
if explanation_parse_mode:
|
||||
data['explanation_parse_mode'] = explanation_parse_mode
|
||||
if open_period:
|
||||
data['open_period'] = open_period
|
||||
if close_date:
|
||||
if isinstance(close_date, datetime):
|
||||
close_date = to_timestamp(close_date)
|
||||
data['close_date'] = close_date
|
||||
|
||||
return self._message(url, data, timeout=timeout, disable_notification=disable_notification,
|
||||
reply_to_message_id=reply_to_message_id, reply_markup=reply_markup,
|
||||
@@ -3726,7 +3764,9 @@ class Bot(TelegramObject):
|
||||
|
||||
if reply_markup:
|
||||
if isinstance(reply_markup, ReplyMarkup):
|
||||
data['reply_markup'] = reply_markup.to_dict()
|
||||
# We need to_json() instead of to_dict() here, because reply_markups may be
|
||||
# attached to media messages, which aren't json dumped by utils.request
|
||||
data['reply_markup'] = reply_markup.to_json()
|
||||
else:
|
||||
data['reply_markup'] = reply_markup
|
||||
|
||||
@@ -3741,6 +3781,7 @@ class Bot(TelegramObject):
|
||||
reply_to_message_id=None,
|
||||
reply_markup=None,
|
||||
timeout=None,
|
||||
emoji=None,
|
||||
**kwargs):
|
||||
"""
|
||||
Use this method to send a dice, which will have a random value from 1 to 6. On success, the
|
||||
@@ -3748,6 +3789,8 @@ class Bot(TelegramObject):
|
||||
|
||||
Args:
|
||||
chat_id (:obj:`int` | :obj:`str`): Unique identifier for the target private chat.
|
||||
emoji (:obj:`str`, optional): Emoji on which the dice throw animation is based.
|
||||
Currently, must be one of “🎲” or “🎯”. Defaults to “🎲”
|
||||
disable_notification (:obj:`bool`, optional): Sends the message silently. Users will
|
||||
receive a notification with no sound.
|
||||
reply_to_message_id (:obj:`int`, optional): If the message is a reply, ID of the
|
||||
@@ -3773,6 +3816,9 @@ class Bot(TelegramObject):
|
||||
'chat_id': chat_id,
|
||||
}
|
||||
|
||||
if emoji:
|
||||
data['emoji'] = emoji
|
||||
|
||||
return self._message(url, data, timeout=timeout, disable_notification=disable_notification,
|
||||
reply_to_message_id=reply_to_message_id, reply_markup=reply_markup,
|
||||
**kwargs)
|
||||
|
||||
+20
-3
@@ -23,17 +23,26 @@ from telegram import TelegramObject
|
||||
|
||||
class Dice(TelegramObject):
|
||||
"""
|
||||
This object represents a dice with random value from 1 to 6. (The singular form of "dice" is
|
||||
"die". However, PTB mimics the Telegram API, which uses the term "dice".)
|
||||
This object represents a dice with random value from 1 to 6 for currently supported base eomji.
|
||||
(The singular form of "dice" is "die". However, PTB mimics the Telegram API, which uses the
|
||||
term "dice".)
|
||||
|
||||
Note:
|
||||
If :attr:`emoji` is "🎯", a value of 6 currently represents a bullseye, while a value of 1
|
||||
indicates that the dartboard was missed. However, this behaviour is undocumented and might
|
||||
be changed by Telegram.
|
||||
|
||||
Attributes:
|
||||
value (:obj:`int`): Value of the dice.
|
||||
emoji (:obj:`str`): Emoji on which the dice throw animation is based.
|
||||
|
||||
Args:
|
||||
value (:obj:`int`): Value of the dice, 1-6.
|
||||
emoji (:obj:`str`): Emoji on which the dice throw animation is based.
|
||||
"""
|
||||
def __init__(self, value, **kwargs):
|
||||
def __init__(self, value, emoji, **kwargs):
|
||||
self.value = value
|
||||
self.emoji = emoji
|
||||
|
||||
@classmethod
|
||||
def de_json(cls, data, bot):
|
||||
@@ -41,3 +50,11 @@ class Dice(TelegramObject):
|
||||
return None
|
||||
|
||||
return cls(**data)
|
||||
|
||||
DICE = '🎲'
|
||||
""":obj:`str`: '🎲'"""
|
||||
DARTS = '🎯'
|
||||
""":obj:`str`: '🎯'"""
|
||||
ALL_EMOJI = [DICE, DARTS]
|
||||
"""List[:obj:`str`]: List of all supported base emoji. Currently :attr:`DICE` and
|
||||
:attr:`DARTS`."""
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
# along with this program. If not, see [http://www.gnu.org/licenses/].
|
||||
"""This module contains the BasePersistence class."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
class BasePersistence(object):
|
||||
|
||||
class BasePersistence(ABC):
|
||||
"""Interface class for adding persistence to your bot.
|
||||
Subclass this object for different implementations of a persistent bot.
|
||||
|
||||
@@ -57,6 +59,7 @@ class BasePersistence(object):
|
||||
self.store_chat_data = store_chat_data
|
||||
self.store_bot_data = store_bot_data
|
||||
|
||||
@abstractmethod
|
||||
def get_user_data(self):
|
||||
""""Will be called by :class:`telegram.ext.Dispatcher` upon creation with a
|
||||
persistence object. It should return the user_data if stored, or an empty
|
||||
@@ -65,8 +68,8 @@ class BasePersistence(object):
|
||||
Returns:
|
||||
:obj:`defaultdict`: The restored user data.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_chat_data(self):
|
||||
""""Will be called by :class:`telegram.ext.Dispatcher` upon creation with a
|
||||
persistence object. It should return the chat_data if stored, or an empty
|
||||
@@ -75,8 +78,8 @@ class BasePersistence(object):
|
||||
Returns:
|
||||
:obj:`defaultdict`: The restored chat data.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_bot_data(self):
|
||||
""""Will be called by :class:`telegram.ext.Dispatcher` upon creation with a
|
||||
persistence object. It should return the bot_data if stored, or an empty
|
||||
@@ -85,8 +88,8 @@ class BasePersistence(object):
|
||||
Returns:
|
||||
:obj:`defaultdict`: The restored bot data.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_conversations(self, name):
|
||||
""""Will be called by :class:`telegram.ext.Dispatcher` when a
|
||||
:class:`telegram.ext.ConversationHandler` is added if
|
||||
@@ -99,8 +102,8 @@ class BasePersistence(object):
|
||||
Returns:
|
||||
:obj:`dict`: The restored conversations for the handler.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def update_conversation(self, name, key, new_state):
|
||||
"""Will be called when a :attr:`telegram.ext.ConversationHandler.update_state`
|
||||
is called. this allows the storeage of the new state in the persistence.
|
||||
@@ -110,8 +113,8 @@ class BasePersistence(object):
|
||||
key (:obj:`tuple`): The key the state is changed for.
|
||||
new_state (:obj:`tuple` | :obj:`any`): The new state for the given key.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def update_user_data(self, user_id, data):
|
||||
"""Will be called by the :class:`telegram.ext.Dispatcher` after a handler has
|
||||
handled an update.
|
||||
@@ -120,8 +123,8 @@ class BasePersistence(object):
|
||||
user_id (:obj:`int`): The user the data might have been changed for.
|
||||
data (:obj:`dict`): The :attr:`telegram.ext.dispatcher.user_data` [user_id].
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def update_chat_data(self, chat_id, data):
|
||||
"""Will be called by the :class:`telegram.ext.Dispatcher` after a handler has
|
||||
handled an update.
|
||||
@@ -130,8 +133,8 @@ class BasePersistence(object):
|
||||
chat_id (:obj:`int`): The chat the data might have been changed for.
|
||||
data (:obj:`dict`): The :attr:`telegram.ext.dispatcher.chat_data` [chat_id].
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def update_bot_data(self, data):
|
||||
"""Will be called by the :class:`telegram.ext.Dispatcher` after a handler has
|
||||
handled an update.
|
||||
@@ -139,7 +142,6 @@ class BasePersistence(object):
|
||||
Args:
|
||||
data (:obj:`dict`): The :attr:`telegram.ext.dispatcher.bot_data` .
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def flush(self):
|
||||
"""Will be called by :class:`telegram.ext.Updater` upon receiving a stop signal. Gives the
|
||||
|
||||
+55
-28
@@ -21,13 +21,14 @@
|
||||
import re
|
||||
|
||||
from future.utils import string_types
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from telegram import Chat, Update, MessageEntity
|
||||
|
||||
__all__ = ['Filters', 'BaseFilter', 'InvertedFilter', 'MergedFilter']
|
||||
|
||||
|
||||
class BaseFilter(object):
|
||||
class BaseFilter(ABC):
|
||||
"""Base class for all Message Filters.
|
||||
|
||||
Subclassing from this class filters to be combined using bitwise operators:
|
||||
@@ -103,6 +104,7 @@ class BaseFilter(object):
|
||||
self.name = self.__class__.__name__
|
||||
return self.name
|
||||
|
||||
@abstractmethod
|
||||
def filter(self, update):
|
||||
"""This method must be overwritten.
|
||||
|
||||
@@ -118,8 +120,6 @@ class BaseFilter(object):
|
||||
|
||||
"""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class InvertedFilter(BaseFilter):
|
||||
"""Represents a filter that has been inverted.
|
||||
@@ -215,6 +215,38 @@ class MergedFilter(BaseFilter):
|
||||
self.and_filter or self.or_filter)
|
||||
|
||||
|
||||
class _DiceEmoji(BaseFilter):
|
||||
|
||||
def __init__(self, emoji=None, name=None):
|
||||
self.name = 'Filters.dice.{}'.format(name) if name else 'Filters.dice'
|
||||
self.emoji = emoji
|
||||
|
||||
class _DiceValues(BaseFilter):
|
||||
|
||||
def __init__(self, values, name, emoji=None):
|
||||
self.values = [values] if isinstance(values, int) else values
|
||||
self.emoji = emoji
|
||||
self.name = '{}({})'.format(name, values)
|
||||
|
||||
def filter(self, message):
|
||||
if bool(message.dice and message.dice.value in self.values):
|
||||
if self.emoji:
|
||||
return message.dice.emoji == self.emoji
|
||||
return True
|
||||
|
||||
def __call__(self, update):
|
||||
if isinstance(update, Update):
|
||||
return self.filter(update.effective_message)
|
||||
else:
|
||||
return self._DiceValues(update, self.name, emoji=self.emoji)
|
||||
|
||||
def filter(self, message):
|
||||
if bool(message.dice):
|
||||
if self.emoji:
|
||||
return message.dice.emoji == self.emoji
|
||||
return True
|
||||
|
||||
|
||||
class Filters(object):
|
||||
"""Predefined filters for use as the `filter` argument of :class:`telegram.ext.MessageHandler`.
|
||||
|
||||
@@ -273,8 +305,11 @@ class Filters(object):
|
||||
MessageHandler(Filters.text(buttons), callback_method)
|
||||
|
||||
Note:
|
||||
Dice messages don't have text. If you want to filter either text or dice messages, use
|
||||
``Filters.text | Filters.dice``.
|
||||
* Dice messages don't have text. If you want to filter either text or dice messages, use
|
||||
``Filters.text | Filters.dice``.
|
||||
* Messages containing a command are accepted by this filter. Use
|
||||
``Filters.text & (~Filters.command)``, if you want to filter only text messages without
|
||||
commands.
|
||||
|
||||
Args:
|
||||
update (List[:obj:`str`] | Tuple[:obj:`str`], optional): Which messages to allow. Only
|
||||
@@ -350,6 +385,9 @@ class Filters(object):
|
||||
MessageHandler(Filters.command, command_at_start_callback)
|
||||
MessageHandler(Filters.command(False), command_anywhere_callback)
|
||||
|
||||
Note:
|
||||
``Filters.text`` also accepts messages containing a command.
|
||||
|
||||
Args:
|
||||
update (:obj:`bool`, optional): Whether to only allow messages that `start` with a bot
|
||||
command. Defaults to ``True``.
|
||||
@@ -961,26 +999,9 @@ officedocument.wordprocessingml.document")``-
|
||||
poll = _Poll()
|
||||
"""Messages that contain a :class:`telegram.Poll`."""
|
||||
|
||||
class _Dice(BaseFilter):
|
||||
name = 'Filters.dice'
|
||||
|
||||
class _DiceValues(BaseFilter):
|
||||
|
||||
def __init__(self, values):
|
||||
self.values = [values] if isinstance(values, int) else values
|
||||
self.name = 'Filters.dice({})'.format(values)
|
||||
|
||||
def filter(self, message):
|
||||
return bool(message.dice and message.dice.value in self.values)
|
||||
|
||||
def __call__(self, update):
|
||||
if isinstance(update, Update):
|
||||
return self.filter(update.effective_message)
|
||||
else:
|
||||
return self._DiceValues(update)
|
||||
|
||||
def filter(self, message):
|
||||
return bool(message.dice)
|
||||
class _Dice(_DiceEmoji):
|
||||
dice = _DiceEmoji('🎲', 'dice')
|
||||
darts = _DiceEmoji('🎯', 'darts')
|
||||
|
||||
dice = _Dice()
|
||||
"""Dice Messages. If an integer or a list of integers is passed, it filters messages to only
|
||||
@@ -998,9 +1019,15 @@ officedocument.wordprocessingml.document")``-
|
||||
update (:obj:`int` | List[:obj:`int`], optional): Which values to allow. If not
|
||||
specified, will allow any dice message.
|
||||
|
||||
Note:
|
||||
Dice messages don't have text. If you want to filter either text or dice messages, use
|
||||
``Filters.text | Filters.dice``.
|
||||
Note:
|
||||
Dice messages don't have text. If you want to filter either text or dice messages, use
|
||||
``Filters.text | Filters.dice``.
|
||||
|
||||
Attributes:
|
||||
dice: Dice messages with the emoji 🎲. Passing a list of integers is supported just as for
|
||||
:attr:`Filters.dice`.
|
||||
darts: Dice messages with the emoji 🎯. Passing a list of integers is supported just as for
|
||||
:attr:`Filters.dice`.
|
||||
"""
|
||||
|
||||
class language(BaseFilter):
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
# along with this program. If not, see [http://www.gnu.org/licenses/].
|
||||
"""This module contains the base class for handlers as used by the Dispatcher."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
class Handler(object):
|
||||
|
||||
class Handler(ABC):
|
||||
"""The base class for all update handlers. Create custom handlers by inheriting from it.
|
||||
|
||||
Attributes:
|
||||
@@ -82,6 +84,7 @@ class Handler(object):
|
||||
self.pass_user_data = pass_user_data
|
||||
self.pass_chat_data = pass_chat_data
|
||||
|
||||
@abstractmethod
|
||||
def check_update(self, update):
|
||||
"""
|
||||
This method is called to determine if an update should be handled by
|
||||
@@ -96,7 +99,6 @@ class Handler(object):
|
||||
when the update gets handled.
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def handle_update(self, update, dispatcher, check_result, context=None):
|
||||
"""
|
||||
|
||||
+177
-11
@@ -18,6 +18,7 @@
|
||||
# along with this program. If not, see [http://www.gnu.org/licenses/].
|
||||
"""This module contains the classes JobQueue and Job."""
|
||||
|
||||
import calendar
|
||||
import datetime
|
||||
import logging
|
||||
import time
|
||||
@@ -29,7 +30,7 @@ from threading import Thread, Lock, Event
|
||||
|
||||
from telegram.ext.callbackcontext import CallbackContext
|
||||
from telegram.utils.deprecate import TelegramDeprecationWarning
|
||||
from telegram.utils.helpers import to_float_timestamp, _UTC
|
||||
from telegram.utils.helpers import to_float_timestamp
|
||||
|
||||
|
||||
class Days(object):
|
||||
@@ -104,6 +105,7 @@ class JobQueue(object):
|
||||
# enqueue:
|
||||
self.logger.debug('Putting job %s with t=%s', job.name, time_spec)
|
||||
self._queue.put((next_t, job))
|
||||
job._set_next_t(next_t)
|
||||
|
||||
# Wake up the loop if this job should be executed next
|
||||
self._set_next_peek(next_t)
|
||||
@@ -135,6 +137,9 @@ class JobQueue(object):
|
||||
job should run. This could be either today or, if the time has already passed,
|
||||
tomorrow. If the timezone (``time.tzinfo``) is ``None``, UTC will be assumed.
|
||||
|
||||
If ``when`` is :obj:`datetime.datetime` or :obj:`datetime.time` type
|
||||
then ``when.tzinfo`` will define ``Job.tzinfo``. Otherwise UTC will be assumed.
|
||||
|
||||
context (:obj:`object`, optional): Additional data needed for the callback function.
|
||||
Can be accessed through ``job.context`` in the callback. Defaults to ``None``.
|
||||
name (:obj:`str`, optional): The name of the new job. Defaults to
|
||||
@@ -145,7 +150,14 @@ class JobQueue(object):
|
||||
queue.
|
||||
|
||||
"""
|
||||
job = Job(callback, repeat=False, context=context, name=name, job_queue=self)
|
||||
tzinfo = when.tzinfo if isinstance(when, (datetime.datetime, datetime.time)) else None
|
||||
|
||||
job = Job(callback,
|
||||
repeat=False,
|
||||
context=context,
|
||||
name=name,
|
||||
job_queue=self,
|
||||
tzinfo=tzinfo)
|
||||
self._put(job, time_spec=when)
|
||||
return job
|
||||
|
||||
@@ -179,6 +191,9 @@ class JobQueue(object):
|
||||
job should run. This could be either today or, if the time has already passed,
|
||||
tomorrow. If the timezone (``time.tzinfo``) is ``None``, UTC will be assumed.
|
||||
|
||||
If ``first`` is :obj:`datetime.datetime` or :obj:`datetime.time` type
|
||||
then ``first.tzinfo`` will define ``Job.tzinfo``. Otherwise UTC will be assumed.
|
||||
|
||||
Defaults to ``interval``
|
||||
context (:obj:`object`, optional): Additional data needed for the callback function.
|
||||
Can be accessed through ``job.context`` in the callback. Defaults to ``None``.
|
||||
@@ -189,21 +204,131 @@ class JobQueue(object):
|
||||
:class:`telegram.ext.Job`: The new ``Job`` instance that has been added to the job
|
||||
queue.
|
||||
|
||||
Notes:
|
||||
Note:
|
||||
`interval` is always respected "as-is". That means that if DST changes during that
|
||||
interval, the job might not run at the time one would expect. It is always recommended
|
||||
to pin servers to UTC time, then time related behaviour can always be expected.
|
||||
|
||||
"""
|
||||
tzinfo = first.tzinfo if isinstance(first, (datetime.datetime, datetime.time)) else None
|
||||
|
||||
job = Job(callback,
|
||||
interval=interval,
|
||||
repeat=True,
|
||||
context=context,
|
||||
name=name,
|
||||
job_queue=self)
|
||||
job_queue=self,
|
||||
tzinfo=tzinfo)
|
||||
self._put(job, time_spec=first)
|
||||
return job
|
||||
|
||||
def run_monthly(self, callback, when, day, context=None, name=None, day_is_strict=True):
|
||||
"""Creates a new ``Job`` that runs on a monthly basis and adds it to the queue.
|
||||
|
||||
Args:
|
||||
callback (:obj:`callable`): The callback function that should be executed by the new
|
||||
job. Callback signature for context based API:
|
||||
|
||||
``def callback(CallbackContext)``
|
||||
|
||||
``context.job`` is the :class:`telegram.ext.Job` instance. It can be used to access
|
||||
its ``job.context`` or change it to a repeating job.
|
||||
when (:obj:`datetime.time`): Time of day at which the job should run. If the timezone
|
||||
(``when.tzinfo``) is ``None``, UTC will be assumed. This will also implicitly
|
||||
define ``Job.tzinfo``.
|
||||
day (:obj:`int`): Defines the day of the month whereby the job would run. It should
|
||||
be within the range of 1 and 31, inclusive.
|
||||
context (:obj:`object`, optional): Additional data needed for the callback function.
|
||||
Can be accessed through ``job.context`` in the callback. Defaults to ``None``.
|
||||
name (:obj:`str`, optional): The name of the new job. Defaults to
|
||||
``callback.__name__``.
|
||||
day_is_strict (:obj:`bool`, optional): If ``False`` and day > month.days, will pick
|
||||
the last day in the month. Defaults to ``True``.
|
||||
|
||||
Returns:
|
||||
:class:`telegram.ext.Job`: The new ``Job`` instance that has been added to the job
|
||||
queue.
|
||||
|
||||
"""
|
||||
tzinfo = when.tzinfo if isinstance(when, (datetime.datetime, datetime.time)) else None
|
||||
if 1 <= day <= 31:
|
||||
next_dt = self._get_next_month_date(day, day_is_strict, when, allow_now=True)
|
||||
job = Job(callback, repeat=False, context=context, name=name, job_queue=self,
|
||||
is_monthly=True, day_is_strict=day_is_strict, tzinfo=tzinfo)
|
||||
self._put(job, time_spec=next_dt)
|
||||
return job
|
||||
else:
|
||||
raise ValueError("The elements of the 'day' argument should be from 1 up to"
|
||||
" and including 31")
|
||||
|
||||
def _get_next_month_date(self, day, day_is_strict, when, allow_now=False):
|
||||
"""This method returns the date that the next monthly job should be scheduled.
|
||||
|
||||
Args:
|
||||
day (:obj:`int`): The day of the month the job should run.
|
||||
day_is_strict (:obj:`bool`):
|
||||
Specification as to whether the specified day of job should be strictly
|
||||
respected. If day_is_strict is ``True`` it ignores months whereby the
|
||||
specified date does not exist (e.g February 31st). If it set to ``False``,
|
||||
it returns the last valid date of the month instead. For example,
|
||||
if the user runs a job on the 31st of every month, and sets
|
||||
the day_is_strict variable to ``False``, April, for example,
|
||||
the job would run on April 30th.
|
||||
when (:obj:`datetime.time`): Time of day at which the job should run. If the
|
||||
timezone (``time.tzinfo``) is ``None``, UTC will be assumed.
|
||||
allow_now (:obj:`bool`): Whether executing the job right now is a feasible options.
|
||||
For stability reasons, this defaults to :obj:`False`, but it needs to be :obj:`True`
|
||||
on initializing a job.
|
||||
|
||||
"""
|
||||
dt = datetime.datetime.now(tz=when.tzinfo or datetime.timezone.utc)
|
||||
dt_time = dt.time().replace(tzinfo=when.tzinfo)
|
||||
days_in_current_month = calendar.monthrange(dt.year, dt.month)[1]
|
||||
days_till_months_end = days_in_current_month - dt.day
|
||||
if days_in_current_month < day:
|
||||
# if the day does not exist in the current month (e.g Feb 31st)
|
||||
if day_is_strict is False:
|
||||
# set day as last day of month instead
|
||||
next_dt = dt + datetime.timedelta(days=days_till_months_end)
|
||||
else:
|
||||
# else set as day in subsequent month. Subsequent month is
|
||||
# guaranteed to have the date, if current month does not have the date.
|
||||
next_dt = dt + datetime.timedelta(days=days_till_months_end + day)
|
||||
else:
|
||||
# if the day exists in the current month
|
||||
if dt.day < day:
|
||||
# day is upcoming
|
||||
next_dt = dt + datetime.timedelta(day - dt.day)
|
||||
elif dt.day > day or (dt.day == day and ((not allow_now and dt_time >= when)
|
||||
or (allow_now and dt_time > when))):
|
||||
# run next month if day has already passed
|
||||
next_year = dt.year + 1 if dt.month == 12 else dt.year
|
||||
next_month = 1 if dt.month == 12 else dt.month + 1
|
||||
days_in_next_month = calendar.monthrange(next_year, next_month)[1]
|
||||
next_month_has_date = days_in_next_month >= day
|
||||
if next_month_has_date:
|
||||
next_dt = dt + datetime.timedelta(days=days_till_months_end + day)
|
||||
elif day_is_strict:
|
||||
# schedule the subsequent month if day is strict
|
||||
next_dt = dt + datetime.timedelta(
|
||||
days=days_till_months_end + days_in_next_month + day)
|
||||
else:
|
||||
# schedule in the next month last date if day is not strict
|
||||
next_dt = dt + datetime.timedelta(days=days_till_months_end
|
||||
+ days_in_next_month)
|
||||
|
||||
else:
|
||||
# day is today but time has not yet come
|
||||
next_dt = dt
|
||||
|
||||
# Set the correct time
|
||||
next_dt = next_dt.replace(hour=when.hour, minute=when.minute, second=when.second,
|
||||
microsecond=when.microsecond)
|
||||
# fold is new in Py3.6
|
||||
if hasattr(next_dt, 'fold'):
|
||||
next_dt = next_dt.replace(fold=when.fold)
|
||||
return next_dt
|
||||
|
||||
def run_daily(self, callback, time, days=Days.EVERY_DAY, context=None, name=None):
|
||||
"""Creates a new ``Job`` that runs on a daily basis and adds it to the queue.
|
||||
|
||||
@@ -217,6 +342,7 @@ class JobQueue(object):
|
||||
its ``job.context`` or change it to a repeating job.
|
||||
time (:obj:`datetime.time`): Time of day at which the job should run. If the timezone
|
||||
(``time.tzinfo``) is ``None``, UTC will be assumed.
|
||||
``time.tzinfo`` will implicitly define ``Job.tzinfo``.
|
||||
days (Tuple[:obj:`int`], optional): Defines on which days of the week the job should
|
||||
run. Defaults to ``EVERY_DAY``
|
||||
context (:obj:`object`, optional): Additional data needed for the callback function.
|
||||
@@ -228,7 +354,7 @@ class JobQueue(object):
|
||||
:class:`telegram.ext.Job`: The new ``Job`` instance that has been added to the job
|
||||
queue.
|
||||
|
||||
Notes:
|
||||
Note:
|
||||
Daily is just an alias for "24 Hours". That means that if DST changes during that
|
||||
interval, the job might not run at the time one would expect. It is always recommended
|
||||
to pin servers to UTC time, then time related behaviour can always be expected.
|
||||
@@ -300,7 +426,13 @@ class JobQueue(object):
|
||||
|
||||
if job.repeat and not job.removed:
|
||||
self._put(job, previous_t=t)
|
||||
elif job.is_monthly and not job.removed:
|
||||
dt = datetime.datetime.now(tz=job.tzinfo)
|
||||
dt_time = dt.time().replace(tzinfo=job.tzinfo)
|
||||
self._put(job, time_spec=self._get_next_month_date(dt.day, job.day_is_strict,
|
||||
dt_time))
|
||||
else:
|
||||
job._set_next_t(None)
|
||||
self.logger.debug('Dropping non-repeating or removed job %s', job.name)
|
||||
|
||||
def start(self):
|
||||
@@ -367,6 +499,8 @@ class Job(object):
|
||||
callback (:obj:`callable`): The callback function that should be executed by the new job.
|
||||
context (:obj:`object`): Optional. Additional data needed for the callback function.
|
||||
name (:obj:`str`): Optional. The name of the new job.
|
||||
is_monthly (:obj: `bool`): Optional. Indicates whether it is a monthly job.
|
||||
day_is_strict (:obj: `bool`): Optional. Indicates whether the monthly jobs day is strict.
|
||||
|
||||
Args:
|
||||
callback (:obj:`callable`): The callback function that should be executed by the new job.
|
||||
@@ -393,6 +527,11 @@ class Job(object):
|
||||
tzinfo (:obj:`datetime.tzinfo`, optional): timezone associated to this job. Used when
|
||||
checking the day of the week to determine whether a job should run (only relevant when
|
||||
``days is not Days.EVERY_DAY``). Defaults to UTC.
|
||||
is_monthly (:obj:`bool`, optional): If this job is supposed to be a monthly scheduled job.
|
||||
Defaults to ``False``.
|
||||
day_is_strict (:obj:`bool`, optional): If ``False`` and day > month.days, will pick the
|
||||
last day in the month. Defaults to ``True``. Only relevant when ``is_monthly`` is
|
||||
``True``.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
@@ -403,7 +542,9 @@ class Job(object):
|
||||
days=Days.EVERY_DAY,
|
||||
name=None,
|
||||
job_queue=None,
|
||||
tzinfo=None):
|
||||
tzinfo=None,
|
||||
is_monthly=False,
|
||||
day_is_strict=True):
|
||||
|
||||
self.callback = callback
|
||||
self.context = context
|
||||
@@ -412,11 +553,14 @@ class Job(object):
|
||||
self._repeat = None
|
||||
self._interval = None
|
||||
self.interval = interval
|
||||
self._next_t = None
|
||||
self.repeat = repeat
|
||||
self.is_monthly = is_monthly
|
||||
self.day_is_strict = day_is_strict
|
||||
|
||||
self._days = None
|
||||
self.days = days
|
||||
self.tzinfo = tzinfo or _UTC
|
||||
self.tzinfo = tzinfo or datetime.timezone.utc
|
||||
|
||||
self._job_queue = weakref.proxy(job_queue) if job_queue is not None else None
|
||||
|
||||
@@ -438,6 +582,7 @@ class Job(object):
|
||||
|
||||
"""
|
||||
self._remove.set()
|
||||
self._next_t = None
|
||||
|
||||
@property
|
||||
def removed(self):
|
||||
@@ -471,8 +616,8 @@ class Job(object):
|
||||
raise ValueError("The 'interval' can not be 'None' when 'repeat' is set to 'True'")
|
||||
|
||||
if not (interval is None or isinstance(interval, (Number, datetime.timedelta))):
|
||||
raise ValueError("The 'interval' must be of type 'datetime.timedelta',"
|
||||
" 'int' or 'float'")
|
||||
raise TypeError("The 'interval' must be of type 'datetime.timedelta',"
|
||||
" 'int' or 'float'")
|
||||
|
||||
self._interval = interval
|
||||
|
||||
@@ -485,6 +630,27 @@ class Job(object):
|
||||
else:
|
||||
return interval
|
||||
|
||||
@property
|
||||
def next_t(self):
|
||||
"""
|
||||
:obj:`datetime.datetime`: Datetime for the next job execution.
|
||||
Datetime is localized according to :attr:`tzinfo`.
|
||||
If job is removed or already ran it equals to ``None``.
|
||||
|
||||
"""
|
||||
return datetime.datetime.fromtimestamp(self._next_t, self.tzinfo) if self._next_t else None
|
||||
|
||||
def _set_next_t(self, next_t):
|
||||
if isinstance(next_t, datetime.datetime):
|
||||
# Set timezone to UTC in case datetime is in local timezone.
|
||||
next_t = next_t.astimezone(datetime.timezone.utc)
|
||||
next_t = to_float_timestamp(next_t)
|
||||
elif not (isinstance(next_t, Number) or next_t is None):
|
||||
raise TypeError("The 'next_t' argument should be one of the following types: "
|
||||
"'float', 'int', 'datetime.datetime' or 'NoneType'")
|
||||
|
||||
self._next_t = next_t
|
||||
|
||||
@property
|
||||
def repeat(self):
|
||||
""":obj:`bool`: Optional. If this job should periodically execute its callback function."""
|
||||
@@ -504,10 +670,10 @@ class Job(object):
|
||||
@days.setter
|
||||
def days(self, days):
|
||||
if not isinstance(days, tuple):
|
||||
raise ValueError("The 'days' argument should be of type 'tuple'")
|
||||
raise TypeError("The 'days' argument should be of type 'tuple'")
|
||||
|
||||
if not all(isinstance(day, int) for day in days):
|
||||
raise ValueError("The elements of the 'days' argument should be of type 'int'")
|
||||
raise TypeError("The elements of the 'days' argument should be of type 'int'")
|
||||
|
||||
if not all(0 <= day <= 6 for day in days):
|
||||
raise ValueError("The elements of the 'days' argument should be from 0 up to and "
|
||||
|
||||
@@ -249,7 +249,7 @@ class MessageQueue(object):
|
||||
``DelayQueue`` (if set to ``False``), resulting in needed delays to avoid
|
||||
hitting specified limits. Defaults to ``False``.
|
||||
|
||||
Notes:
|
||||
Note:
|
||||
Method is designed to accept ``telegram.utils.promise.Promise`` as ``promise``
|
||||
argument, but other callables could be used too. For example, lambdas or simple
|
||||
functions could be used to wrap original func to be called with needed args. In that
|
||||
|
||||
@@ -195,7 +195,7 @@ class MaskPosition(TelegramObject):
|
||||
size, from top to bottom.
|
||||
scale (:obj:`float`): Mask scaling coefficient. For example, 2.0 means double size.
|
||||
|
||||
Notes:
|
||||
Note:
|
||||
:attr:`type` should be one of the following: `forehead`, `eyes`, `mouth` or `chin`. You can
|
||||
use the classconstants for those.
|
||||
|
||||
|
||||
+98
-3
@@ -19,7 +19,10 @@
|
||||
# along with this program. If not, see [http://www.gnu.org/licenses/].
|
||||
"""This module contains an object that represents a Telegram Poll."""
|
||||
|
||||
from telegram import (TelegramObject, User)
|
||||
import sys
|
||||
|
||||
from telegram import (TelegramObject, User, MessageEntity)
|
||||
from telegram.utils.helpers import to_timestamp, from_timestamp
|
||||
|
||||
|
||||
class PollOption(TelegramObject):
|
||||
@@ -95,6 +98,14 @@ class Poll(TelegramObject):
|
||||
type (:obj:`str`): Poll type, currently can be :attr:`REGULAR` or :attr:`QUIZ`.
|
||||
allows_multiple_answers (:obj:`bool`): True, if the poll allows multiple answers.
|
||||
correct_option_id (:obj:`int`): Optional. Identifier of the correct answer option.
|
||||
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.
|
||||
explanation_entities (List[:class:`telegram.MessageEntity`]): Optional. Special entities
|
||||
like usernames, URLs, bot commands, etc. that appear in the :attr:`explanation`.
|
||||
open_period (:obj:`int`): Optional. Amount of time in seconds the poll will be active
|
||||
after creation.
|
||||
close_date (:obj:`datetime.datetime`): Optional. Point in time when the poll will be
|
||||
automatically closed.
|
||||
|
||||
Args:
|
||||
id (:obj:`str`): Unique poll identifier.
|
||||
@@ -107,11 +118,32 @@ class Poll(TelegramObject):
|
||||
correct_option_id (:obj:`int`, optional): 0-based identifier of the correct answer option.
|
||||
Available only for polls in the quiz mode, which are closed, or was sent (not
|
||||
forwarded) by the bot or to the private chat with the bot.
|
||||
explanation (:obj:`str`, optional): Text that is shown when a user chooses an incorrect
|
||||
answer or taps on the lamp icon in a quiz-style poll, 0-200 characters.
|
||||
explanation_entities (List[:class:`telegram.MessageEntity`], optional): Special entities
|
||||
like usernames, URLs, bot commands, etc. that appear in the :attr:`explanation`.
|
||||
open_period (:obj:`int`, optional): Amount of time in seconds the poll will be active
|
||||
after creation.
|
||||
close_date (:obj:`datetime.datetime`, optional): Point in time (Unix timestamp) when the
|
||||
poll will be automatically closed. Converted to :obj:`datetime.datetime`.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, id, question, options, total_voter_count, is_closed, is_anonymous, type,
|
||||
allows_multiple_answers, correct_option_id=None, **kwargs):
|
||||
def __init__(self,
|
||||
id,
|
||||
question,
|
||||
options,
|
||||
total_voter_count,
|
||||
is_closed,
|
||||
is_anonymous,
|
||||
type,
|
||||
allows_multiple_answers,
|
||||
correct_option_id=None,
|
||||
explanation=None,
|
||||
explanation_entities=None,
|
||||
open_period=None,
|
||||
close_date=None,
|
||||
**kwargs):
|
||||
self.id = id
|
||||
self.question = question
|
||||
self.options = options
|
||||
@@ -121,6 +153,10 @@ class Poll(TelegramObject):
|
||||
self.type = type
|
||||
self.allows_multiple_answers = allows_multiple_answers
|
||||
self.correct_option_id = correct_option_id
|
||||
self.explanation = explanation
|
||||
self.explanation_entities = explanation_entities
|
||||
self.open_period = open_period
|
||||
self.close_date = close_date
|
||||
|
||||
self._id_attrs = (self.id,)
|
||||
|
||||
@@ -132,6 +168,8 @@ class Poll(TelegramObject):
|
||||
data = super(Poll, cls).de_json(data, bot)
|
||||
|
||||
data['options'] = [PollOption.de_json(option, bot) for option in data['options']]
|
||||
data['explanation_entities'] = MessageEntity.de_list(data.get('explanation_entities'), bot)
|
||||
data['close_date'] = from_timestamp(data.get('close_date'))
|
||||
|
||||
return cls(**data)
|
||||
|
||||
@@ -139,9 +177,66 @@ class Poll(TelegramObject):
|
||||
data = super(Poll, self).to_dict()
|
||||
|
||||
data['options'] = [x.to_dict() for x in self.options]
|
||||
if self.explanation_entities:
|
||||
data['explanation_entities'] = [e.to_dict() for e in self.explanation_entities]
|
||||
data['close_date'] = to_timestamp(data.get('close_date'))
|
||||
|
||||
return data
|
||||
|
||||
def parse_explanation_entity(self, entity):
|
||||
"""Returns the text from a given :class:`telegram.MessageEntity`.
|
||||
|
||||
Note:
|
||||
This method is present because Telegram calculates the offset and length in
|
||||
UTF-16 codepoint pairs, which some versions of Python don't handle automatically.
|
||||
(That is, you can't just slice ``Message.text`` with the offset and length.)
|
||||
|
||||
Args:
|
||||
entity (:class:`telegram.MessageEntity`): The entity to extract the text from. It must
|
||||
be an entity that belongs to this message.
|
||||
|
||||
Returns:
|
||||
:obj:`str`: The text of the given entity.
|
||||
|
||||
"""
|
||||
# Is it a narrow build, if so we don't need to convert
|
||||
if sys.maxunicode == 0xffff:
|
||||
return self.explanation[entity.offset:entity.offset + entity.length]
|
||||
else:
|
||||
entity_text = self.explanation.encode('utf-16-le')
|
||||
entity_text = entity_text[entity.offset * 2:(entity.offset + entity.length) * 2]
|
||||
|
||||
return entity_text.decode('utf-16-le')
|
||||
|
||||
def parse_explanation_entities(self, types=None):
|
||||
"""
|
||||
Returns a :obj:`dict` that maps :class:`telegram.MessageEntity` to :obj:`str`.
|
||||
It contains entities from this polls explanation filtered by their ``type`` attribute as
|
||||
the key, and the text that each entity belongs to as the value of the :obj:`dict`.
|
||||
|
||||
Note:
|
||||
This method should always be used instead of the :attr:`explanation_entities`
|
||||
attribute, since it calculates the correct substring from the message text based on
|
||||
UTF-16 codepoints. See :attr:`parse_explanation_entity` for more info.
|
||||
|
||||
Args:
|
||||
types (List[:obj:`str`], optional): List of ``MessageEntity`` types as strings. If the
|
||||
``type`` attribute of an entity is contained in this list, it will be returned.
|
||||
Defaults to :attr:`telegram.MessageEntity.ALL_TYPES`.
|
||||
|
||||
Returns:
|
||||
Dict[:class:`telegram.MessageEntity`, :obj:`str`]: A dictionary of entities mapped to
|
||||
the text that belongs to them, calculated based on UTF-16 codepoints.
|
||||
|
||||
"""
|
||||
if types is None:
|
||||
types = MessageEntity.ALL_TYPES
|
||||
|
||||
return {
|
||||
entity: self.parse_explanation_entity(entity)
|
||||
for entity in self.explanation_entities if entity.type in types
|
||||
}
|
||||
|
||||
REGULAR = "regular"
|
||||
""":obj:`str`: 'regular'"""
|
||||
QUIZ = "quiz"
|
||||
|
||||
+14
-43
@@ -75,46 +75,12 @@ def escape_markdown(text, version=1, entity_type=None):
|
||||
# -------- date/time related helpers --------
|
||||
# TODO: add generic specification of UTC for naive datetimes to docs
|
||||
|
||||
if hasattr(dtm, 'timezone'):
|
||||
# Python 3.3+
|
||||
def _datetime_to_float_timestamp(dt_obj):
|
||||
if dt_obj.tzinfo is None:
|
||||
dt_obj = dt_obj.replace(tzinfo=_UTC)
|
||||
return dt_obj.timestamp()
|
||||
|
||||
_UtcOffsetTimezone = dtm.timezone
|
||||
_UTC = dtm.timezone.utc
|
||||
else:
|
||||
# Python < 3.3 (incl 2.7)
|
||||
|
||||
# hardcoded timezone class (`datetime.timezone` isn't available in py2)
|
||||
class _UtcOffsetTimezone(dtm.tzinfo):
|
||||
def __init__(self, offset):
|
||||
self.offset = offset
|
||||
|
||||
def tzname(self, dt):
|
||||
return 'UTC +{}'.format(self.offset)
|
||||
|
||||
def utcoffset(self, dt):
|
||||
return self.offset
|
||||
|
||||
def dst(self, dt):
|
||||
return dtm.timedelta(0)
|
||||
|
||||
_UTC = _UtcOffsetTimezone(dtm.timedelta(0))
|
||||
__EPOCH_DT = dtm.datetime.fromtimestamp(0, tz=_UTC)
|
||||
__NAIVE_EPOCH_DT = __EPOCH_DT.replace(tzinfo=None)
|
||||
|
||||
# _datetime_to_float_timestamp
|
||||
# Not using future.backports.datetime here as datetime value might be an input from the user,
|
||||
# making every isinstace() call more delicate. So we just use our own compat layer.
|
||||
def _datetime_to_float_timestamp(dt_obj):
|
||||
epoch_dt = __EPOCH_DT if dt_obj.tzinfo is not None else __NAIVE_EPOCH_DT
|
||||
return (dt_obj - epoch_dt).total_seconds()
|
||||
|
||||
_datetime_to_float_timestamp.__doc__ = \
|
||||
def _datetime_to_float_timestamp(dt_obj):
|
||||
"""Converts a datetime object to a float timestamp (with sub-second precision).
|
||||
If the datetime object is timezone-naive, it is assumed to be in UTC."""
|
||||
If the datetime object is timezone-naive, it is assumed to be in UTC."""
|
||||
if dt_obj.tzinfo is None:
|
||||
dt_obj = dt_obj.replace(tzinfo=dtm.timezone.utc)
|
||||
return dt_obj.timestamp()
|
||||
|
||||
|
||||
def to_float_timestamp(t, reference_timestamp=None):
|
||||
@@ -196,22 +162,27 @@ def to_timestamp(dt_obj, reference_timestamp=None):
|
||||
return int(to_float_timestamp(dt_obj, reference_timestamp)) if dt_obj is not None else None
|
||||
|
||||
|
||||
def from_timestamp(unixtime):
|
||||
def from_timestamp(unixtime, tzinfo=dtm.timezone.utc):
|
||||
"""
|
||||
Converts an (integer) unix timestamp to a naive datetime object in UTC.
|
||||
Converts an (integer) unix timestamp to a timezone aware datetime object.
|
||||
``None`` s are left alone (i.e. ``from_timestamp(None)`` is ``None``).
|
||||
|
||||
Args:
|
||||
unixtime (int): integer POSIX timestamp
|
||||
tzinfo (:obj:`datetime.tzinfo`, optional): The timezone, the timestamp is to be converted
|
||||
to. Defaults to UTC.
|
||||
|
||||
Returns:
|
||||
equivalent :obj:`datetime.datetime` value in naive UTC if ``timestamp`` is not
|
||||
timezone aware equivalent :obj:`datetime.datetime` value if ``timestamp`` is not
|
||||
``None``; else ``None``
|
||||
"""
|
||||
if unixtime is None:
|
||||
return None
|
||||
|
||||
return dtm.datetime.utcfromtimestamp(unixtime)
|
||||
if tzinfo is not None:
|
||||
return dtm.datetime.fromtimestamp(unixtime, tz=tzinfo)
|
||||
else:
|
||||
return dtm.datetime.utcfromtimestamp(unixtime)
|
||||
|
||||
# -------- end --------
|
||||
|
||||
|
||||
+1
-1
@@ -17,4 +17,4 @@
|
||||
# You should have received a copy of the GNU Lesser Public License
|
||||
# along with this program. If not, see [http://www.gnu.org/licenses/].
|
||||
|
||||
__version__ = '12.6'
|
||||
__version__ = '12.7'
|
||||
|
||||
+19
-2
@@ -21,6 +21,9 @@ import json
|
||||
import base64
|
||||
import os
|
||||
import random
|
||||
import pytest
|
||||
from telegram.utils.request import Request
|
||||
from telegram.error import RetryAfter
|
||||
|
||||
# Provide some public fallbacks so it's easy for contributors to run tests on their local machine
|
||||
# These bots are only able to talk in our test chats, so they are quite useless for other
|
||||
@@ -30,7 +33,7 @@ FALLBACKS = [
|
||||
'token': '579694714:AAHRLL5zBVy4Blx2jRFKe1HlfnXCg08WuLY',
|
||||
'payment_provider_token': '284685063:TEST:NjQ0NjZlNzI5YjJi',
|
||||
'chat_id': '675666224',
|
||||
'super_group_id': '-1001493296829',
|
||||
'super_group_id': '-1001310911135',
|
||||
'channel_id': '@pythontelegrambottests',
|
||||
'bot_name': 'PTB tests fallback 1',
|
||||
'bot_username': '@ptb_fallback_1_bot'
|
||||
@@ -38,7 +41,7 @@ FALLBACKS = [
|
||||
'token': '558194066:AAEEylntuKSLXj9odiv3TnX7Z5KY2J3zY3M',
|
||||
'payment_provider_token': '284685063:TEST:YjEwODQwMTFmNDcy',
|
||||
'chat_id': '675666224',
|
||||
'super_group_id': '-1001493296829',
|
||||
'super_group_id': '-1001221216830',
|
||||
'channel_id': '@pythontelegrambottests',
|
||||
'bot_name': 'PTB tests fallback 2',
|
||||
'bot_username': '@ptb_fallback_2_bot'
|
||||
@@ -73,3 +76,17 @@ def get(name, fallback):
|
||||
|
||||
def get_bot():
|
||||
return {k: get(k, v) for k, v in random.choice(FALLBACKS).items()}
|
||||
|
||||
|
||||
# Patch request to xfail on flood control errors
|
||||
original_request_wrapper = Request._request_wrapper
|
||||
|
||||
|
||||
def patient_request_wrapper(*args, **kwargs):
|
||||
try:
|
||||
return original_request_wrapper(*args, **kwargs)
|
||||
except RetryAfter as e:
|
||||
pytest.xfail('Not waiting for flood control: {}'.format(e))
|
||||
|
||||
|
||||
Request._request_wrapper = patient_request_wrapper
|
||||
|
||||
+25
-2
@@ -31,7 +31,7 @@ from telegram import (Bot, Message, User, Chat, MessageEntity, Update,
|
||||
InlineQuery, CallbackQuery, ShippingQuery, PreCheckoutQuery,
|
||||
ChosenInlineResult)
|
||||
from telegram.ext import Dispatcher, JobQueue, Updater, BaseFilter, Defaults
|
||||
from telegram.utils.helpers import _UtcOffsetTimezone
|
||||
from telegram.error import BadRequest
|
||||
from tests.bots import get_bot
|
||||
|
||||
GITHUB_ACTION = os.getenv('GITHUB_ACTION', False)
|
||||
@@ -280,4 +280,27 @@ def utc_offset(request):
|
||||
|
||||
@pytest.fixture()
|
||||
def timezone(utc_offset):
|
||||
return _UtcOffsetTimezone(utc_offset)
|
||||
return datetime.timezone(utc_offset)
|
||||
|
||||
|
||||
def expect_bad_request(func, message, reason):
|
||||
"""
|
||||
Wrapper for testing bot functions expected to result in an :class:`telegram.error.BadRequest`.
|
||||
Makes it XFAIL, if the specified error message is present.
|
||||
|
||||
Args:
|
||||
func: The callable to be executed.
|
||||
message: The expected message of the bad request error. If another message is present,
|
||||
the error will be reraised.
|
||||
reason: Explanation for the XFAIL.
|
||||
|
||||
Returns:
|
||||
On success, returns the return value of :attr:`func`
|
||||
"""
|
||||
try:
|
||||
return func()
|
||||
except BadRequest as e:
|
||||
if message in str(e):
|
||||
pytest.xfail('{}. {}'.format(reason, e))
|
||||
else:
|
||||
raise e
|
||||
|
||||
+82
-7
@@ -27,9 +27,10 @@ from future.utils import string_types
|
||||
from telegram import (Bot, Update, ChatAction, TelegramError, User, InlineKeyboardMarkup,
|
||||
InlineKeyboardButton, InlineQueryResultArticle, InputTextMessageContent,
|
||||
ShippingOption, LabeledPrice, ChatPermissions, Poll, BotCommand,
|
||||
InlineQueryResultDocument)
|
||||
InlineQueryResultDocument, Dice, MessageEntity, ParseMode)
|
||||
from telegram.error import BadRequest, InvalidToken, NetworkError, RetryAfter
|
||||
from telegram.utils.helpers import from_timestamp, escape_markdown
|
||||
from tests.conftest import expect_bad_request
|
||||
|
||||
BASE_TIME = time.time()
|
||||
HIGHSCORE_DELTA = 1450000000
|
||||
@@ -213,18 +214,86 @@ class TestBot(object):
|
||||
assert poll.question == question
|
||||
assert poll.total_voter_count == 0
|
||||
|
||||
explanation = '[Here is a link](https://google.com)'
|
||||
explanation_entities = [
|
||||
MessageEntity(MessageEntity.TEXT_LINK, 0, 14, url='https://google.com')
|
||||
]
|
||||
message_quiz = bot.send_poll(chat_id=super_group_id, question=question, options=answers,
|
||||
type=Poll.QUIZ, correct_option_id=2, is_closed=True)
|
||||
type=Poll.QUIZ, correct_option_id=2, is_closed=True,
|
||||
explanation=explanation,
|
||||
explanation_parse_mode=ParseMode.MARKDOWN_V2)
|
||||
assert message_quiz.poll.correct_option_id == 2
|
||||
assert message_quiz.poll.type == Poll.QUIZ
|
||||
assert message_quiz.poll.is_closed
|
||||
assert message_quiz.poll.explanation == 'Here is a link'
|
||||
assert message_quiz.poll.explanation_entities == explanation_entities
|
||||
|
||||
@flaky(3, 1)
|
||||
@pytest.mark.timeout(10)
|
||||
def test_send_dice(self, bot, chat_id):
|
||||
message = bot.send_dice(chat_id)
|
||||
@pytest.mark.parametrize(['open_period', 'close_date'], [(5, None), (None, True)])
|
||||
def test_send_open_period(self, bot, super_group_id, open_period, close_date):
|
||||
question = 'Is this a test?'
|
||||
answers = ['Yes', 'No', 'Maybe']
|
||||
reply_markup = InlineKeyboardMarkup.from_button(
|
||||
InlineKeyboardButton(text='text', callback_data='data'))
|
||||
|
||||
if close_date:
|
||||
close_date = dtm.datetime.utcnow() + dtm.timedelta(seconds=5)
|
||||
|
||||
message = bot.send_poll(chat_id=super_group_id, question=question, options=answers,
|
||||
is_anonymous=False, allows_multiple_answers=True, timeout=60,
|
||||
open_period=open_period, close_date=close_date)
|
||||
time.sleep(5.1)
|
||||
new_message = bot.edit_message_reply_markup(chat_id=super_group_id,
|
||||
message_id=message.message_id,
|
||||
reply_markup=reply_markup, timeout=60)
|
||||
assert new_message.poll.id == message.poll.id
|
||||
assert new_message.poll.is_closed
|
||||
|
||||
@flaky(3, 1)
|
||||
@pytest.mark.timeout(10)
|
||||
@pytest.mark.parametrize('default_bot', [{'parse_mode': 'Markdown'}], indirect=True)
|
||||
def test_send_poll_default_parse_mode(self, default_bot, super_group_id):
|
||||
explanation = 'Italic Bold Code'
|
||||
explanation_markdown = '_Italic_ *Bold* `Code`'
|
||||
question = 'Is this a test?'
|
||||
answers = ['Yes', 'No', 'Maybe']
|
||||
|
||||
message = default_bot.send_poll(chat_id=super_group_id, question=question, options=answers,
|
||||
type=Poll.QUIZ, correct_option_id=2, is_closed=True,
|
||||
explanation=explanation_markdown)
|
||||
assert message.poll.explanation == explanation
|
||||
assert message.poll.explanation_entities == [
|
||||
MessageEntity(MessageEntity.ITALIC, 0, 6),
|
||||
MessageEntity(MessageEntity.BOLD, 7, 4),
|
||||
MessageEntity(MessageEntity.CODE, 12, 4)
|
||||
]
|
||||
|
||||
message = default_bot.send_poll(chat_id=super_group_id, question=question, options=answers,
|
||||
type=Poll.QUIZ, correct_option_id=2, is_closed=True,
|
||||
explanation=explanation_markdown,
|
||||
explanation_parse_mode=None)
|
||||
assert message.poll.explanation == explanation_markdown
|
||||
assert message.poll.explanation_entities == []
|
||||
|
||||
message = default_bot.send_poll(chat_id=super_group_id, question=question, options=answers,
|
||||
type=Poll.QUIZ, correct_option_id=2, is_closed=True,
|
||||
explanation=explanation_markdown,
|
||||
explanation_parse_mode='HTML')
|
||||
assert message.poll.explanation == explanation_markdown
|
||||
assert message.poll.explanation_entities == []
|
||||
|
||||
@flaky(3, 1)
|
||||
@pytest.mark.timeout(10)
|
||||
@pytest.mark.parametrize('emoji', Dice.ALL_EMOJI + [None])
|
||||
def test_send_dice(self, bot, chat_id, emoji):
|
||||
message = bot.send_dice(chat_id, emoji=emoji)
|
||||
|
||||
assert message.dice
|
||||
if emoji is None:
|
||||
assert message.dice.emoji == Dice.DICE
|
||||
else:
|
||||
assert message.dice.emoji == emoji
|
||||
|
||||
@flaky(3, 1)
|
||||
@pytest.mark.timeout(10)
|
||||
@@ -552,7 +621,7 @@ class TestBot(object):
|
||||
chat = bot.get_chat(super_group_id)
|
||||
|
||||
assert chat.type == 'supergroup'
|
||||
assert chat.title == '>>> telegram.Bot(test)'
|
||||
assert chat.title == '>>> telegram.Bot(test) @{}'.format(bot.username)
|
||||
assert chat.id == int(super_group_id)
|
||||
|
||||
# TODO: Add bot to group to test there too
|
||||
@@ -816,13 +885,19 @@ class TestBot(object):
|
||||
@flaky(3, 1)
|
||||
@pytest.mark.timeout(10)
|
||||
def test_set_chat_photo(self, bot, channel_id):
|
||||
with open('tests/data/telegram_test_channel.jpg', 'rb') as f:
|
||||
def func():
|
||||
assert bot.set_chat_photo(channel_id, f)
|
||||
|
||||
with open('tests/data/telegram_test_channel.jpg', 'rb') as f:
|
||||
expect_bad_request(func, 'Type of file mismatch', 'Telegram did not accept the file.')
|
||||
|
||||
@flaky(3, 1)
|
||||
@pytest.mark.timeout(10)
|
||||
def test_delete_chat_photo(self, bot, channel_id):
|
||||
assert bot.delete_chat_photo(channel_id)
|
||||
def func():
|
||||
assert bot.delete_chat_photo(channel_id)
|
||||
|
||||
expect_bad_request(func, 'Chat_not_modified', 'Chat photo was not set.')
|
||||
|
||||
@flaky(3, 1)
|
||||
@pytest.mark.timeout(10)
|
||||
|
||||
@@ -22,6 +22,7 @@ import pytest
|
||||
from flaky import flaky
|
||||
|
||||
from telegram import ChatPhoto, Voice, TelegramError
|
||||
from tests.conftest import expect_bad_request
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
@@ -33,7 +34,10 @@ def chatphoto_file():
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
def chat_photo(bot, super_group_id):
|
||||
return bot.get_chat(super_group_id, timeout=50).photo
|
||||
def func():
|
||||
return bot.get_chat(super_group_id, timeout=50).photo
|
||||
|
||||
return expect_bad_request(func, 'Type of file mismatch', 'Telegram did not accept the file.')
|
||||
|
||||
|
||||
class TestChatPhoto(object):
|
||||
@@ -46,7 +50,10 @@ class TestChatPhoto(object):
|
||||
@flaky(3, 1)
|
||||
@pytest.mark.timeout(10)
|
||||
def test_send_all_args(self, bot, super_group_id, chatphoto_file, chat_photo, thumb_file):
|
||||
assert bot.set_chat_photo(super_group_id, chatphoto_file)
|
||||
def func():
|
||||
assert bot.set_chat_photo(super_group_id, chatphoto_file)
|
||||
|
||||
expect_bad_request(func, 'Type of file mismatch', 'Telegram did not accept the file.')
|
||||
|
||||
@flaky(3, 1)
|
||||
@pytest.mark.timeout(10)
|
||||
|
||||
+9
-5
@@ -22,19 +22,22 @@ import pytest
|
||||
from telegram import Dice
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def dice():
|
||||
return Dice(value=5)
|
||||
@pytest.fixture(scope="class",
|
||||
params=Dice.ALL_EMOJI)
|
||||
def dice(request):
|
||||
return Dice(value=5, emoji=request.param)
|
||||
|
||||
|
||||
class TestDice(object):
|
||||
value = 4
|
||||
|
||||
def test_de_json(self, bot):
|
||||
json_dict = {'value': self.value}
|
||||
@pytest.mark.parametrize('emoji', Dice.ALL_EMOJI)
|
||||
def test_de_json(self, bot, emoji):
|
||||
json_dict = {'value': self.value, 'emoji': emoji}
|
||||
dice = Dice.de_json(json_dict, bot)
|
||||
|
||||
assert dice.value == self.value
|
||||
assert dice.emoji == emoji
|
||||
assert Dice.de_json(None, bot) is None
|
||||
|
||||
def test_to_dict(self, dice):
|
||||
@@ -42,3 +45,4 @@ class TestDice(object):
|
||||
|
||||
assert isinstance(dice_dict, dict)
|
||||
assert dice_dict['value'] == dice.value
|
||||
assert dice_dict['emoji'] == dice.emoji
|
||||
|
||||
@@ -373,6 +373,12 @@ class TestDispatcher(object):
|
||||
def update_user_data(self, user_id, data):
|
||||
raise Exception
|
||||
|
||||
def get_conversations(self, name):
|
||||
pass
|
||||
|
||||
def update_conversation(self, name, key, new_state):
|
||||
pass
|
||||
|
||||
def start1(b, u):
|
||||
pass
|
||||
|
||||
@@ -470,6 +476,21 @@ class TestDispatcher(object):
|
||||
def update_user_data(self, user_id, data):
|
||||
self.update(data)
|
||||
|
||||
def get_chat_data(self):
|
||||
pass
|
||||
|
||||
def get_bot_data(self):
|
||||
pass
|
||||
|
||||
def get_user_data(self):
|
||||
pass
|
||||
|
||||
def get_conversations(self, name):
|
||||
pass
|
||||
|
||||
def update_conversation(self, name, key, new_state):
|
||||
pass
|
||||
|
||||
def callback(update, context):
|
||||
pass
|
||||
|
||||
@@ -513,6 +534,21 @@ class TestDispatcher(object):
|
||||
def update_user_data(self, user_id, data):
|
||||
self.test_flag_user_data = True
|
||||
|
||||
def update_conversation(self, name, key, new_state):
|
||||
pass
|
||||
|
||||
def get_conversations(self, name):
|
||||
pass
|
||||
|
||||
def get_user_data(self):
|
||||
pass
|
||||
|
||||
def get_bot_data(self):
|
||||
pass
|
||||
|
||||
def get_chat_data(self):
|
||||
pass
|
||||
|
||||
def callback(update, context):
|
||||
pass
|
||||
|
||||
|
||||
+21
-8
@@ -622,22 +622,37 @@ class TestFilters(object):
|
||||
update.message.poll = 'test'
|
||||
assert Filters.poll(update)
|
||||
|
||||
def test_filters_dice(self, update):
|
||||
update.message.dice = Dice(4)
|
||||
@pytest.mark.parametrize('emoji', Dice.ALL_EMOJI)
|
||||
def test_filters_dice(self, update, emoji):
|
||||
update.message.dice = Dice(4, emoji)
|
||||
assert Filters.dice(update)
|
||||
update.message.dice = None
|
||||
assert not Filters.dice(update)
|
||||
|
||||
def test_filters_dice_iterable(self, update):
|
||||
@pytest.mark.parametrize('emoji', Dice.ALL_EMOJI)
|
||||
def test_filters_dice_list(self, update, emoji):
|
||||
update.message.dice = None
|
||||
assert not Filters.dice(5)(update)
|
||||
|
||||
update.message.dice = Dice(5)
|
||||
update.message.dice = Dice(5, emoji)
|
||||
assert Filters.dice(5)(update)
|
||||
assert Filters.dice({5, 6})(update)
|
||||
assert not Filters.dice(1)(update)
|
||||
assert not Filters.dice([2, 3])(update)
|
||||
|
||||
def test_filters_dice_type(self, update):
|
||||
update.message.dice = Dice(5, '🎲')
|
||||
assert Filters.dice.dice(update)
|
||||
assert Filters.dice.dice([4, 5])(update)
|
||||
assert not Filters.dice.darts(update)
|
||||
assert not Filters.dice.dice([6])(update)
|
||||
|
||||
update.message.dice = Dice(5, '🎯')
|
||||
assert Filters.dice.darts(update)
|
||||
assert Filters.dice.darts([4, 5])(update)
|
||||
assert not Filters.dice.dice(update)
|
||||
assert not Filters.dice.darts([6])(update)
|
||||
|
||||
def test_language_filter_single(self, update):
|
||||
update.message.from_user.language_code = 'en_US'
|
||||
assert (Filters.language('en_US'))(update)
|
||||
@@ -730,10 +745,8 @@ class TestFilters(object):
|
||||
class _CustomFilter(BaseFilter):
|
||||
pass
|
||||
|
||||
custom = _CustomFilter()
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
(custom & Filters.text)(update)
|
||||
with pytest.raises(TypeError, match='Can\'t instantiate abstract class _CustomFilter'):
|
||||
_CustomFilter()
|
||||
|
||||
def test_custom_unnamed_filter(self, update):
|
||||
class Unnamed(BaseFilter):
|
||||
|
||||
+13
-5
@@ -27,14 +27,14 @@ from telegram import User
|
||||
from telegram import MessageEntity
|
||||
from telegram.message import Message
|
||||
from telegram.utils import helpers
|
||||
from telegram.utils.helpers import _UtcOffsetTimezone, _datetime_to_float_timestamp
|
||||
from telegram.utils.helpers import _datetime_to_float_timestamp
|
||||
|
||||
|
||||
# sample time specification values categorised into absolute / delta / time-of-day
|
||||
ABSOLUTE_TIME_SPECS = [dtm.datetime.now(tz=_UtcOffsetTimezone(dtm.timedelta(hours=-7))),
|
||||
ABSOLUTE_TIME_SPECS = [dtm.datetime.now(tz=dtm.timezone(dtm.timedelta(hours=-7))),
|
||||
dtm.datetime.utcnow()]
|
||||
DELTA_TIME_SPECS = [dtm.timedelta(hours=3, seconds=42, milliseconds=2), 30, 7.5]
|
||||
TIME_OF_DAY_TIME_SPECS = [dtm.time(12, 42, tzinfo=_UtcOffsetTimezone(dtm.timedelta(hours=-7))),
|
||||
TIME_OF_DAY_TIME_SPECS = [dtm.time(12, 42, tzinfo=dtm.timezone(dtm.timedelta(hours=-7))),
|
||||
dtm.time(12, 42)]
|
||||
RELATIVE_TIME_SPECS = DELTA_TIME_SPECS + TIME_OF_DAY_TIME_SPECS
|
||||
TIME_SPECS = ABSOLUTE_TIME_SPECS + RELATIVE_TIME_SPECS
|
||||
@@ -142,8 +142,16 @@ class TestHelpers(object):
|
||||
# this 'convenience' behaviour has been left left for backwards compatibility
|
||||
assert helpers.to_timestamp(None) is None
|
||||
|
||||
def test_from_timestamp(self):
|
||||
assert helpers.from_timestamp(1573431976) == dtm.datetime(2019, 11, 11, 0, 26, 16)
|
||||
def test_from_timestamp_naive(self):
|
||||
datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, tzinfo=None)
|
||||
assert helpers.from_timestamp(1573431976, tzinfo=None) == datetime
|
||||
|
||||
def test_from_timestamp_aware(self, timezone):
|
||||
# we're parametrizing this with two different UTC offsets to exclude the possibility
|
||||
# of an xpass when the test is run in a timezone with the same UTC offset
|
||||
datetime = dtm.datetime(2019, 11, 11, 0, 26, 16, 10**5, tzinfo=timezone)
|
||||
assert (helpers.from_timestamp(1573431976.1 - timezone.utcoffset(None).total_seconds())
|
||||
== datetime)
|
||||
|
||||
def test_create_deep_linked_url(self):
|
||||
username = 'JamesTheMock'
|
||||
|
||||
@@ -31,6 +31,7 @@ from .test_document import document, document_file # noqa: F401
|
||||
from .test_photo import _photo, photo_file, photo, thumb # noqa: F401
|
||||
# noinspection PyUnresolvedReferences
|
||||
from .test_video import video, video_file # noqa: F401
|
||||
from tests.conftest import expect_bad_request
|
||||
|
||||
|
||||
@pytest.fixture(scope='class')
|
||||
@@ -320,10 +321,14 @@ class TestSendMediaGroup(object):
|
||||
@pytest.mark.timeout(10) # noqa: F811
|
||||
def test_send_media_group_new_files(self, bot, chat_id, video_file, photo_file, # noqa: F811
|
||||
animation_file): # noqa: F811
|
||||
messages = bot.send_media_group(chat_id, [
|
||||
InputMediaVideo(video_file),
|
||||
InputMediaPhoto(photo_file)
|
||||
])
|
||||
def func():
|
||||
return bot.send_media_group(chat_id, [
|
||||
InputMediaVideo(video_file),
|
||||
InputMediaPhoto(photo_file)
|
||||
])
|
||||
messages = expect_bad_request(func, 'Type of file mismatch',
|
||||
'Telegram did not accept the file.')
|
||||
|
||||
assert isinstance(messages, list)
|
||||
assert len(messages) == 2
|
||||
assert all([isinstance(mes, Message) for mes in messages])
|
||||
|
||||
+169
-7
@@ -16,6 +16,7 @@
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser Public License
|
||||
# along with this program. If not, see [http://www.gnu.org/licenses/].
|
||||
import calendar
|
||||
import datetime as dtm
|
||||
import os
|
||||
import sys
|
||||
@@ -25,10 +26,8 @@ from time import sleep
|
||||
|
||||
import pytest
|
||||
from flaky import flaky
|
||||
|
||||
from telegram.ext import JobQueue, Updater, Job, CallbackContext
|
||||
from telegram.utils.deprecate import TelegramDeprecationWarning
|
||||
from telegram.utils.helpers import _UtcOffsetTimezone, _UTC
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
@@ -276,7 +275,7 @@ class TestJobQueue(object):
|
||||
# must subtract one minute because the UTC offset has to be strictly less than 24h
|
||||
# thus this test will xpass if run in the interval [00:00, 00:01) UTC time
|
||||
# (because target time will be 23:59 UTC, so local and target weekday will be the same)
|
||||
target_tzinfo = _UtcOffsetTimezone(dtm.timedelta(days=1, minutes=-1))
|
||||
target_tzinfo = dtm.timezone(dtm.timedelta(days=1, minutes=-1))
|
||||
target_datetime = (utcnow + dtm.timedelta(days=1, minutes=-1, seconds=delta)).replace(
|
||||
tzinfo=target_tzinfo)
|
||||
target_time = target_datetime.timetz()
|
||||
@@ -288,6 +287,69 @@ class TestJobQueue(object):
|
||||
assert self.result == 1
|
||||
assert job_queue._queue.get(False)[0] == pytest.approx(expected_reschedule_time)
|
||||
|
||||
def test_run_monthly(self, job_queue):
|
||||
delta, now = 0.1, time.time()
|
||||
date_time = dtm.datetime.utcfromtimestamp(now)
|
||||
time_of_day = (date_time + dtm.timedelta(seconds=delta)).time()
|
||||
expected_reschedule_time = now + delta
|
||||
|
||||
day = date_time.day
|
||||
expected_reschedule_time += calendar.monthrange(date_time.year,
|
||||
date_time.month)[1] * 24 * 60 * 60
|
||||
|
||||
job_queue.run_monthly(self.job_run_once, time_of_day, day)
|
||||
sleep(0.2)
|
||||
assert self.result == 1
|
||||
assert job_queue._queue.get(False)[0] == pytest.approx(expected_reschedule_time)
|
||||
|
||||
def test_run_monthly_and_not_strict(self, job_queue):
|
||||
# This only really tests something in months with < 31 days.
|
||||
# But the trouble of patching datetime is probably not worth it
|
||||
|
||||
delta, now = 0.1, time.time()
|
||||
date_time = dtm.datetime.utcfromtimestamp(now)
|
||||
time_of_day = (date_time + dtm.timedelta(seconds=delta)).time()
|
||||
expected_reschedule_time = now + delta
|
||||
|
||||
day = date_time.day
|
||||
date_time += dtm.timedelta(calendar.monthrange(date_time.year,
|
||||
date_time.month)[1] - day)
|
||||
# next job should be scheduled on last day of month if day_is_strict is False
|
||||
expected_reschedule_time += (calendar.monthrange(date_time.year,
|
||||
date_time.month)[1] - day) * 24 * 60 * 60
|
||||
|
||||
job_queue.run_monthly(self.job_run_once, time_of_day, 31, day_is_strict=False)
|
||||
assert job_queue._queue.get(False)[0] == pytest.approx(expected_reschedule_time)
|
||||
|
||||
def test_run_monthly_with_timezone(self, job_queue):
|
||||
"""test that the day is retrieved based on the job's timezone
|
||||
We set a job to run at the current UTC time of day (plus a small delay buffer) with a
|
||||
timezone that is---approximately (see below)---UTC +24, and set it to run on the weekday
|
||||
after the current UTC weekday. The job should therefore be executed now (because in UTC+24,
|
||||
the time of day is the same as the current weekday is the one after the current UTC
|
||||
weekday).
|
||||
"""
|
||||
now = time.time()
|
||||
utcnow = dtm.datetime.utcfromtimestamp(now)
|
||||
delta = 0.1
|
||||
|
||||
# must subtract one minute because the UTC offset has to be strictly less than 24h
|
||||
# thus this test will xpass if run in the interval [00:00, 00:01) UTC time
|
||||
# (because target time will be 23:59 UTC, so local and target weekday will be the same)
|
||||
target_tzinfo = dtm.timezone(dtm.timedelta(days=1, minutes=-1))
|
||||
target_datetime = (utcnow + dtm.timedelta(days=1, minutes=-1, seconds=delta)).replace(
|
||||
tzinfo=target_tzinfo)
|
||||
target_time = target_datetime.timetz()
|
||||
target_day = target_datetime.day
|
||||
expected_reschedule_time = now + delta
|
||||
expected_reschedule_time += calendar.monthrange(target_datetime.year,
|
||||
target_datetime.month)[1] * 24 * 60 * 60
|
||||
|
||||
job_queue.run_monthly(self.job_run_once, target_time, target_day)
|
||||
sleep(delta + 0.1)
|
||||
assert self.result == 1
|
||||
assert job_queue._queue.get(False)[0] == pytest.approx(expected_reschedule_time)
|
||||
|
||||
def test_warnings(self, job_queue):
|
||||
j = Job(self.job_run_once, repeat=False)
|
||||
with pytest.raises(ValueError, match='can not be set to'):
|
||||
@@ -298,18 +360,21 @@ class TestJobQueue(object):
|
||||
with pytest.raises(ValueError, match='can not be'):
|
||||
j.interval = None
|
||||
j.repeat = False
|
||||
with pytest.raises(ValueError, match='must be of type'):
|
||||
with pytest.raises(TypeError, match='must be of type'):
|
||||
j.interval = 'every 3 minutes'
|
||||
j.interval = 15
|
||||
assert j.interval_seconds == 15
|
||||
|
||||
with pytest.raises(ValueError, match='argument should be of type'):
|
||||
with pytest.raises(TypeError, match='argument should be of type'):
|
||||
j.days = 'every day'
|
||||
with pytest.raises(ValueError, match='The elements of the'):
|
||||
with pytest.raises(TypeError, match='The elements of the'):
|
||||
j.days = ('mon', 'wed')
|
||||
with pytest.raises(ValueError, match='from 0 up to and'):
|
||||
j.days = (0, 6, 12, 14)
|
||||
|
||||
with pytest.raises(TypeError, match='argument should be one of the'):
|
||||
j._set_next_t('tomorrow')
|
||||
|
||||
def test_get_jobs(self, job_queue):
|
||||
job1 = job_queue.run_once(self.job_run_once, 10, name='name1')
|
||||
job2 = job_queue.run_once(self.job_run_once, 10, name='name1')
|
||||
@@ -340,4 +405,101 @@ class TestJobQueue(object):
|
||||
jobs = [job_1, job_2, job_3]
|
||||
|
||||
for job in jobs:
|
||||
assert job.tzinfo == _UTC
|
||||
assert job.tzinfo == dtm.timezone.utc
|
||||
|
||||
def test_job_next_t_property(self, job_queue):
|
||||
# Testing:
|
||||
# - next_t values match values from self._queue.queue (for run_once and run_repeating jobs)
|
||||
# - next_t equals None if job is removed or if it's already ran
|
||||
|
||||
job1 = job_queue.run_once(self.job_run_once, 0.06, name='run_once job')
|
||||
job2 = job_queue.run_once(self.job_run_once, 0.06, name='canceled run_once job')
|
||||
job_queue.run_repeating(self.job_run_once, 0.04, name='repeatable job')
|
||||
|
||||
sleep(0.05)
|
||||
job2.schedule_removal()
|
||||
|
||||
with job_queue._queue.mutex:
|
||||
for t, job in job_queue._queue.queue:
|
||||
t = dtm.datetime.fromtimestamp(t, job.tzinfo)
|
||||
|
||||
if job.removed:
|
||||
assert job.next_t is None
|
||||
else:
|
||||
assert job.next_t == t
|
||||
|
||||
assert self.result == 1
|
||||
sleep(0.02)
|
||||
|
||||
assert self.result == 2
|
||||
assert job1.next_t is None
|
||||
assert job2.next_t is None
|
||||
|
||||
def test_job_set_next_t(self, job_queue):
|
||||
# Testing next_t setter for 'datetime.datetime' values
|
||||
|
||||
job = job_queue.run_once(self.job_run_once, 0.05)
|
||||
|
||||
t = dtm.datetime.now(tz=dtm.timezone(dtm.timedelta(hours=12)))
|
||||
job._set_next_t(t)
|
||||
job.tzinfo = dtm.timezone(dtm.timedelta(hours=5))
|
||||
assert job.next_t == t.astimezone(job.tzinfo)
|
||||
|
||||
def test_passing_tzinfo_to_job(self, job_queue):
|
||||
"""Test that tzinfo is correctly passed to job with run_once, run_daily, run_repeating
|
||||
and run_monthly methods"""
|
||||
|
||||
when_dt_tz_specific = dtm.datetime.now(
|
||||
tz=dtm.timezone(dtm.timedelta(hours=12))
|
||||
) + dtm.timedelta(seconds=2)
|
||||
when_dt_tz_utc = dtm.datetime.now() + dtm.timedelta(seconds=2)
|
||||
job_once1 = job_queue.run_once(self.job_run_once, when_dt_tz_specific)
|
||||
job_once2 = job_queue.run_once(self.job_run_once, when_dt_tz_utc)
|
||||
|
||||
when_time_tz_specific = (dtm.datetime.now(
|
||||
tz=dtm.timezone(dtm.timedelta(hours=12))
|
||||
) + dtm.timedelta(seconds=2)).timetz()
|
||||
when_time_tz_utc = (dtm.datetime.now() + dtm.timedelta(seconds=2)).timetz()
|
||||
job_once3 = job_queue.run_once(self.job_run_once, when_time_tz_specific)
|
||||
job_once4 = job_queue.run_once(self.job_run_once, when_time_tz_utc)
|
||||
|
||||
first_dt_tz_specific = dtm.datetime.now(
|
||||
tz=dtm.timezone(dtm.timedelta(hours=12))
|
||||
) + dtm.timedelta(seconds=2)
|
||||
first_dt_tz_utc = dtm.datetime.now() + dtm.timedelta(seconds=2)
|
||||
job_repeating1 = job_queue.run_repeating(
|
||||
self.job_run_once, 2, first=first_dt_tz_specific)
|
||||
job_repeating2 = job_queue.run_repeating(
|
||||
self.job_run_once, 2, first=first_dt_tz_utc)
|
||||
|
||||
first_time_tz_specific = (dtm.datetime.now(
|
||||
tz=dtm.timezone(dtm.timedelta(hours=12))
|
||||
) + dtm.timedelta(seconds=2)).timetz()
|
||||
first_time_tz_utc = (dtm.datetime.now() + dtm.timedelta(seconds=2)).timetz()
|
||||
job_repeating3 = job_queue.run_repeating(
|
||||
self.job_run_once, 2, first=first_time_tz_specific)
|
||||
job_repeating4 = job_queue.run_repeating(
|
||||
self.job_run_once, 2, first=first_time_tz_utc)
|
||||
|
||||
time_tz_specific = (dtm.datetime.now(
|
||||
tz=dtm.timezone(dtm.timedelta(hours=12))
|
||||
) + dtm.timedelta(seconds=2)).timetz()
|
||||
time_tz_utc = (dtm.datetime.now() + dtm.timedelta(seconds=2)).timetz()
|
||||
job_daily1 = job_queue.run_daily(self.job_run_once, time_tz_specific)
|
||||
job_daily2 = job_queue.run_daily(self.job_run_once, time_tz_utc)
|
||||
|
||||
job_monthly1 = job_queue.run_monthly(self.job_run_once, time_tz_specific, 1)
|
||||
job_monthly2 = job_queue.run_monthly(self.job_run_once, time_tz_utc, 1)
|
||||
|
||||
assert job_once1.tzinfo == when_dt_tz_specific.tzinfo
|
||||
assert job_once2.tzinfo == dtm.timezone.utc
|
||||
assert job_once3.tzinfo == when_time_tz_specific.tzinfo
|
||||
assert job_once4.tzinfo == dtm.timezone.utc
|
||||
assert job_repeating1.tzinfo == first_dt_tz_specific.tzinfo
|
||||
assert job_repeating2.tzinfo == dtm.timezone.utc
|
||||
assert job_repeating3.tzinfo == first_time_tz_specific.tzinfo
|
||||
assert job_repeating4.tzinfo == dtm.timezone.utc
|
||||
assert job_daily1.tzinfo == time_tz_specific.tzinfo
|
||||
assert job_daily2.tzinfo == dtm.timezone.utc
|
||||
assert job_monthly1.tzinfo == time_tz_specific.tzinfo
|
||||
assert job_monthly2.tzinfo == dtm.timezone.utc
|
||||
|
||||
@@ -92,13 +92,13 @@ def message(bot):
|
||||
options=[PollOption(text='a', voter_count=1),
|
||||
PollOption(text='b', voter_count=2)], is_closed=False,
|
||||
total_voter_count=0, is_anonymous=False, type=Poll.REGULAR,
|
||||
allows_multiple_answers=True)},
|
||||
allows_multiple_answers=True, explanation_entities=[])},
|
||||
{'text': 'a text message', 'reply_markup': {'inline_keyboard': [[{
|
||||
'text': 'start', 'url': 'http://google.com'}, {
|
||||
'text': 'next', 'callback_data': 'abcd'}],
|
||||
[{'text': 'Cancel', 'callback_data': 'Cancel'}]]}},
|
||||
{'quote': True},
|
||||
{'dice': Dice(4)}
|
||||
{'dice': Dice(4, '🎲')}
|
||||
],
|
||||
ids=['forwarded_user', 'forwarded_channel', 'reply', 'edited', 'text',
|
||||
'caption_entities', 'audio', 'document', 'animation', 'game', 'photo',
|
||||
|
||||
+34
-19
@@ -51,7 +51,33 @@ def change_directory(tmp_path):
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def base_persistence():
|
||||
return BasePersistence(store_chat_data=True, store_user_data=True, store_bot_data=True)
|
||||
class OwnPersistence(BasePersistence):
|
||||
|
||||
def get_bot_data(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_chat_data(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_user_data(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_conversations(self, name):
|
||||
raise NotImplementedError
|
||||
|
||||
def update_bot_data(self, data):
|
||||
raise NotImplementedError
|
||||
|
||||
def update_chat_data(self, chat_id, data):
|
||||
raise NotImplementedError
|
||||
|
||||
def update_conversation(self, name, key, new_state):
|
||||
raise NotImplementedError
|
||||
|
||||
def update_user_data(self, user_id, data):
|
||||
raise NotImplementedError
|
||||
|
||||
return OwnPersistence(store_chat_data=True, store_user_data=True, store_bot_data=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@@ -100,22 +126,13 @@ class TestBasePersistence(object):
|
||||
def test_creation(self, base_persistence):
|
||||
assert base_persistence.store_chat_data
|
||||
assert base_persistence.store_user_data
|
||||
with pytest.raises(NotImplementedError):
|
||||
base_persistence.get_bot_data()
|
||||
with pytest.raises(NotImplementedError):
|
||||
base_persistence.get_chat_data()
|
||||
with pytest.raises(NotImplementedError):
|
||||
base_persistence.get_user_data()
|
||||
with pytest.raises(NotImplementedError):
|
||||
base_persistence.get_conversations("test")
|
||||
with pytest.raises(NotImplementedError):
|
||||
base_persistence.update_bot_data(None)
|
||||
with pytest.raises(NotImplementedError):
|
||||
base_persistence.update_chat_data(None, None)
|
||||
with pytest.raises(NotImplementedError):
|
||||
base_persistence.update_user_data(None, None)
|
||||
with pytest.raises(NotImplementedError):
|
||||
base_persistence.update_conversation(None, None, None)
|
||||
assert base_persistence.store_bot_data
|
||||
|
||||
def test_abstract_methods(self):
|
||||
with pytest.raises(TypeError, match=('get_bot_data, get_chat_data, get_conversations, '
|
||||
'get_user_data, update_bot_data, update_chat_data, '
|
||||
'update_conversation, update_user_data')):
|
||||
BasePersistence()
|
||||
|
||||
def test_implementation(self, updater, base_persistence):
|
||||
dp = updater.dispatcher
|
||||
@@ -127,8 +144,6 @@ class TestBasePersistence(object):
|
||||
with pytest.raises(ValueError, match="if dispatcher has no persistence"):
|
||||
dp.add_handler(ConversationHandler([], {}, [], persistent=True, name="My Handler"))
|
||||
dp.persistence = base_persistence
|
||||
with pytest.raises(NotImplementedError):
|
||||
dp.add_handler(ConversationHandler([], {}, [], persistent=True, name="My Handler"))
|
||||
|
||||
def test_dispatcher_integration_init(self, bot, base_persistence, chat_data, user_data,
|
||||
bot_data):
|
||||
|
||||
+6
-2
@@ -24,6 +24,7 @@ from flaky import flaky
|
||||
|
||||
from telegram import Sticker, TelegramError, PhotoSize, InputFile
|
||||
from telegram.utils.helpers import escape_markdown
|
||||
from tests.conftest import expect_bad_request
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
@@ -35,8 +36,11 @@ def photo_file():
|
||||
|
||||
@pytest.fixture(scope='class')
|
||||
def _photo(bot, chat_id):
|
||||
with open('tests/data/telegram.jpg', 'rb') as f:
|
||||
return bot.send_photo(chat_id, photo=f, timeout=50).photo
|
||||
def func():
|
||||
with open('tests/data/telegram.jpg', 'rb') as f:
|
||||
return bot.send_photo(chat_id, photo=f, timeout=50).photo
|
||||
|
||||
return expect_bad_request(func, 'Type of file mismatch', 'Telegram did not accept the file.')
|
||||
|
||||
|
||||
@pytest.fixture(scope='class')
|
||||
|
||||
+41
-3
@@ -19,7 +19,9 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from telegram import Poll, PollOption, PollAnswer, User
|
||||
from datetime import datetime
|
||||
from telegram import Poll, PollOption, PollAnswer, User, MessageEntity
|
||||
from telegram.utils.helpers import to_timestamp
|
||||
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
@@ -91,7 +93,11 @@ def poll():
|
||||
TestPoll.is_closed,
|
||||
TestPoll.is_anonymous,
|
||||
TestPoll.type,
|
||||
TestPoll.allows_multiple_answers
|
||||
TestPoll.allows_multiple_answers,
|
||||
explanation=TestPoll.explanation,
|
||||
explanation_entities=TestPoll.explanation_entities,
|
||||
open_period=TestPoll.open_period,
|
||||
close_date=TestPoll.close_date,
|
||||
)
|
||||
|
||||
|
||||
@@ -104,6 +110,11 @@ class TestPoll(object):
|
||||
is_anonymous = False
|
||||
type = Poll.REGULAR
|
||||
allows_multiple_answers = True
|
||||
explanation = (b'\\U0001f469\\u200d\\U0001f469\\u200d\\U0001f467'
|
||||
b'\\u200d\\U0001f467\\U0001f431http://google.com').decode('unicode-escape')
|
||||
explanation_entities = [MessageEntity(13, 17, MessageEntity.URL)]
|
||||
open_period = 42
|
||||
close_date = datetime.utcnow()
|
||||
|
||||
def test_de_json(self):
|
||||
json_dict = {
|
||||
@@ -114,7 +125,11 @@ class TestPoll(object):
|
||||
'is_closed': self.is_closed,
|
||||
'is_anonymous': self.is_anonymous,
|
||||
'type': self.type,
|
||||
'allows_multiple_answers': self.allows_multiple_answers
|
||||
'allows_multiple_answers': self.allows_multiple_answers,
|
||||
'explanation': self.explanation,
|
||||
'explanation_entities': [self.explanation_entities[0].to_dict()],
|
||||
'open_period': self.open_period,
|
||||
'close_date': to_timestamp(self.close_date)
|
||||
}
|
||||
poll = Poll.de_json(json_dict, None)
|
||||
|
||||
@@ -130,6 +145,11 @@ class TestPoll(object):
|
||||
assert poll.is_anonymous == self.is_anonymous
|
||||
assert poll.type == self.type
|
||||
assert poll.allows_multiple_answers == self.allows_multiple_answers
|
||||
assert poll.explanation == self.explanation
|
||||
assert poll.explanation_entities == self.explanation_entities
|
||||
assert poll.open_period == self.open_period
|
||||
assert pytest.approx(poll.close_date == self.close_date)
|
||||
assert to_timestamp(poll.close_date) == to_timestamp(self.close_date)
|
||||
|
||||
def test_to_dict(self, poll):
|
||||
poll_dict = poll.to_dict()
|
||||
@@ -143,3 +163,21 @@ class TestPoll(object):
|
||||
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['explanation'] == poll.explanation
|
||||
assert poll_dict['explanation_entities'] == [poll.explanation_entities[0].to_dict()]
|
||||
assert poll_dict['open_period'] == poll.open_period
|
||||
assert poll_dict['close_date'] == to_timestamp(poll.close_date)
|
||||
|
||||
def test_parse_entity(self, poll):
|
||||
entity = MessageEntity(type=MessageEntity.URL, offset=13, length=17)
|
||||
poll.explanation_entities = [entity]
|
||||
|
||||
assert poll.parse_explanation_entity(entity) == 'http://google.com'
|
||||
|
||||
def test_parse_entities(self, poll):
|
||||
entity = MessageEntity(type=MessageEntity.URL, offset=13, length=17)
|
||||
entity_2 = MessageEntity(type=MessageEntity.BOLD, offset=13, length=1)
|
||||
poll.explanation_entities = [entity_2, entity]
|
||||
|
||||
assert poll.parse_explanation_entities(MessageEntity.URL) == {entity: 'http://google.com'}
|
||||
assert poll.parse_explanation_entities() == {entity: 'http://google.com', entity_2: 'h'}
|
||||
|
||||
+11
-2
@@ -25,6 +25,7 @@ from flaky import flaky
|
||||
from future.utils import PY2
|
||||
|
||||
from telegram import Sticker, PhotoSize, TelegramError, StickerSet, Audio, MaskPosition
|
||||
from telegram.error import BadRequest
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
@@ -260,7 +261,11 @@ class TestSticker(object):
|
||||
def sticker_set(bot):
|
||||
ss = bot.get_sticker_set('test_by_{}'.format(bot.username))
|
||||
if len(ss.stickers) > 100:
|
||||
raise Exception('stickerset is growing too large.')
|
||||
try:
|
||||
for i in range(1, 50):
|
||||
bot.delete_sticker_from_set(ss.stickers[-i].file_id)
|
||||
except BadRequest:
|
||||
raise Exception('stickerset is growing too large.')
|
||||
return ss
|
||||
|
||||
|
||||
@@ -268,7 +273,11 @@ def sticker_set(bot):
|
||||
def animated_sticker_set(bot):
|
||||
ss = bot.get_sticker_set('animated_test_by_{}'.format(bot.username))
|
||||
if len(ss.stickers) > 100:
|
||||
raise Exception('stickerset is growing too large.')
|
||||
try:
|
||||
for i in range(1, 50):
|
||||
bot.delete_sticker_from_set(ss.stickers[-i].file_id)
|
||||
except BadRequest:
|
||||
raise Exception('stickerset is growing too large.')
|
||||
return ss
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ from future.builtins import bytes
|
||||
|
||||
from telegram import TelegramError, Message, User, Chat, Update, Bot
|
||||
from telegram.error import Unauthorized, InvalidToken, TimedOut, RetryAfter
|
||||
from telegram.ext import Updater, Dispatcher, BasePersistence
|
||||
from telegram.ext import Updater, Dispatcher, DictPersistence
|
||||
|
||||
signalskip = pytest.mark.skipif(sys.platform == 'win32',
|
||||
reason='Can\'t send signals without stopping '
|
||||
@@ -467,7 +467,7 @@ class TestUpdater(object):
|
||||
|
||||
def test_mutual_exclude_persistence_dispatcher(self):
|
||||
dispatcher = Dispatcher(None, None)
|
||||
persistence = BasePersistence()
|
||||
persistence = DictPersistence()
|
||||
with pytest.raises(ValueError):
|
||||
Updater(dispatcher=dispatcher, persistence=persistence)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user