diff --git a/.github/CONTRIBUTING.rst b/.github/CONTRIBUTING.rst index e6c748b84..05bd22e02 100644 --- a/.github/CONTRIBUTING.rst +++ b/.github/CONTRIBUTING.rst @@ -25,7 +25,7 @@ Setting things up .. code-block:: bash - $ sudo pip install -r requirements.txt -r requirements-dev.txt + $ pip install -r requirements.txt -r requirements-dev.txt 5. Install pre-commit hooks: diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE/bug_report.md similarity index 71% rename from .github/ISSUE_TEMPLATE.md rename to .github/ISSUE_TEMPLATE/bug_report.md index 8945f449e..1b32e8050 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,9 +1,16 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG]" +labels: 'bug :bug:' +assignees: '' + +--- + + +### Issue I am facing +Please describe the issue here in as much detail as possible + +### Traceback to the issue +``` +put it here +``` + +### Related part of your code +```python +put it here +``` diff --git a/.github/workflows/example_notifier.yml b/.github/workflows/example_notifier.yml new file mode 100644 index 000000000..661f63431 --- /dev/null +++ b/.github/workflows/example_notifier.yml @@ -0,0 +1,14 @@ +name: Warning maintainers +on: + pull_request: + paths: examples/** +jobs: + job: + runs-on: ubuntu-latest + name: about example change + steps: + - name: running the check + uses: Poolitzer/notifier-action@master + with: + notify-message: Hey there. Relax, I am just a little warning for the maintainers to release directly after merging your PR, otherwise we have broken examples and people might get confused :) + repo-token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..0d182b081 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,92 @@ +name: Testing your PR +on: + pull_request: + branches: + - master + +jobs: + pytest: + name: pytest + runs-on: ${{matrix.os}} + strategy: + matrix: + python-version: [2.7, 3.5, 3.6, 3.7] + os: [ubuntu-latest, windows-latest] + include: + - os: ubuntu-latest + python-version: 3.7 + test-build: True + test-pre-commit: True + - os: windows-latest + python-version: 3.7 + test-build: True + fail-fast: False + steps: + - uses: actions/checkout@v1 + - name: Initialize vendored libs + run: + git submodule update --init --recursive + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -W ignore -m pip install --upgrade pip + python -W ignore -m pip install -U codecov pytest-cov + python -W ignore -m pip install -r requirements.txt + python -W ignore -m pip install -r requirements-dev.txt + + - name: Test with pytest + run: | + pytest -v -m nocoverage + nocov_exit=$? + pytest -v -m "not nocoverage" --cov + cov_exit=$? + global_exit=$(( nocov_exit > cov_exit ? nocov_exit : cov_exit )) + exit ${global_exit} + env: + JOB_INDEX: ${{ strategy.job-index }} + BOTS: W3sidG9rZW4iOiAiNjk2MTg4NzMyOkFBR1Z3RUtmSEhsTmpzY3hFRE5LQXdraEdzdFpfa28xbUMwIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WldGaU1UUmxNbVF5TnpNeSIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIENQeXRob24gMi43IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19jcHl0aG9uXzI3X2JvdCJ9LCB7InRva2VuIjogIjY3MTQ2ODg4NjpBQUdQR2ZjaVJJQlVORmU4MjR1SVZkcTdKZTNfWW5BVE5HdyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOlpHWXdPVGxrTXpNeE4yWTIiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIFRyYXZpcyB1c2luZyBDUHl0aG9uIDMuNCIsICJib3RfdXNlcm5hbWUiOiAiQHB0Yl90cmF2aXNfY3B5dGhvbl8zNF9ib3QifSwgeyJ0b2tlbiI6ICI2MjkzMjY1Mzg6QUFGUnJaSnJCN29CM211ekdzR0pYVXZHRTVDUXpNNUNVNG8iLCAicGF5bWVudF9wcm92aWRlcl90b2tlbiI6ICIyODQ2ODUwNjM6VEVTVDpNbU01WVdKaFl6a3hNMlUxIiwgImJvdF9uYW1lIjogIlBUQiB0ZXN0cyBvbiBUcmF2aXMgdXNpbmcgQ1B5dGhvbiAzLjUiLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfdHJhdmlzX2NweXRob25fMzVfYm90In0sIHsidG9rZW4iOiAiNjQwMjA4OTQzOkFBRmhCalFwOXFtM1JUeFN6VXBZekJRakNsZS1Kano1aGNrIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WXpoa1pUZzFOamMxWXpWbCIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIENQeXRob24gMy42IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19jcHl0aG9uXzM2X2JvdCJ9LCB7InRva2VuIjogIjY5NTEwNDA4ODpBQUhmenlsSU9qU0lJUy1lT25JMjB5MkUyMEhvZEhzZnotMCIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOk9HUTFNRGd3WmpJd1pqRmwiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIFRyYXZpcyB1c2luZyBDUHl0aG9uIDMuNyIsICJib3RfdXNlcm5hbWUiOiAiQHB0Yl90cmF2aXNfY3B5dGhvbl8zN19ib3QifSwgeyJ0b2tlbiI6ICI2OTE0MjM1NTQ6QUFGOFdrakNaYm5IcVBfaTZHaFRZaXJGRWxackdhWU9oWDAiLCAicGF5bWVudF9wcm92aWRlcl90b2tlbiI6ICIyODQ2ODUwNjM6VEVTVDpZamM1TlRoaU1tUXlNV1ZoIiwgImJvdF9uYW1lIjogIlBUQiB0ZXN0cyBvbiBUcmF2aXMgdXNpbmcgUHlQeSAyLjciLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfdHJhdmlzX3B5cHlfMjdfYm90In0sIHsidG9rZW4iOiAiNjg0MzM5OTg0OkFBRk1nRUVqcDAxcjVyQjAwN3lDZFZOc2c4QWxOc2FVLWNjIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6TVRBek1UWTNNR1V5TmpnMCIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gVHJhdmlzIHVzaW5nIFB5UHkgMy41IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX3RyYXZpc19weXB5XzM1X2JvdCJ9LCB7InRva2VuIjogIjY5MDA5MTM0NzpBQUZMbVI1cEFCNVljcGVfbU9oN3pNNEpGQk9oMHozVDBUbyIsICJwYXltZW50X3Byb3ZpZGVyX3Rva2VuIjogIjI4NDY4NTA2MzpURVNUOlpEaGxOekU1TURrd1lXSmkiLCAiYm90X25hbWUiOiAiUFRCIHRlc3RzIG9uIEFwcFZleW9yIHVzaW5nIENQeXRob24gMy40IiwgImJvdF91c2VybmFtZSI6ICJAcHRiX2FwcHZleW9yX2NweXRob25fMzRfYm90In0sIHsidG9rZW4iOiAiNjk0MzA4MDUyOkFBRUIyX3NvbkNrNTVMWTlCRzlBTy1IOGp4aVBTNTVvb0JBIiwgInBheW1lbnRfcHJvdmlkZXJfdG9rZW4iOiAiMjg0Njg1MDYzOlRFU1Q6WW1aaVlXWm1NakpoWkdNeSIsICJib3RfbmFtZSI6ICJQVEIgdGVzdHMgb24gQXBwVmV5b3IgdXNpbmcgQ1B5dGhvbiAyLjciLCAiYm90X3VzZXJuYW1lIjogIkBwdGJfYXBwdmV5b3JfY3B5dGhvbl8yN19ib3QifV0= + TEST_BUILD: ${{ matrix.test-build }} + TEST_PRE_COMMIT: ${{ matrix.test-pre-commit }} + shell: bash --noprofile --norc {0} + + - name: Submit coverage + run: | + if [ "$CODECOV_TOKEN" != "" ]; then + codecov -F github -t $CODECOV_TOKEN --name "${{ matrix.os }}-${{ matrix.python-version }}" + fi + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + shell: bash + test_official: + name: test-official + runs-on: ${{matrix.os}} + strategy: + matrix: + python-version: [3.7] + os: [ubuntu-latest] + fail-fast: False + steps: + - uses: actions/checkout@v1 + - name: Initialize vendored libs + run: + git submodule update --init --recursive + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -W ignore -m pip install --upgrade pip + python -W ignore -m pip install -r requirements.txt + python -W ignore -m pip install -r requirements-dev.txt + - name: Compare to official api + run: | + pytest -v tests/test_official.py + exit $? + env: + TEST_OFFICIAL: "true" + shell: bash --noprofile --norc {0} + diff --git a/AUTHORS.rst b/AUTHORS.rst index 63cd5e0bd..a72ca99f9 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -26,6 +26,7 @@ The following wonderful people contributed directly or indirectly to this projec - `d-qoi `_ - `daimajia `_ - `Daniel Reed `_ +- `Dmitry Grigoryev `_ - `Ehsan Online `_ - `Eli Gao `_ - `Emilio Molinari `_ diff --git a/README.rst b/README.rst index 812f3686a..4c6953bd6 100644 --- a/README.rst +++ b/README.rst @@ -192,11 +192,12 @@ You can get help in several ways: 1. We have a vibrant community of developers helping each other in our `Telegram group `_. Join us! -2. Our `Wiki pages `_ offer a growing amount of resources. +2. Report bugs, request new features or ask questions by `creating an issue `_. -3. You can ask for help on Stack Overflow using the `python-telegram-bot tag `_. +3. Our `Wiki pages `_ offer a growing amount of resources. + +4. You can even ask for help on Stack Overflow using the `python-telegram-bot tag `_. -4. As last resort, the developers are ready to help you with `serious issues `_. ============ diff --git a/examples/README.md b/examples/README.md index 3adda0430..e6e2241d2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,6 @@ # Examples -In this folder there are small examples to show what a bot written with `python-telegram-bot` looks like. Some bots focus on one specific aspect of the Telegram Bot API while others focus on one of the mechanics of this library. Except for the [`echobot.py`](#pure-api) example, they all use the high-level framework this library provides with the [`telegram.ext`](https://python-telegram-bot.readthedocs.io/en/latest/telegram.ext.html) submodule. +In this folder are small examples to show what a bot written with `python-telegram-bot` looks like. Some bots focus on one specific aspect of the Telegram Bot API while others focus on one of the mechanics of this library. Except for the [`echobot.py`](#pure-api) example, they all use the high-level framework this library provides with the [`telegram.ext`](https://python-telegram-bot.readthedocs.io/en/latest/telegram.ext.html) submodule. All examples are licensed under the [CC0 License](https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples/LICENSE.txt) and are therefore fully dedicated to the public domain. You can use them as the base for your own bots without worrying about copyrights. @@ -35,4 +35,4 @@ A basic example of a bot that can accept payments. Don't forget to enable and co A basic example of a bot store conversation state and user_data over multiple restarts. ## Pure API -The [`echobot.py`](https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples/echobot.py) example uses only the pure, "bare-metal" API wrapper. +The [`echobot.py`](https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples/echobot.py) example uses only the pure, "bare-metal" API wrapper. diff --git a/examples/passportbot.py b/examples/passportbot.py index ebcfc6154..5d57f731e 100644 --- a/examples/passportbot.py +++ b/examples/passportbot.py @@ -21,7 +21,7 @@ logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s logger = logging.getLogger(__name__) -def msg(bot, update): +def msg(update, context): # If we received any passport data passport_data = update.message.passport_data if passport_data: @@ -77,9 +77,9 @@ def msg(bot, update): actual_file.download() -def error(bot, update, error): +def error(update, context): """Log Errors caused by Updates.""" - logger.warning('Update "%s" caused error "%s"', update, error) + logger.warning('Update "%s" caused error "%s"', update, context.error) def main(): diff --git a/examples/paymentbot.py b/examples/paymentbot.py index 229f87e32..5c65a043c 100644 --- a/examples/paymentbot.py +++ b/examples/paymentbot.py @@ -42,7 +42,7 @@ def start_with_shipping_callback(update, context): currency = "USD" # price in dollars price = 1 - # price * 100 so as to include 2 d.p. + # price * 100 so as to include 2 decimal points # check https://core.telegram.org/bots/payments#supported-currencies for more details prices = [LabeledPrice("Test", price * 100)] @@ -66,7 +66,7 @@ def start_without_shipping_callback(update, context): currency = "USD" # price in dollars price = 1 - # price * 100 so as to include 2 d.p. + # price * 100 so as to include 2 decimal points prices = [LabeledPrice("Test", price * 100)] # optionally pass need_name=True, need_phone_number=True, diff --git a/telegram/constants.py b/telegram/constants.py index 9bb20940e..ba5af0fc3 100644 --- a/telegram/constants.py +++ b/telegram/constants.py @@ -17,7 +17,8 @@ """Constants in the Telegram network. The following constants were extracted from the -`Telegram Bots FAQ `_. +`Telegram Bots FAQ `_ and +`Telegram Bots API `_. Attributes: MAX_MESSAGE_LENGTH (:obj:`int`): 4096 @@ -25,6 +26,7 @@ Attributes: SUPPORTED_WEBHOOK_PORTS (List[:obj:`int`]): [443, 80, 88, 8443] MAX_FILESIZE_DOWNLOAD (:obj:`int`): In bytes (20MB) MAX_FILESIZE_UPLOAD (:obj:`int`): In bytes (50MB) + MAX_PHOTOSIZE_UPLOAD (:obj:`int`): In bytes (10MB) MAX_MESSAGES_PER_SECOND_PER_CHAT (:obj:`int`): `1`. Telegram may allow short bursts that go over this limit, but eventually you'll begin receiving 429 errors. MAX_MESSAGES_PER_SECOND (:obj:`int`): 30 @@ -47,6 +49,7 @@ MAX_CAPTION_LENGTH = 1024 SUPPORTED_WEBHOOK_PORTS = [443, 80, 88, 8443] MAX_FILESIZE_DOWNLOAD = int(20E6) # (20MB) MAX_FILESIZE_UPLOAD = int(50E6) # (50MB) +MAX_PHOTOSIZE_UPLOAD = int(10E6) # (10MB) MAX_MESSAGES_PER_SECOND_PER_CHAT = 1 MAX_MESSAGES_PER_SECOND = 30 MAX_MESSAGES_PER_MINUTE_PER_GROUP = 20 diff --git a/telegram/ext/conversationhandler.py b/telegram/ext/conversationhandler.py index a53f4e81c..a31809a70 100644 --- a/telegram/ext/conversationhandler.py +++ b/telegram/ext/conversationhandler.py @@ -20,6 +20,7 @@ import logging import warnings +from threading import Lock from telegram import Update from telegram.ext import (Handler, CallbackQueryHandler, InlineQueryHandler, @@ -37,8 +38,7 @@ class _ConversationTimeoutContext(object): class ConversationHandler(Handler): """ A handler to hold a conversation with a single user by managing four collections of other - handlers. Note that neither posts in Telegram Channels, nor group interactions with multiple - users are managed by instances of this class. + handlers. The first collection, a ``list`` named :attr:`entry_points`, is used to initiate the conversation, for example with a :class:`telegram.ext.CommandHandler` or @@ -184,7 +184,9 @@ class ConversationHandler(Handler): self.map_to_parent = map_to_parent self.timeout_jobs = dict() + self._timeout_jobs_lock = Lock() self.conversations = dict() + self._conversations_lock = Lock() self.logger = logging.getLogger(__name__) @@ -262,7 +264,8 @@ class ConversationHandler(Handler): return None key = self._get_key(update) - state = self.conversations.get(key) + with self._conversations_lock: + state = self.conversations.get(key) # Resolve promises if isinstance(state, tuple) and len(state) == 2 and isinstance(state[1], Promise): @@ -281,7 +284,8 @@ class ConversationHandler(Handler): if res is None and old_state is None: res = self.END self.update_state(res, key) - state = self.conversations.get(key) + with self._conversations_lock: + state = self.conversations.get(key) else: handlers = self.states.get(self.WAITING, []) for handler in handlers: @@ -340,15 +344,22 @@ class ConversationHandler(Handler): """ conversation_key, handler, check_result = check_result - new_state = handler.handle_update(update, dispatcher, check_result, context) - timeout_job = self.timeout_jobs.pop(conversation_key, None) - if timeout_job is not None: - timeout_job.schedule_removal() - if self.conversation_timeout and new_state != self.END: - self.timeout_jobs[conversation_key] = dispatcher.job_queue.run_once( - self._trigger_timeout, self.conversation_timeout, - context=_ConversationTimeoutContext(conversation_key, update, dispatcher)) + with self._timeout_jobs_lock: + # Remove the old timeout job (if present) + timeout_job = self.timeout_jobs.pop(conversation_key, None) + + if timeout_job is not None: + timeout_job.schedule_removal() + + new_state = handler.handle_update(update, dispatcher, check_result, context) + + with self._timeout_jobs_lock: + if self.conversation_timeout and new_state != self.END: + # Add the new timeout job + self.timeout_jobs[conversation_key] = dispatcher.job_queue.run_once( + self._trigger_timeout, self.conversation_timeout, + context=_ConversationTimeoutContext(conversation_key, update, dispatcher)) if isinstance(self.map_to_parent, dict) and new_state in self.map_to_parent: self.update_state(self.END, conversation_key) @@ -358,33 +369,42 @@ class ConversationHandler(Handler): def update_state(self, new_state, key): if new_state == self.END: - if key in self.conversations: - # If there is no key in conversations, nothing is done. - del self.conversations[key] - if self.persistent: - self.persistence.update_conversation(self.name, key, None) + with self._conversations_lock: + if key in self.conversations: + # If there is no key in conversations, nothing is done. + del self.conversations[key] + if self.persistent: + self.persistence.update_conversation(self.name, key, None) elif isinstance(new_state, Promise): - self.conversations[key] = (self.conversations.get(key), new_state) - if self.persistent: - self.persistence.update_conversation(self.name, key, - (self.conversations.get(key), new_state)) + with self._conversations_lock: + self.conversations[key] = (self.conversations.get(key), new_state) + if self.persistent: + self.persistence.update_conversation(self.name, key, + (self.conversations.get(key), new_state)) elif new_state is not None: - self.conversations[key] = new_state - if self.persistent: - self.persistence.update_conversation(self.name, key, new_state) + with self._conversations_lock: + self.conversations[key] = new_state + if self.persistent: + self.persistence.update_conversation(self.name, key, new_state) def _trigger_timeout(self, context, job=None): self.logger.debug('conversation timeout was triggered!') # Backward compatibility with bots that do not use CallbackContext if isinstance(context, CallbackContext): - context = context.job.context - else: - context = job.context + job = context.job + + context = job.context + + with self._timeout_jobs_lock: + found_job = self.timeout_jobs[context.conversation_key] + if found_job is not job: + # The timeout has been canceled in handle_update + return + del self.timeout_jobs[context.conversation_key] - del self.timeout_jobs[context.conversation_key] handlers = self.states.get(self.TIMEOUT, []) for handler in handlers: check = handler.check_update(context.update) diff --git a/telegram/ext/filters.py b/telegram/ext/filters.py index ae5df4e68..40c9abdf9 100644 --- a/telegram/ext/filters.py +++ b/telegram/ext/filters.py @@ -909,6 +909,39 @@ officedocument.wordprocessingml.document")``- return message.from_user.language_code and any( [message.from_user.language_code.startswith(x) for x in self.lang]) + class msg_in(BaseFilter): + """Filters messages to only allow those whose text/caption appears in a given list. + + Examples: + A simple usecase is to allow only messages that were send by a custom + :class:`telegram.ReplyKeyboardMarkup`:: + + buttons = ['Start', 'Settings', 'Back'] + markup = ReplyKeyboardMarkup.from_column(buttons) + ... + MessageHandler(Filters.msg_in(buttons), callback_method) + + Args: + list_ (List[:obj:`str`]): Which messages to allow through. Only exact matches + are allowed. + caption (:obj:`bool`): Optional. Whether the caption should be used instead of text. + Default is ``False``. + + """ + + def __init__(self, list_, caption=False): + self.list_ = list_ + self.caption = caption + self.name = 'Filters.msg_in({!r}, caption={!r})'.format(self.list_, self.caption) + + def filter(self, message): + if self.caption: + txt = message.caption + else: + txt = message.text + + return txt in self.list_ + class _UpdateType(BaseFilter): update_filter = True diff --git a/telegram/ext/jobqueue.py b/telegram/ext/jobqueue.py index 1f513872b..d3e0edced 100644 --- a/telegram/ext/jobqueue.py +++ b/telegram/ext/jobqueue.py @@ -42,7 +42,8 @@ class JobQueue(object): Attributes: _queue (:obj:`PriorityQueue`): The queue that holds the Jobs. bot (:class:`telegram.Bot`): The bot instance that should be passed to the jobs. - DEPRECATED: Use set_dispatcher instead. + DEPRECATED: Use :attr:`set_dispatcher` instead. + """ def __init__(self, bot=None): @@ -68,6 +69,13 @@ class JobQueue(object): self._running = False def set_dispatcher(self, dispatcher): + """Set the dispatcher to be used by this JobQueue. Use this instead of passing a + :class:`telegram.Bot` to the JobQueue, which is deprecated. + + Args: + dispatcher (:class:`telegram.ext.Dispatcher`): The dispatcher. + + """ self._dispatcher = dispatcher def _put(self, job, next_t=None, last_t=None): diff --git a/telegram/message.py b/telegram/message.py index 913705c74..9e4d2f8ed 100644 --- a/telegram/message.py +++ b/telegram/message.py @@ -742,13 +742,14 @@ class Message(TelegramObject): self._quote(kwargs) return self.bot.send_poll(self.chat_id, *args, **kwargs) - def forward(self, chat_id, disable_notification=False): + def forward(self, chat_id, *args, **kwargs): """Shortcut for:: bot.forward_message(chat_id=chat_id, from_chat_id=update.message.chat_id, - disable_notification=disable_notification, - message_id=update.message.message_id) + message_id=update.message.message_id, + *args, + **kwargs) Returns: :class:`telegram.Message`: On success, instance representing the message forwarded. @@ -757,8 +758,9 @@ class Message(TelegramObject): return self.bot.forward_message( chat_id=chat_id, from_chat_id=self.chat_id, - disable_notification=disable_notification, - message_id=self.message_id) + message_id=self.message_id, + *args, + **kwargs) def edit_text(self, *args, **kwargs): """Shortcut for:: diff --git a/tests/bots.py b/tests/bots.py index 3177cde26..a7cca964b 100644 --- a/tests/bots.py +++ b/tests/bots.py @@ -17,11 +17,10 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. """Provide a bot to tests""" +import json +import base64 import os import random -import sys - -from platform import python_implementation # 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 @@ -32,32 +31,42 @@ FALLBACKS = [ 'payment_provider_token': '284685063:TEST:NjQ0NjZlNzI5YjJi', 'chat_id': '675666224', 'super_group_id': '-1001493296829', - 'channel_id': '@pythontelegrambottests' + 'channel_id': '@pythontelegrambottests', + 'bot_name': 'PTB tests fallback 1', + 'bot_username': '@ptb_fallback_1_bot' }, { 'token': '558194066:AAEEylntuKSLXj9odiv3TnX7Z5KY2J3zY3M', 'payment_provider_token': '284685063:TEST:YjEwODQwMTFmNDcy', 'chat_id': '675666224', 'super_group_id': '-1001493296829', - 'channel_id': '@pythontelegrambottests' + 'channel_id': '@pythontelegrambottests', + 'bot_name': 'PTB tests fallback 2', + 'bot_username': '@ptb_fallback_2_bot' } ] +GITHUB_ACTION = os.getenv('GITHUB_ACTION', None) +BOTS = os.getenv('BOTS', None) +JOB_INDEX = os.getenv('JOB_INDEX', None) +if GITHUB_ACTION is not None and BOTS is not None and JOB_INDEX is not None: + BOTS = json.loads(base64.b64decode(BOTS).decode('utf-8')) + JOB_INDEX = int(JOB_INDEX) + def get(name, fallback): - full_name = '{0}_{1}_{2[0]}{2[1]}'.format(name, python_implementation(), - sys.version_info).upper() - # First try full_names such as - # TOKEN_CPYTHON_33 - # CHAT_ID_PYPY_27 - val = os.getenv(full_name) - if val: - return val - # Then try short names - # TOKEN - # CHAT_ID + # If we have TOKEN, PAYMENT_PROVIDER_TOKEN, CHAT_ID, SUPER_GROUP_ID, + # CHANNEL_ID, BOT_NAME, or BOT_USERNAME in the environment, then use that val = os.getenv(name.upper()) if val: return val + + # If we're running as a github action then fetch bots from the repo secrets + if GITHUB_ACTION is not None and BOTS is not None and JOB_INDEX is not None: + try: + return BOTS[JOB_INDEX][name] + except KeyError: + pass + # Otherwise go with the fallback return fallback diff --git a/tests/conftest.py b/tests/conftest.py index 5b0417eea..32caff59e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,6 +38,11 @@ TRAVIS = os.getenv('TRAVIS', False) if TRAVIS: pytest_plugins = ['tests.travis_fold'] +GITHUB_ACTION = os.getenv('GITHUB_ACTION', False) + +if GITHUB_ACTION: + pytest_plugins = ['tests.plugin_github_group'] + # THIS KEY IS OBVIOUSLY COMPROMISED # DO NOT USE IN PRODUCTION! PRIVATE_KEY = b"-----BEGIN RSA PRIVATE KEY-----\r\nMIIEowIBAAKCAQEA0AvEbNaOnfIL3GjB8VI4M5IaWe+GcK8eSPHkLkXREIsaddum\r\nwPBm/+w8lFYdnY+O06OEJrsaDtwGdU//8cbGJ/H/9cJH3dh0tNbfszP7nTrQD+88\r\nydlcYHzClaG8G+oTe9uEZSVdDXj5IUqR0y6rDXXb9tC9l+oSz+ShYg6+C4grAb3E\r\nSTv5khZ9Zsi/JEPWStqNdpoNuRh7qEYc3t4B/a5BH7bsQENyJSc8AWrfv+drPAEe\r\njQ8xm1ygzWvJp8yZPwOIYuL+obtANcoVT2G2150Wy6qLC0bD88Bm40GqLbSazueC\r\nRHZRug0B9rMUKvKc4FhG4AlNzBCaKgIcCWEqKwIDAQABAoIBACcIjin9d3Sa3S7V\r\nWM32JyVF3DvTfN3XfU8iUzV7U+ZOswA53eeFM04A/Ly4C4ZsUNfUbg72O8Vd8rg/\r\n8j1ilfsYpHVvphwxaHQlfIMa1bKCPlc/A6C7b2GLBtccKTbzjARJA2YWxIaqk9Nz\r\nMjj1IJK98i80qt29xRnMQ5sqOO3gn2SxTErvNchtBiwOH8NirqERXig8VCY6fr3n\r\nz7ZImPU3G/4qpD0+9ULrt9x/VkjqVvNdK1l7CyAuve3D7ha3jPMfVHFtVH5gqbyp\r\nKotyIHAyD+Ex3FQ1JV+H7DkP0cPctQiss7OiO9Zd9C1G2OrfQz9el7ewAPqOmZtC\r\nKjB3hUECgYEA/4MfKa1cvaCqzd3yUprp1JhvssVkhM1HyucIxB5xmBcVLX2/Kdhn\r\nhiDApZXARK0O9IRpFF6QVeMEX7TzFwB6dfkyIePsGxputA5SPbtBlHOvjZa8omMl\r\nEYfNa8x/mJkvSEpzvkWPascuHJWv1cEypqphu/70DxubWB5UKo/8o6cCgYEA0HFy\r\ncgwPMB//nltHGrmaQZPFT7/Qgl9ErZT3G9S8teWY4o4CXnkdU75tBoKAaJnpSfX3\r\nq8VuRerF45AFhqCKhlG4l51oW7TUH50qE3GM+4ivaH5YZB3biwQ9Wqw+QyNLAh/Q\r\nnS4/Wwb8qC9QuyEgcCju5lsCaPEXZiZqtPVxZd0CgYEAshBG31yZjO0zG1TZUwfy\r\nfN3euc8mRgZpSdXIHiS5NSyg7Zr8ZcUSID8jAkJiQ3n3OiAsuq1MGQ6kNa582kLT\r\nFPQdI9Ea8ahyDbkNR0gAY9xbM2kg/Gnro1PorH9PTKE0ekSodKk1UUyNrg4DBAwn\r\nqE6E3ebHXt/2WmqIbUD653ECgYBQCC8EAQNX3AFegPd1GGxU33Lz4tchJ4kMCNU0\r\nN2NZh9VCr3nTYjdTbxsXU8YP44CCKFG2/zAO4kymyiaFAWEOn5P7irGF/JExrjt4\r\nibGy5lFLEq/HiPtBjhgsl1O0nXlwUFzd7OLghXc+8CPUJaz5w42unqT3PBJa40c3\r\nQcIPdQKBgBnSb7BcDAAQ/Qx9juo/RKpvhyeqlnp0GzPSQjvtWi9dQRIu9Pe7luHc\r\nm1Img1EO1OyE3dis/rLaDsAa2AKu1Yx6h85EmNjavBqP9wqmFa0NIQQH8fvzKY3/\r\nP8IHY6009aoamLqYaexvrkHVq7fFKiI6k8myMJ6qblVNFv14+KXU\r\n-----END RSA PRIVATE KEY-----" # noqa: E501 diff --git a/tests/plugin_github_group.py b/tests/plugin_github_group.py new file mode 100644 index 000000000..b7f4ced60 --- /dev/null +++ b/tests/plugin_github_group.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +# +# A library that provides a Python interface to the Telegram Bot API +# Copyright (C) 2015-2018 +# Leandro Toledo de Souza +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser Public License for more details. +# +# You should have received a copy of the GNU Lesser Public License +# along with this program. If not, see [http://www.gnu.org/licenses/]. +import _pytest.config +import pytest + +fold_plugins = {'_cov': 'Coverage report', 'flaky': 'Flaky report'} + + +def terminal_summary_wrapper(original, plugin_name): + text = fold_plugins[plugin_name] + + def pytest_terminal_summary(terminalreporter): + terminalreporter.write('##[group] {}\n'.format(text)) + original(terminalreporter) + terminalreporter.write('##[endgroup]') + + return pytest_terminal_summary + + +@pytest.mark.trylast +def pytest_configure(config): + for hookimpl in config.pluginmanager.hook.pytest_terminal_summary._nonwrappers: + if hookimpl.plugin_name in fold_plugins.keys(): + hookimpl.function = terminal_summary_wrapper(hookimpl.function, + hookimpl.plugin_name) + + +terminal = None +previous_name = None + + +def _get_name(location): + if location[0].startswith('tests/'): + return location[0][6:] + return location[0] + + +@pytest.mark.trylast +def pytest_itemcollected(item): + item._nodeid = item._nodeid.split('::', 1)[1] + + +@pytest.hookimpl(hookwrapper=True, tryfirst=True) +def pytest_runtest_protocol(item, nextitem): + # This is naughty but pytests' own plugins does something similar too, so who cares + global terminal + if terminal is None: + terminal = _pytest.config.create_terminal_writer(item.config) + + global previous_name + + name = _get_name(item.location) + + if previous_name is None or previous_name != name: + previous_name = name + terminal.write('\n##[group] {}'.format(name)) + + yield + + if nextitem is None or _get_name(nextitem.location) != name: + terminal.write('\n##[endgroup]') diff --git a/tests/test_bot.py b/tests/test_bot.py index a45b71a18..80a5c7955 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -114,13 +114,14 @@ class TestBot(object): @pytest.mark.timeout(10) def test_delete_message(self, bot, chat_id): message = bot.send_message(chat_id, text='will be deleted') + time.sleep(2) assert bot.delete_message(chat_id=chat_id, message_id=message.message_id) is True @flaky(3, 1) @pytest.mark.timeout(10) def test_delete_message_old_message(self, bot, chat_id): - with pytest.raises(TelegramError, match='Message to delete not found'): + with pytest.raises(BadRequest): # Considering that the first message is old enough bot.delete_message(chat_id=chat_id, message_id=1) diff --git a/tests/test_conversationhandler.py b/tests/test_conversationhandler.py index 269cb895d..d82d10cbe 100644 --- a/tests/test_conversationhandler.py +++ b/tests/test_conversationhandler.py @@ -613,6 +613,53 @@ class TestConversationHandler(object): assert handler.conversations.get((self.group.id, user1.id)) is None assert not self.is_timeout + def test_conversation_timeout_cancel_conflict(self, dp, bot, user1): + # Start state machine, wait half the timeout, + # then call a callback that takes more than the timeout + # t=0 /start (timeout=.5) + # t=.25 /slowbrew (sleep .5) + # | t=.5 original timeout (should not execute) + # | t=.75 /slowbrew returns (timeout=1.25) + # t=1.25 timeout + + def slowbrew(_bot, update): + sleep(0.25) + # Let's give to the original timeout a chance to execute + dp.job_queue.tick() + sleep(0.25) + # By returning None we do not override the conversation state so + # we can see if the timeout has been executed + + states = self.states + states[self.THIRSTY].append(CommandHandler('slowbrew', slowbrew)) + states.update({ConversationHandler.TIMEOUT: [ + MessageHandler(None, self.passout2) + ]}) + + handler = ConversationHandler(entry_points=self.entry_points, states=states, + fallbacks=self.fallbacks, conversation_timeout=0.5) + dp.add_handler(handler) + + # CommandHandler timeout + message = Message(0, user1, None, self.group, text='/start', + entities=[MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, + length=len('/start'))], + bot=bot) + dp.process_update(Update(update_id=0, message=message)) + sleep(0.25) + dp.job_queue.tick() + message.text = '/slowbrew' + message.entities[0].length = len('/slowbrew') + dp.process_update(Update(update_id=0, message=message)) + dp.job_queue.tick() + assert handler.conversations.get((self.group.id, user1.id)) is not None + assert not self.is_timeout + + sleep(0.5) + dp.job_queue.tick() + assert handler.conversations.get((self.group.id, user1.id)) is None + assert self.is_timeout + def test_per_message_warning_is_only_shown_once(self, recwarn): ConversationHandler( entry_points=self.entry_points, diff --git a/tests/test_filters.py b/tests/test_filters.py index a91b10f38..17f9e9714 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -604,6 +604,16 @@ class TestFilters(object): update.message.from_user.language_code = 'da' assert f(update) + def test_msg_in_filter(self, update): + update.message.text = 'test' + update.message.caption = 'caption' + + assert Filters.msg_in(['test'])(update) + assert Filters.msg_in(['caption'], caption=True)(update) + + assert not Filters.msg_in(['test'], caption=True)(update) + assert not Filters.msg_in(['caption'])(update) + def test_and_filters(self, update): update.message.text = 'test' update.message.forward_date = datetime.datetime.now() diff --git a/tests/test_jobqueue.py b/tests/test_jobqueue.py index f0c7ac405..b4a4985bc 100644 --- a/tests/test_jobqueue.py +++ b/tests/test_jobqueue.py @@ -40,6 +40,8 @@ def job_queue(bot, _dp): @pytest.mark.skipif(os.getenv('APPVEYOR'), reason="On Appveyor precise timings are not accurate.") +@pytest.mark.skipif(os.getenv('GITHUB_ACTIONS', False) and os.name == 'nt', + reason="On windows precise timings are not accurate.") @flaky(10, 1) # Timings aren't quite perfect class TestJobQueue(object): result = 0 diff --git a/tests/test_messagequeue.py b/tests/test_messagequeue.py index 0ed7831a8..60260ffc2 100644 --- a/tests/test_messagequeue.py +++ b/tests/test_messagequeue.py @@ -26,6 +26,8 @@ import telegram.ext.messagequeue as mq @pytest.mark.skipif(os.getenv('APPVEYOR'), reason="On Appveyor precise timings are not accurate.") +@pytest.mark.skipif(os.getenv('GITHUB_ACTIONS', False) and os.name == 'nt', + reason="On windows precise timings are not accurate.") class TestDelayQueue(object): N = 128 burst_limit = 30 diff --git a/tests/test_meta.py b/tests/test_meta.py index 9c6abf13f..512541087 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -17,8 +17,6 @@ # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. import os -import sys -from platform import python_implementation import pytest @@ -30,17 +28,12 @@ def call_pre_commit_hook(hook_id): @pytest.mark.nocoverage @pytest.mark.parametrize('hook_id', argvalues=('yapf', 'flake8', 'pylint')) -@pytest.mark.skipif(not (os.getenv('TRAVIS') or os.getenv('APPVEYOR')), reason='Not running in CI') -@pytest.mark.skipif(not sys.version_info[:2] == (3, 6) or python_implementation() != 'CPython', - reason='Only running pre-commit-hooks on newest tested python version, ' - 'as they are slow and consistent across platforms.') +@pytest.mark.skipif(not os.getenv('TEST_PRE_COMMIT', False), reason='TEST_PRE_COMMIT not enabled') def test_pre_commit_hook(hook_id): assert call_pre_commit_hook(hook_id) == 0 # pragma: no cover @pytest.mark.nocoverage -@pytest.mark.skipif( - not sys.version_info[:2] in ((3, 6), (2, 7)) or python_implementation() != 'CPython', - reason='Only testing build on 2.7 and 3.6') +@pytest.mark.skipif(not os.getenv('TEST_BUILD', False), reason='TEST_BUILD not enabled') def test_build(): assert os.system('python setup.py bdist_dumb') == 0 # pragma: no cover