From 87afd98e02774023a84b1176fbd78558cfe85ee1 Mon Sep 17 00:00:00 2001 From: Eldinnie Date: Tue, 22 May 2018 21:44:20 +0200 Subject: [PATCH] CommandHandler overhaul and PrefixHandler added (#1114) * Commandhandler reworked * Make CommandHandler strict Only register valid botcommands, else raise ValueError * Add PrefixHandler * declare encoding on test_commandhandler * Fix some tests dependend on CommandHandler * CR changes * small docfix. * Test all possibilities for PrefixHandler --- docs/source/telegram.ext.prefixhandler.rst | 6 + docs/source/telegram.ext.rst | 1 + telegram/ext/__init__.py | 4 +- telegram/ext/callbackcontext.py | 6 +- telegram/ext/callbackqueryhandler.py | 2 +- telegram/ext/choseninlineresulthandler.py | 2 +- telegram/ext/commandhandler.py | 224 ++++++++++++--- telegram/ext/handler.py | 2 +- telegram/ext/inlinequeryhandler.py | 2 +- telegram/ext/messagehandler.py | 2 +- telegram/ext/precheckoutqueryhandler.py | 2 +- telegram/ext/regexhandler.py | 2 +- telegram/ext/shippingqueryhandler.py | 2 +- tests/test_commandhandler.py | 317 ++++++++++++++++++--- tests/test_conversationhandler.py | 72 ++++- tests/test_dispatcher.py | 26 +- 16 files changed, 575 insertions(+), 97 deletions(-) create mode 100644 docs/source/telegram.ext.prefixhandler.rst diff --git a/docs/source/telegram.ext.prefixhandler.rst b/docs/source/telegram.ext.prefixhandler.rst new file mode 100644 index 000000000..18fb2be14 --- /dev/null +++ b/docs/source/telegram.ext.prefixhandler.rst @@ -0,0 +1,6 @@ +telegram.ext.PrefixHandler +=========================== + +.. autoclass:: telegram.ext.PrefixHandler + :members: + :show-inheritance: diff --git a/docs/source/telegram.ext.rst b/docs/source/telegram.ext.rst index 09647a1bd..e43616370 100644 --- a/docs/source/telegram.ext.rst +++ b/docs/source/telegram.ext.rst @@ -25,6 +25,7 @@ Handlers telegram.ext.inlinequeryhandler telegram.ext.messagehandler telegram.ext.precheckoutqueryhandler + telegram.ext.prefixhandler telegram.ext.regexhandler telegram.ext.shippingqueryhandler telegram.ext.stringcommandhandler diff --git a/telegram/ext/__init__.py b/telegram/ext/__init__.py index 4538567dc..bbc5d4e54 100644 --- a/telegram/ext/__init__.py +++ b/telegram/ext/__init__.py @@ -25,7 +25,7 @@ from .jobqueue import JobQueue, Job from .updater import Updater from .callbackqueryhandler import CallbackQueryHandler from .choseninlineresulthandler import ChosenInlineResultHandler -from .commandhandler import CommandHandler +from .commandhandler import CommandHandler, PrefixHandler from .inlinequeryhandler import InlineQueryHandler from .messagehandler import MessageHandler from .filters import BaseFilter, Filters @@ -44,4 +44,4 @@ __all__ = ('Dispatcher', 'JobQueue', 'Job', 'Updater', 'CallbackQueryHandler', 'MessageHandler', 'BaseFilter', 'Filters', 'RegexHandler', 'StringCommandHandler', 'StringRegexHandler', 'TypeHandler', 'ConversationHandler', 'PreCheckoutQueryHandler', 'ShippingQueryHandler', 'MessageQueue', 'DelayQueue', - 'DispatcherHandlerStop', 'run_async', 'CallbackContext') + 'DispatcherHandlerStop', 'run_async', 'CallbackContext', 'PrefixHandler') diff --git a/telegram/ext/callbackcontext.py b/telegram/ext/callbackcontext.py index e36f877c4..bf502dc54 100644 --- a/telegram/ext/callbackcontext.py +++ b/telegram/ext/callbackcontext.py @@ -37,9 +37,9 @@ class CallbackContext(object): regex-supported handler, this will contain the object returned from ``re.match(pattern, string)``. args (List[:obj:`str`], optional): Arguments passed to a command if the associated update - is handled by :class:`telegram.ext.CommandHandler` or - :class:`telegram.ext.StringCommandHandler`. It contains a list of the words in the text - after the command, using any whitespace string as a delimiter. + is handled by :class:`telegram.ext.CommandHandler`, :class:`telegram.ext.PrefixHandler` + or :class:`telegram.ext.StringCommandHandler`. It contains a list of the words in the + text after the command, using any whitespace string as a delimiter. error (:class:`telegram.TelegramError`, optional): The Telegram error that was raised. Only present when passed to a error handler registered with :attr:`telegram.ext.Dispatcher.add_error_handler`. diff --git a/telegram/ext/callbackqueryhandler.py b/telegram/ext/callbackqueryhandler.py index 03135d8e9..88c23d7f6 100644 --- a/telegram/ext/callbackqueryhandler.py +++ b/telegram/ext/callbackqueryhandler.py @@ -50,7 +50,7 @@ class CallbackQueryHandler(Handler): Note: :attr:`pass_user_data` and :attr:`pass_chat_data` determine whether a ``dict`` you - can use to keep any data in will be sent to the :attr:`callback` function.. Related to + can use to keep any data in will be sent to the :attr:`callback` function. Related to either the user or the chat that the update was sent in. For each update from the same user or in the same chat, it will be the same ``dict``. diff --git a/telegram/ext/choseninlineresulthandler.py b/telegram/ext/choseninlineresulthandler.py index 09e327410..3ed2528d3 100644 --- a/telegram/ext/choseninlineresulthandler.py +++ b/telegram/ext/choseninlineresulthandler.py @@ -38,7 +38,7 @@ class ChosenInlineResultHandler(Handler): Note: :attr:`pass_user_data` and :attr:`pass_chat_data` determine whether a ``dict`` you - can use to keep any data in will be sent to the :attr:`callback` function.. Related to + can use to keep any data in will be sent to the :attr:`callback` function. Related to either the user or the chat that the update was sent in. For each update from the same user or in the same chat, it will be the same ``dict``. diff --git a/telegram/ext/commandhandler.py b/telegram/ext/commandhandler.py index 2f58ebf9a..508661730 100644 --- a/telegram/ext/commandhandler.py +++ b/telegram/ext/commandhandler.py @@ -16,10 +16,12 @@ # # You should have received a copy of the GNU Lesser Public License # along with this program. If not, see [http://www.gnu.org/licenses/]. -"""This module contains the CommandHandler class.""" +"""This module contains the CommandHandler and PrefixHandler classes.""" +import re + from future.utils import string_types -from telegram import Update +from telegram import Update, MessageEntity from .handler import Handler @@ -27,9 +29,177 @@ class CommandHandler(Handler): """Handler class to handle Telegram commands. Commands are Telegram messages that start with ``/``, optionally followed by an ``@`` and the - bot's name and/or some additional text. + bot's name and/or some additional text. The handler will add a ``list`` to the + :class:`CallbackContext` named :attr:`CallbackContext.args`. It will contain a list of strings, + which is the text following the command split on single or consecutive whitespace characters. Attributes: + command (:obj:`str` | List[:obj:`str`]): The command or list of commands this handler + should listen for. Limitations are the same as described here + https://core.telegram.org/bots#commands + callback (:obj:`callable`): The callback function for this handler. + filters (:class:`telegram.ext.BaseFilter`): Optional. Only allow updates with these + Filters. + allow_edited (:obj:`bool`): Determines Whether the handler should also accept + edited messages. + pass_args (:obj:`bool`): Determines whether the handler should be passed + ``args``. + pass_update_queue (:obj:`bool`): Determines whether ``update_queue`` will be + passed to the callback function. + pass_job_queue (:obj:`bool`): Determines whether ``job_queue`` will be passed to + the callback function. + pass_user_data (:obj:`bool`): Determines whether ``user_data`` will be passed to + the callback function. + pass_chat_data (:obj:`bool`): Determines whether ``chat_data`` will be passed to + the callback function. + + Note: + :attr:`pass_user_data` and :attr:`pass_chat_data` determine whether a ``dict`` you + can use to keep any data in will be sent to the :attr:`callback` function. Related to + either the user or the chat that the update was sent in. For each update from the same user + or in the same chat, it will be the same ``dict``. + + Note that this is DEPRECATED, and you should use context based callbacks. See + https://git.io/vp113 for more info. + + Args: + command (:obj:`str` | List[:obj:`str`]): The command or list of commands this handler + should listen for. Limitations are the same as described here + https://core.telegram.org/bots#commands + callback (:obj:`callable`): The callback function for this handler. Will be called when + :attr:`check_update` has determined that an update should be processed by this handler. + Callback signature for context based API: + + ``def callback(update: Update, context: CallbackContext)`` + + The return value of the callback is usually ignored except for the special case of + :class:`telegram.ext.ConversationHandler`. + filters (:class:`telegram.ext.BaseFilter`, optional): A filter inheriting from + :class:`telegram.ext.filters.BaseFilter`. Standard filters can be found in + :class:`telegram.ext.filters.Filters`. Filters can be combined using bitwise + operators (& for and, | for or, ~ for not). + allow_edited (:obj:`bool`, optional): Determines whether the handler should also accept + edited messages. Default is ``False``. + pass_args (:obj:`bool`, optional): Determines whether the handler should be passed the + arguments passed to the command as a keyword argument called ``args``. It will contain + a list of strings, which is the text following the command split on single or + consecutive whitespace characters. Default is ``False`` + DEPRECATED: Please switch to context based callbacks. + pass_update_queue (:obj:`bool`, optional): If set to ``True``, a keyword argument called + ``update_queue`` will be passed to the callback function. It will be the ``Queue`` + instance used by the :class:`telegram.ext.Updater` and :class:`telegram.ext.Dispatcher` + that contains new updates which can be used to insert updates. Default is ``False``. + DEPRECATED: Please switch to context based callbacks. + pass_job_queue (:obj:`bool`, optional): If set to ``True``, a keyword argument called + ``job_queue`` will be passed to the callback function. It will be a + :class:`telegram.ext.JobQueue` instance created by the :class:`telegram.ext.Updater` + which can be used to schedule new jobs. Default is ``False``. + DEPRECATED: Please switch to context based callbacks. + pass_user_data (:obj:`bool`, optional): If set to ``True``, a keyword argument called + ``user_data`` will be passed to the callback function. Default is ``False``. + DEPRECATED: Please switch to context based callbacks. + pass_chat_data (:obj:`bool`, optional): If set to ``True``, a keyword argument called + ``chat_data`` will be passed to the callback function. Default is ``False``. + DEPRECATED: Please switch to context based callbacks. + + Raises: + ValueError - when command is too long or has illegal chars. + """ + + def __init__(self, + command, + callback, + filters=None, + allow_edited=False, + pass_args=False, + pass_update_queue=False, + pass_job_queue=False, + pass_user_data=False, + pass_chat_data=False): + super(CommandHandler, self).__init__( + callback, + pass_update_queue=pass_update_queue, + pass_job_queue=pass_job_queue, + pass_user_data=pass_user_data, + pass_chat_data=pass_chat_data) + + if isinstance(command, string_types): + self.command = [command.lower()] + else: + self.command = [x.lower() for x in command] + for comm in self.command: + if not re.match(r'^[\da-z_]{1,32}$', comm): + raise ValueError('Command is not a valid bot command') + + self.filters = filters + self.allow_edited = allow_edited + self.pass_args = pass_args + + def check_update(self, update): + """Determines whether an update should be passed to this handlers :attr:`callback`. + + Args: + update (:class:`telegram.Update`): Incoming telegram update. + + Returns: + :obj:`bool` + + """ + if (isinstance(update, Update) and + (update.message or update.edited_message and self.allow_edited)): + message = update.effective_message + + if (message.entities and message.entities[0].type == MessageEntity.BOT_COMMAND and + message.entities[0].offset == 0): + command = message.text[1:message.entities[0].length] + args = message.text.split()[1:] + command = command.split('@') + command.append(message.bot.username) + + if not (command[0].lower() in self.command and + command[1].lower() == message.bot.username.lower()): + return None + + if self.filters is None or self.filters(message): + return args + + def collect_optional_args(self, dispatcher, update=None, check_result=None): + optional_args = super(CommandHandler, self).collect_optional_args(dispatcher, update) + if self.pass_args: + optional_args['args'] = check_result + return optional_args + + def collect_additional_context(self, context, update, dispatcher, check_result): + context.args = check_result + + +class PrefixHandler(CommandHandler): + """Handler class to handle custom prefix commands + + This is a intermediate handler between :class:`MessageHandler` and :class:`CommandHandler`. + It supports configurable commands with the same options as CommandHandler. It will respond to + every combination of :attr:`prefix` and :attr:`command`. It will add a ``list`` to the + :class:`CallbackContext` named :attr:`CallbackContext.args`. It will contain a list of strings, + which is the text following the command split on single or consecutive whitespace characters. + + Examples:: + + Single prefix and command: + + PrefixHandler('!', 'test', callback) will respond to '!test'. + + Multiple prefixes, single command: + + PrefixHandler(['!', '#'], 'test', callback) will respond to '!test' and + '#test'. + + Miltiple prefixes and commands: + + PrefixHandler(['!', '#'], ['test', 'help`], callback) will respond to '!test', + '#test', '!help' and '#help'. + + Attributes: + prefix (:obj:`str` | List[:obj:`str`]): The prefix(es) that will precede :attr:`command`. command (:obj:`str` | List[:obj:`str`]): The command or list of commands this handler should listen for. callback (:obj:`callable`): The callback function for this handler. @@ -50,7 +220,7 @@ class CommandHandler(Handler): Note: :attr:`pass_user_data` and :attr:`pass_chat_data` determine whether a ``dict`` you - can use to keep any data in will be sent to the :attr:`callback` function.. Related to + can use to keep any data in will be sent to the :attr:`callback` function. Related to either the user or the chat that the update was sent in. For each update from the same user or in the same chat, it will be the same ``dict``. @@ -58,6 +228,7 @@ class CommandHandler(Handler): https://git.io/vp113 for more info. Args: + prefix (:obj:`str` | List[:obj:`str`]): The prefix(es) that will precede :attr:`command`. command (:obj:`str` | List[:obj:`str`]): The command or list of commands this handler should listen for. callback (:obj:`callable`): The callback function for this handler. Will be called when @@ -99,6 +270,7 @@ class CommandHandler(Handler): """ def __init__(self, + prefix, command, callback, filters=None, @@ -108,20 +280,23 @@ class CommandHandler(Handler): pass_job_queue=False, pass_user_data=False, pass_chat_data=False): - super(CommandHandler, self).__init__( - callback, + + super(PrefixHandler, self).__init__( + 'nocommand', callback, filters=filters, allow_edited=allow_edited, pass_args=pass_args, pass_update_queue=pass_update_queue, pass_job_queue=pass_job_queue, pass_user_data=pass_user_data, pass_chat_data=pass_chat_data) + if isinstance(prefix, string_types): + self.prefix = [prefix.lower()] + else: + self.prefix = prefix if isinstance(command, string_types): self.command = [command.lower()] else: - self.command = [x.lower() for x in command] - self.filters = filters - self.allow_edited = allow_edited - self.pass_args = pass_args + self.command = command + self.command = [x.lower() + y.lower() for x in self.prefix for y in self.command] def check_update(self, update): """Determines whether an update should be passed to this handlers :attr:`callback`. @@ -135,27 +310,10 @@ class CommandHandler(Handler): """ if (isinstance(update, Update) and (update.message or update.edited_message and self.allow_edited)): - message = update.message or update.edited_message + message = update.effective_message - if message.text and message.text.startswith('/') and len(message.text) > 1: - first_word = message.text_html.split(None, 1)[0] - if len(first_word) > 1 and first_word.startswith('/'): - command = first_word[1:].split('@') - command.append( - message.bot.username) # in case the command was sent without a username - - if not (command[0].lower() in self.command - and command[1].lower() == message.bot.username.lower()): - return None - - if self.filters is None or self.filters(message): - return message.text.split()[1:] - - def collect_optional_args(self, dispatcher, update=None, check_result=None): - optional_args = super(CommandHandler, self).collect_optional_args(dispatcher, update) - if self.pass_args: - optional_args['args'] = check_result - return optional_args - - def collect_additional_context(self, context, update, dispatcher, check_result): - context.args = check_result + text_list = message.text.split() + if text_list[0].lower() not in self.command: + return None + if self.filters is None or self.filters(message): + return text_list[1:] diff --git a/telegram/ext/handler.py b/telegram/ext/handler.py index 3000ba95f..90ef471bc 100644 --- a/telegram/ext/handler.py +++ b/telegram/ext/handler.py @@ -36,7 +36,7 @@ class Handler(object): Note: :attr:`pass_user_data` and :attr:`pass_chat_data` determine whether a ``dict`` you - can use to keep any data in will be sent to the :attr:`callback` function.. Related to + can use to keep any data in will be sent to the :attr:`callback` function. Related to either the user or the chat that the update was sent in. For each update from the same user or in the same chat, it will be the same ``dict``. diff --git a/telegram/ext/inlinequeryhandler.py b/telegram/ext/inlinequeryhandler.py index 19a90a10c..6c7c4c1e4 100644 --- a/telegram/ext/inlinequeryhandler.py +++ b/telegram/ext/inlinequeryhandler.py @@ -49,7 +49,7 @@ class InlineQueryHandler(Handler): Note: :attr:`pass_user_data` and :attr:`pass_chat_data` determine whether a ``dict`` you - can use to keep any data in will be sent to the :attr:`callback` function.. Related to + can use to keep any data in will be sent to the :attr:`callback` function. Related to either the user or the chat that the update was sent in. For each update from the same user or in the same chat, it will be the same ``dict``. diff --git a/telegram/ext/messagehandler.py b/telegram/ext/messagehandler.py index 8cd33d82e..0298d2eff 100644 --- a/telegram/ext/messagehandler.py +++ b/telegram/ext/messagehandler.py @@ -50,7 +50,7 @@ class MessageHandler(Handler): Note: :attr:`pass_user_data` and :attr:`pass_chat_data` determine whether a ``dict`` you - can use to keep any data in will be sent to the :attr:`callback` function.. Related to + can use to keep any data in will be sent to the :attr:`callback` function. Related to either the user or the chat that the update was sent in. For each update from the same user or in the same chat, it will be the same ``dict``. diff --git a/telegram/ext/precheckoutqueryhandler.py b/telegram/ext/precheckoutqueryhandler.py index 07f6bef30..8db00f8fa 100644 --- a/telegram/ext/precheckoutqueryhandler.py +++ b/telegram/ext/precheckoutqueryhandler.py @@ -38,7 +38,7 @@ class PreCheckoutQueryHandler(Handler): Note: :attr:`pass_user_data` and :attr:`pass_chat_data` determine whether a ``dict`` you - can use to keep any data in will be sent to the :attr:`callback` function.. Related to + can use to keep any data in will be sent to the :attr:`callback` function. Related to either the user or the chat that the update was sent in. For each update from the same user or in the same chat, it will be the same ``dict``. diff --git a/telegram/ext/regexhandler.py b/telegram/ext/regexhandler.py index 9dbbbac65..cf7f7ff0a 100644 --- a/telegram/ext/regexhandler.py +++ b/telegram/ext/regexhandler.py @@ -53,7 +53,7 @@ class RegexHandler(Handler): Note: :attr:`pass_user_data` and :attr:`pass_chat_data` determine whether a ``dict`` you - can use to keep any data in will be sent to the :attr:`callback` function.. Related to + can use to keep any data in will be sent to the :attr:`callback` function. Related to either the user or the chat that the update was sent in. For each update from the same user or in the same chat, it will be the same ``dict``. diff --git a/telegram/ext/shippingqueryhandler.py b/telegram/ext/shippingqueryhandler.py index 8e07d4f47..a23647564 100644 --- a/telegram/ext/shippingqueryhandler.py +++ b/telegram/ext/shippingqueryhandler.py @@ -38,7 +38,7 @@ class ShippingQueryHandler(Handler): Note: :attr:`pass_user_data` and :attr:`pass_chat_data` determine whether a ``dict`` you - can use to keep any data in will be sent to the :attr:`callback` function.. Related to + can use to keep any data in will be sent to the :attr:`callback` function. Related to either the user or the chat that the update was sent in. For each update from the same user or in the same chat, it will be the same ``dict``. diff --git a/tests/test_commandhandler.py b/tests/test_commandhandler.py index 6264f9d77..beeb35878 100644 --- a/tests/test_commandhandler.py +++ b/tests/test_commandhandler.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- # # A library that provides a Python interface to the Telegram Bot API # Copyright (C) 2015-2018 @@ -21,8 +22,9 @@ from queue import Queue import pytest from telegram import (Message, Update, Chat, Bot, User, CallbackQuery, InlineQuery, - ChosenInlineResult, ShippingQuery, PreCheckoutQuery) -from telegram.ext import CommandHandler, Filters, BaseFilter, CallbackContext, JobQueue + ChosenInlineResult, ShippingQuery, PreCheckoutQuery, MessageEntity) +from telegram.ext import CommandHandler, Filters, BaseFilter, CallbackContext, JobQueue, \ + PrefixHandler message = Message(1, User(1, '', False), None, Chat(1, ''), text='test') @@ -49,7 +51,15 @@ def false_update(request): @pytest.fixture(scope='function') def message(bot): - return Message(1, User(1, '', False), None, Chat(1, ''), bot=bot) + return Message(message_id=1, + from_user=User(id=1, first_name='', is_bot=False), + date=None, + chat=Chat(id=1, type=''), + message='/test', + bot=bot, + entities=[MessageEntity(type=MessageEntity.BOT_COMMAND, + offset=0, + length=len('/test'))]) class TestCommandHandler(object): @@ -117,13 +127,26 @@ class TestCommandHandler(object): check = handler.check_update(Update(0, message)) assert check is None or check is False + message.entities = [] + message.text = '/test' + check = handler.check_update(Update(0, message)) + assert check is None or check is False + + @pytest.mark.parametrize('command', + ['way_too_longcommand1234567yes_way_toooooooLong', 'ïñválídletters', + 'invalid #&* chars'], + ids=['too long', 'invalid letter', 'invalid characters']) + def test_invalid_commands(self, command): + with pytest.raises(ValueError, match='not a valid bot command'): + CommandHandler(command, self.callback_basic) + def test_command_list(self, message): - handler = CommandHandler(['test', 'start'], self.callback_basic) + handler = CommandHandler(['test', 'star'], self.callback_basic) message.text = '/test' check = handler.check_update(Update(0, message)) - message.text = '/start' + message.text = '/star' check = handler.check_update(Update(0, message)) message.text = '/stop' @@ -137,11 +160,14 @@ class TestCommandHandler(object): message.text = '/test' check = handler.check_update(Update(0, message)) assert check is not None and check is not False + check = handler.check_update(Update(0, edited_message=message)) assert check is None or check is False + handler.allow_edited = True check = handler.check_update(Update(0, message)) assert check is not None and check is not False + check = handler.check_update(Update(0, edited_message=message)) assert check is not None and check is not False @@ -149,11 +175,13 @@ class TestCommandHandler(object): handler = CommandHandler('test', self.callback_basic) message.text = '/test@{}'.format(message.bot.username) + message.entities[0].length = len(message.text) check = handler.check_update(Update(0, message)) assert check is not None and check is not False message.text = '/test@otherbot' - assert not handler.check_update(Update(0, message)) + check = handler.check_update(Update(0, message)) + assert check is None or check is False def test_with_filter(self, message): handler = CommandHandler('test', self.callback_basic, Filters.group) @@ -177,11 +205,7 @@ class TestCommandHandler(object): self.test_flag = False message.text = '/test@{}'.format(message.bot.username) - dp.process_update(Update(0, message=message)) - assert self.test_flag - - self.test_flag = False - message.text = '/test one two' + message.entities[0].length = len(message.text) dp.process_update(Update(0, message=message)) assert self.test_flag @@ -190,6 +214,12 @@ class TestCommandHandler(object): dp.process_update(Update(0, message=message)) assert self.test_flag + self.test_flag = False + message.text = '/test one two' + message.entities[0].length = len('/test') + dp.process_update(Update(0, message=message)) + assert self.test_flag + def test_newline(self, dp, message): handler = CommandHandler('test', self.callback_basic) dp.add_handler(handler) @@ -197,31 +227,10 @@ class TestCommandHandler(object): message.text = '/test\nfoobar' check = handler.check_update(Update(0, message)) assert check is not None and check is not False + dp.process_update(Update(0, message)) assert self.test_flag - def test_single_char(self, dp, message): - # Regression test for https://github.com/python-telegram-bot/python-telegram-bot/issues/871 - handler = CommandHandler('test', self.callback_basic) - dp.add_handler(handler) - - message.text = 'a' - check = handler.check_update(Update(0, message)) - assert check is None or check is False - - def test_single_slash(self, dp, message): - # Regression test for https://github.com/python-telegram-bot/python-telegram-bot/issues/871 - handler = CommandHandler('test', self.callback_basic) - dp.add_handler(handler) - - message.text = '/' - check = handler.check_update(Update(0, message)) - assert check is None or check is False - - message.text = '/ test' - check = handler.check_update(Update(0, message)) - assert check is None or check is False - def test_pass_user_or_chat_data(self, dp, message): handler = CommandHandler('test', self.callback_data_1, pass_user_data=True) @@ -295,9 +304,9 @@ class TestCommandHandler(object): test_filter = TestFilter() - handler = CommandHandler('foo', self.callback_basic, + handler = CommandHandler('test', self.callback_basic, filters=test_filter) - message.text = '/bar' + message.text = '/star' check = handler.check_update(Update(0, message=message)) assert check is None or check is False @@ -323,3 +332,241 @@ class TestCommandHandler(object): message.text = '/test one two' cdp.process_update(Update(0, message)) assert self.test_flag + + +par = ['!help', '!test', '#help', '#test', 'mytrig-help', 'mytrig-test'] + + +@pytest.fixture(scope='function', params=par) +def prefixmessage(bot, request): + return Message(message_id=1, + from_user=User(id=1, first_name='', is_bot=False), + date=None, + chat=Chat(id=1, type=''), + text=request.param, + bot=bot) + + +class TestPrefixHandler(object): + test_flag = False + + @pytest.fixture(autouse=True) + def reset(self): + self.test_flag = False + + def callback_basic(self, bot, update): + test_bot = isinstance(bot, Bot) + test_update = isinstance(update, Update) + self.test_flag = test_bot and test_update + + def callback_data_1(self, bot, update, user_data=None, chat_data=None): + self.test_flag = (user_data is not None) or (chat_data is not None) + + def callback_data_2(self, bot, update, user_data=None, chat_data=None): + self.test_flag = (user_data is not None) and (chat_data is not None) + + def callback_queue_1(self, bot, update, job_queue=None, update_queue=None): + self.test_flag = (job_queue is not None) or (update_queue is not None) + + def callback_queue_2(self, bot, update, job_queue=None, update_queue=None): + self.test_flag = (job_queue is not None) and (update_queue is not None) + + def ch_callback_args(self, bot, update, args): + if update.message.text in par: + self.test_flag = len(args) == 0 + else: + self.test_flag = args == ['one', 'two'] + + def callback_context(self, update, context): + self.test_flag = (isinstance(context, CallbackContext) and + isinstance(context.bot, Bot) and + isinstance(update, Update) and + isinstance(context.update_queue, Queue) and + isinstance(context.job_queue, JobQueue) and + isinstance(context.user_data, dict) and + isinstance(context.chat_data, dict) and + isinstance(update.message, Message)) + + def callback_context_args(self, update, context): + self.test_flag = context.args == ['one', 'two'] + + def test_basic(self, dp, prefixmessage): + handler = PrefixHandler(['!', '#', 'mytrig-'], ['help', 'test'], self.callback_basic) + dp.add_handler(handler) + + dp.process_update(Update(0, prefixmessage)) + assert self.test_flag + + prefixmessage.text = 'test' + check = handler.check_update(Update(0, prefixmessage)) + assert check is None or check is False + + prefixmessage.text = '#nocom' + check = handler.check_update(Update(0, prefixmessage)) + assert check is None or check is False + + message.text = 'not !test at start' + check = handler.check_update(Update(0, message)) + assert check is None or check is False + + def test_single_prefix_single_command(self, prefixmessage): + handler = PrefixHandler('!', 'test', self.callback_basic) + + check = handler.check_update(Update(0, prefixmessage)) + if prefixmessage.text in ['!test']: + assert check is not None and check is not False + else: + assert check is None or check is False + + def test_single_prefix_multi_command(self, prefixmessage): + handler = PrefixHandler('!', ['test', 'help'], self.callback_basic) + + check = handler.check_update(Update(0, prefixmessage)) + if prefixmessage.text in ['!test', '!help']: + assert check is not None and check is not False + else: + assert check is None or check is False + + def test_multi_prefix_single_command(self, prefixmessage): + handler = PrefixHandler(['!', '#'], 'test', self.callback_basic) + + check = handler.check_update(Update(0, prefixmessage)) + if prefixmessage.text in ['!test', '#test']: + assert check is not None and check is not False + else: + assert check is None or check is False + + def test_edited(self, prefixmessage): + handler = PrefixHandler(['!', '#', 'mytrig-'], ['help', 'test'], self.callback_basic) + + check = handler.check_update(Update(0, prefixmessage)) + assert check is not None and check is not False + + check = handler.check_update(Update(0, edited_message=prefixmessage)) + assert check is None or check is False + + handler.allow_edited = True + check = handler.check_update(Update(0, prefixmessage)) + assert check is not None and check is not False + + check = handler.check_update(Update(0, edited_message=prefixmessage)) + assert check is not None and check is not False + + def test_with_filter(self, prefixmessage): + handler = PrefixHandler(['!', '#', 'mytrig-'], ['help', 'test'], self.callback_basic, + filters=Filters.group) + + prefixmessage.chat = Chat(-23, 'group') + check = handler.check_update(Update(0, prefixmessage)) + assert check is not None and check is not False + + prefixmessage.chat = Chat(23, 'private') + check = handler.check_update(Update(0, prefixmessage)) + assert check is None or check is False + + def test_pass_args(self, dp, prefixmessage): + handler = PrefixHandler(['!', '#', 'mytrig-'], ['help', 'test'], self.ch_callback_args, + pass_args=True) + dp.add_handler(handler) + + dp.process_update(Update(0, message=prefixmessage)) + assert self.test_flag + + self.test_flag = False + prefixmessage.text += ' one two' + dp.process_update(Update(0, message=prefixmessage)) + assert self.test_flag + + def test_pass_user_or_chat_data(self, dp, prefixmessage): + handler = PrefixHandler(['!', '#', 'mytrig-'], ['help', 'test'], self.callback_data_1, + pass_user_data=True) + dp.add_handler(handler) + + dp.process_update(Update(0, message=prefixmessage)) + assert self.test_flag + + dp.remove_handler(handler) + self.test_flag = False + handler = PrefixHandler(['!', '#', 'mytrig-'], ['help', 'test'], self.callback_data_1, + pass_chat_data=True) + dp.add_handler(handler) + dp.process_update(Update(0, message=prefixmessage)) + assert self.test_flag + + dp.remove_handler(handler) + self.test_flag = False + handler = PrefixHandler(['!', '#', 'mytrig-'], ['help', 'test'], self.callback_data_2, + pass_chat_data=True, pass_user_data=True) + dp.add_handler(handler) + dp.process_update(Update(0, message=prefixmessage)) + assert self.test_flag + + def test_pass_job_or_update_queue(self, dp, prefixmessage): + handler = PrefixHandler(['!', '#', 'mytrig-'], ['help', 'test'], self.callback_queue_1, + pass_job_queue=True) + dp.add_handler(handler) + + dp.process_update(Update(0, message=prefixmessage)) + assert self.test_flag + + dp.remove_handler(handler) + self.test_flag = False + handler = PrefixHandler(['!', '#', 'mytrig-'], ['help', 'test'], self.callback_queue_1, + pass_update_queue=True) + dp.add_handler(handler) + dp.process_update(Update(0, message=prefixmessage)) + assert self.test_flag + + dp.remove_handler(handler) + self.test_flag = False + handler = PrefixHandler(['!', '#', 'mytrig-'], ['help', 'test'], self.callback_queue_2, + pass_job_queue=True, pass_update_queue=True) + dp.add_handler(handler) + dp.process_update(Update(0, message=prefixmessage)) + assert self.test_flag + + def test_other_update_types(self, false_update): + handler = PrefixHandler(['!', '#', 'mytrig-'], ['help', 'test'], self.callback_basic) + check = handler.check_update(false_update) + assert check is None or check is False + + def test_filters_for_wrong_command(self, prefixmessage): + """Filters should not be executed if the command does not match the handler""" + + class TestFilter(BaseFilter): + def __init__(self): + self.tested = False + + def filter(self, message): + self.tested = True + + test_filter = TestFilter() + + handler = PrefixHandler(['!', '#', 'mytrig-'], ['help', 'test'], self.callback_basic, + filters=test_filter) + + prefixmessage.text = '/star' + + check = handler.check_update(Update(0, message=prefixmessage)) + assert check is None or check is False + + assert not test_filter.tested + + def test_context(self, cdp, prefixmessage): + handler = PrefixHandler(['!', '#', 'mytrig-'], ['help', 'test'], self.callback_context) + cdp.add_handler(handler) + + cdp.process_update(Update(0, prefixmessage)) + assert self.test_flag + + def test_context_args(self, cdp, prefixmessage): + handler = PrefixHandler(['!', '#', 'mytrig-'], ['help', 'test'], + self.callback_context_args) + cdp.add_handler(handler) + + cdp.process_update(Update(0, prefixmessage)) + assert not self.test_flag + + prefixmessage.text += ' one two' + cdp.process_update(Update(0, prefixmessage)) + assert self.test_flag diff --git a/tests/test_conversationhandler.py b/tests/test_conversationhandler.py index 2cb2c22c6..ed66f4849 100644 --- a/tests/test_conversationhandler.py +++ b/tests/test_conversationhandler.py @@ -22,7 +22,7 @@ from time import sleep import pytest from telegram import (CallbackQuery, Chat, ChosenInlineResult, InlineQuery, Message, - PreCheckoutQuery, ShippingQuery, Update, User) + PreCheckoutQuery, ShippingQuery, Update, User, MessageEntity) from telegram.ext import (ConversationHandler, CommandHandler, CallbackQueryHandler) @@ -103,22 +103,28 @@ class TestConversationHandler(object): dp.add_handler(handler) # User one, starts the state machine. - message = Message(0, user1, None, self.group, text='/start', bot=bot) + 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)) assert self.current_state[user1.id] == self.THIRSTY # The user is thirsty and wants to brew coffee. message.text = '/brew' + message.entities[0].length = len('/brew') dp.process_update(Update(update_id=0, message=message)) assert self.current_state[user1.id] == self.BREWING # Lets see if an invalid command makes sure, no state is changed. message.text = '/nothing' + message.entities[0].length = len('/nothing') dp.process_update(Update(update_id=0, message=message)) assert self.current_state[user1.id] == self.BREWING # Lets see if the state machine still works by pouring coffee. message.text = '/pourCoffee' + message.entities[0].length = len('/pourCoffee') dp.process_update(Update(update_id=0, message=message)) assert self.current_state[user1.id] == self.DRINKING @@ -134,13 +140,19 @@ class TestConversationHandler(object): fallbacks=self.fallbacks) dp.add_handler(handler) - message = Message(0, user1, None, self.group, text='/start', bot=bot) + 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)) message.text = '/brew' + message.entities[0].length = len('/brew') dp.process_update(Update(update_id=0, message=message)) message.text = '/pourCoffee' + message.entities[0].length = len('/pourCoffee') dp.process_update(Update(update_id=0, message=message)) message.text = '/end' + message.entities[0].length = len('/end') with caplog.at_level(logging.ERROR): dp.process_update(Update(update_id=0, message=message)) assert len(caplog.records) == 0 @@ -154,23 +166,29 @@ class TestConversationHandler(object): dp.add_handler(handler) # first check if fallback will not trigger start when not started - message = Message(0, user1, None, self.group, text='/eat', bot=bot) + message = Message(0, user1, None, self.group, text='/eat', + entities=[MessageEntity(type=MessageEntity.BOT_COMMAND, + offset=0, length=len('/eat'))], + bot=bot) dp.process_update(Update(update_id=0, message=message)) with pytest.raises(KeyError): self.current_state[user1.id] # User starts the state machine. message.text = '/start' + message.entities[0].length = len('/start') dp.process_update(Update(update_id=0, message=message)) assert self.current_state[user1.id] == self.THIRSTY # The user is thirsty and wants to brew coffee. message.text = '/brew' + message.entities[0].length = len('/brew') dp.process_update(Update(update_id=0, message=message)) assert self.current_state[user1.id] == self.BREWING # Now a fallback command is issued message.text = '/eat' + message.entities[0].length = len('/eat') dp.process_update(Update(update_id=0, message=message)) assert self.current_state[user1.id] == self.THIRSTY @@ -183,17 +201,22 @@ class TestConversationHandler(object): dp.add_handler(handler) # User one, starts the state machine. - message = Message(0, user1, None, self.group, text='/start', bot=bot) + 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)) # The user is thirsty and wants to brew coffee. message.text = '/brew' + message.entities[0].length = len('/brew') dp.process_update(Update(update_id=0, message=message)) # Let's now verify that for another user, who did not start yet, # the state will be changed because they are in the same group. message.from_user = user2 message.text = '/pourCoffee' + message.entities[0].length = len('/pourCoffee') dp.process_update(Update(update_id=0, message=message)) assert handler.conversations[(self.group.id,)] == self.DRINKING @@ -207,17 +230,22 @@ class TestConversationHandler(object): dp.add_handler(handler) # User one, starts the state machine. - message = Message(0, user1, None, self.group, text='/start', bot=bot) + 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)) # The user is thirsty and wants to brew coffee. message.text = '/brew' + message.entities[0].length = len('/brew') dp.process_update(Update(update_id=0, message=message)) # Let's now verify that for the same user in a different group, the state will still be # updated message.chat = self.second_group message.text = '/pourCoffee' + message.entities[0].length = len('/pourCoffee') dp.process_update(Update(update_id=0, message=message)) assert handler.conversations[(user1.id,)] == self.DRINKING @@ -266,7 +294,10 @@ class TestConversationHandler(object): dp.add_handler(handler) # User starts the state machine and immediately ends it. - message = Message(0, user1, None, self.group, text='/start', bot=bot) + 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)) assert len(handler.conversations) == 0 @@ -280,13 +311,17 @@ class TestConversationHandler(object): # User starts the state machine with an async function that immediately ends the # conversation. Async results are resolved when the users state is queried next time. - message = Message(0, user1, None, self.group, text='/start', bot=bot) + message = Message(0, user1, None, self.group, text='/start', + entities=[MessageEntity(type=MessageEntity.BOT_COMMAND, + offset=0, length=len('/start'))], + bot=bot) dp.update_queue.put(Update(update_id=0, message=message)) sleep(.1) # Assert that the Promise has been accepted as the new state assert len(handler.conversations) == 1 message.text = 'resolve promise pls' + message.entities[0].length = len('resolve promise pls') dp.update_queue.put(Update(update_id=0, message=message)) sleep(.1) # Assert that the Promise has been resolved and the conversation ended. @@ -329,7 +364,10 @@ class TestConversationHandler(object): dp.add_handler(handler) # Start state machine, then reach timeout - message = Message(0, user1, None, self.group, text='/start', bot=bot) + 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)) assert handler.conversations.get((self.group.id, user1.id)) == self.THIRSTY sleep(0.5) @@ -340,6 +378,7 @@ class TestConversationHandler(object): dp.process_update(Update(update_id=1, message=message)) assert handler.conversations.get((self.group.id, user1.id)) == self.THIRSTY message.text = '/brew' + message.entities[0].length = len('/brew') dp.job_queue.tick() dp.process_update(Update(update_id=2, message=message)) assert handler.conversations.get((self.group.id, user1.id)) == self.BREWING @@ -359,19 +398,24 @@ class TestConversationHandler(object): # t=.6 /pourCoffee (timeout=1.1) # t=.75 second timeout # t=1.1 actual timeout - message = Message(0, user1, None, self.group, text='/start', bot=bot) + 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)) assert handler.conversations.get((self.group.id, user1.id)) == self.THIRSTY sleep(0.25) # t=.25 dp.job_queue.tick() assert handler.conversations.get((self.group.id, user1.id)) == self.THIRSTY message.text = '/brew' + message.entities[0].length = len('/brew') dp.process_update(Update(update_id=0, message=message)) assert handler.conversations.get((self.group.id, user1.id)) == self.BREWING sleep(0.35) # t=.6 dp.job_queue.tick() assert handler.conversations.get((self.group.id, user1.id)) == self.BREWING message.text = '/pourCoffee' + message.entities[0].length = len('/pourCoffee') dp.process_update(Update(update_id=0, message=message)) assert handler.conversations.get((self.group.id, user1.id)) == self.DRINKING sleep(.4) # t=1 @@ -387,15 +431,21 @@ class TestConversationHandler(object): dp.add_handler(handler) # Start state machine, do something as second user, then reach timeout - message = Message(0, user1, None, self.group, text='/start', bot=bot) + 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)) assert handler.conversations.get((self.group.id, user1.id)) == self.THIRSTY message.text = '/brew' + message.entities[0].length = len('/brew') + message.entities[0].length = len('/brew') message.from_user = user2 dp.job_queue.tick() dp.process_update(Update(update_id=0, message=message)) assert handler.conversations.get((self.group.id, user2.id)) is None message.text = '/start' + message.entities[0].length = len('/start') dp.job_queue.tick() dp.process_update(Update(update_id=0, message=message)) assert handler.conversations.get((self.group.id, user2.id)) == self.THIRSTY diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index bb31e3f5a..601527f89 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -23,7 +23,7 @@ from time import sleep import pytest -from telegram import TelegramError, Message, User, Chat, Update, Bot +from telegram import TelegramError, Message, User, Chat, Update, Bot, MessageEntity from telegram.ext import MessageHandler, Filters, CommandHandler, CallbackContext, JobQueue from telegram.ext.dispatcher import run_async, Dispatcher, DispatcherHandlerStop from telegram.utils.deprecate import TelegramDeprecationWarning @@ -227,7 +227,11 @@ class TestDispatcher(object): passed.append('error') passed.append(e) - update = Update(1, message=Message(1, None, None, None, text='/start', bot=bot)) + update = Update(1, message=Message(1, None, None, None, text='/start', + entities=[MessageEntity(type=MessageEntity.BOT_COMMAND, + offset=0, + length=len('/start'))], + bot=bot)) # If Stop raised handlers in other groups should not be called. passed = [] @@ -254,7 +258,11 @@ class TestDispatcher(object): passed.append('error') passed.append(e) - update = Update(1, message=Message(1, None, None, None, text='/start', bot=bot)) + update = Update(1, message=Message(1, None, None, None, text='/start', + entities=[MessageEntity(type=MessageEntity.BOT_COMMAND, + offset=0, + length=len('/start'))], + bot=bot)) # If an unhandled exception was caught, no further handlers from the same group should be # called. @@ -284,7 +292,11 @@ class TestDispatcher(object): passed.append('error') passed.append(e) - update = Update(1, message=Message(1, None, None, None, text='/start', bot=bot)) + update = Update(1, message=Message(1, None, None, None, text='/start', + entities=[MessageEntity(type=MessageEntity.BOT_COMMAND, + offset=0, + length=len('/start'))], + bot=bot)) # If a TelegramException was caught, an error handler should be called and no further # handlers from the same group should be called. @@ -315,7 +327,11 @@ class TestDispatcher(object): passed.append(e) raise DispatcherHandlerStop - update = Update(1, message=Message(1, None, None, None, text='/start', bot=bot)) + update = Update(1, message=Message(1, None, None, None, text='/start', + entities=[MessageEntity(type=MessageEntity.BOT_COMMAND, + offset=0, + length=len('/start'))], + bot=bot)) # If a TelegramException was caught, an error handler should be called and no further # handlers from the same group should be called.