python-telegram-bot/tests/ext/test_conversationhandler.py

2270 lines
87 KiB
Python

#!/usr/bin/env python
#
# A library that provides a Python interface to the Telegram Bot API
# Copyright (C) 2015-2023
# Leandro Toledo de Souza <devs@python-telegram-bot.org>
#
# 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/].
"""Persistence of conversations is tested in test_basepersistence.py"""
import asyncio
import logging
from pathlib import Path
from warnings import filterwarnings
import pytest
from telegram import (
CallbackQuery,
Chat,
ChosenInlineResult,
InlineQuery,
Message,
MessageEntity,
PreCheckoutQuery,
ShippingQuery,
Update,
User,
)
from telegram.ext import (
ApplicationBuilder,
ApplicationHandlerStop,
CallbackContext,
CallbackQueryHandler,
ChosenInlineResultHandler,
CommandHandler,
ConversationHandler,
Defaults,
InlineQueryHandler,
JobQueue,
MessageHandler,
PollAnswerHandler,
PollHandler,
PreCheckoutQueryHandler,
ShippingQueryHandler,
StringCommandHandler,
StringRegexHandler,
TypeHandler,
filters,
)
from telegram.warnings import PTBUserWarning
from tests.auxil.build_messages import make_command_message
from tests.auxil.files import PROJECT_ROOT_PATH
from tests.auxil.pytest_classes import PytestBot, make_bot
from tests.auxil.slots import mro_slots
@pytest.fixture(scope="class")
def user1():
return User(first_name="Misses Test", id=123, is_bot=False)
@pytest.fixture(scope="class")
def user2():
return User(first_name="Mister Test", id=124, is_bot=False)
def raise_ahs(func):
async def decorator(self, *args, **kwargs):
result = await func(self, *args, **kwargs)
if self.raise_app_handler_stop:
raise ApplicationHandlerStop(result)
return result
return decorator
class TestConversationHandler:
"""Persistence of conversations is tested in test_basepersistence.py"""
# State definitions
# At first we're thirsty. Then we brew coffee, we drink it
# and then we can start coding!
END, THIRSTY, BREWING, DRINKING, CODING = range(-1, 4)
# Drinking state definitions (nested)
# At first we're holding the cup. Then we sip coffee, and last we swallow it
HOLDING, SIPPING, SWALLOWING, REPLENISHING, STOPPING = map(chr, range(ord("a"), ord("f")))
current_state, entry_points, states, fallbacks = None, None, None, None
group = Chat(0, Chat.GROUP)
second_group = Chat(1, Chat.GROUP)
raise_app_handler_stop = False
test_flag = False
# Test related
@pytest.fixture(autouse=True)
def _reset(self):
self.raise_app_handler_stop = False
self.test_flag = False
self.current_state = {}
self.entry_points = [CommandHandler("start", self.start)]
self.states = {
self.THIRSTY: [CommandHandler("brew", self.brew), CommandHandler("wait", self.start)],
self.BREWING: [CommandHandler("pourCoffee", self.drink)],
self.DRINKING: [
CommandHandler("startCoding", self.code),
CommandHandler("drinkMore", self.drink),
CommandHandler("end", self.end),
],
self.CODING: [
CommandHandler("keepCoding", self.code),
CommandHandler("gettingThirsty", self.start),
CommandHandler("drinkMore", self.drink),
],
}
self.fallbacks = [CommandHandler("eat", self.start)]
self.is_timeout = False
# for nesting tests
self.nested_states = {
self.THIRSTY: [CommandHandler("brew", self.brew), CommandHandler("wait", self.start)],
self.BREWING: [CommandHandler("pourCoffee", self.drink)],
self.CODING: [
CommandHandler("keepCoding", self.code),
CommandHandler("gettingThirsty", self.start),
CommandHandler("drinkMore", self.drink),
],
}
self.drinking_entry_points = [CommandHandler("hold", self.hold)]
self.drinking_states = {
self.HOLDING: [CommandHandler("sip", self.sip)],
self.SIPPING: [CommandHandler("swallow", self.swallow)],
self.SWALLOWING: [CommandHandler("hold", self.hold)],
}
self.drinking_fallbacks = [
CommandHandler("replenish", self.replenish),
CommandHandler("stop", self.stop),
CommandHandler("end", self.end),
CommandHandler("startCoding", self.code),
CommandHandler("drinkMore", self.drink),
]
self.drinking_entry_points.extend(self.drinking_fallbacks)
# Map nested states to parent states:
self.drinking_map_to_parent = {
# Option 1 - Map a fictional internal state to an external parent state
self.REPLENISHING: self.BREWING,
# Option 2 - Map a fictional internal state to the END state on the parent
self.STOPPING: self.END,
# Option 3 - Map the internal END state to an external parent state
self.END: self.CODING,
# Option 4 - Map an external state to the same external parent state
self.CODING: self.CODING,
# Option 5 - Map an external state to the internal entry point
self.DRINKING: self.DRINKING,
}
# State handlers
def _set_state(self, update, state):
self.current_state[update.message.from_user.id] = state
return state
# Actions
@raise_ahs
async def start(self, update, context):
if isinstance(update, Update):
return self._set_state(update, self.THIRSTY)
return self._set_state(context.bot, self.THIRSTY)
@raise_ahs
async def end(self, update, context):
return self._set_state(update, self.END)
@raise_ahs
async def start_end(self, update, context):
return self._set_state(update, self.END)
@raise_ahs
async def start_none(self, update, context):
return self._set_state(update, None)
@raise_ahs
async def brew(self, update, context):
if isinstance(update, Update):
return self._set_state(update, self.BREWING)
return self._set_state(context.bot, self.BREWING)
@raise_ahs
async def drink(self, update, context):
return self._set_state(update, self.DRINKING)
@raise_ahs
async def code(self, update, context):
return self._set_state(update, self.CODING)
@raise_ahs
async def passout(self, update, context):
assert update.message.text == "/brew"
assert isinstance(update, Update)
self.is_timeout = True
@raise_ahs
async def passout2(self, update, context):
assert isinstance(update, Update)
self.is_timeout = True
@raise_ahs
async def passout_context(self, update, context):
assert update.message.text == "/brew"
assert isinstance(context, CallbackContext)
self.is_timeout = True
@raise_ahs
async def passout2_context(self, update, context):
assert isinstance(context, CallbackContext)
self.is_timeout = True
# Drinking actions (nested)
@raise_ahs
async def hold(self, update, context):
return self._set_state(update, self.HOLDING)
@raise_ahs
async def sip(self, update, context):
return self._set_state(update, self.SIPPING)
@raise_ahs
async def swallow(self, update, context):
return self._set_state(update, self.SWALLOWING)
@raise_ahs
async def replenish(self, update, context):
return self._set_state(update, self.REPLENISHING)
@raise_ahs
async def stop(self, update, context):
return self._set_state(update, self.STOPPING)
def test_slot_behaviour(self):
handler = ConversationHandler(entry_points=[], states={}, fallbacks=[])
for attr in handler.__slots__:
assert getattr(handler, attr, "err") != "err", f"got extra slot '{attr}'"
assert len(mro_slots(handler)) == len(set(mro_slots(handler))), "duplicate slot"
def test_init(self):
entry_points = []
states = {}
fallbacks = []
map_to_parent = {}
ch = ConversationHandler(
entry_points=entry_points,
states=states,
fallbacks=fallbacks,
per_chat="per_chat",
per_user="per_user",
per_message="per_message",
persistent="persistent",
name="name",
allow_reentry="allow_reentry",
conversation_timeout=42,
map_to_parent=map_to_parent,
)
assert ch.entry_points is entry_points
assert ch.states is states
assert ch.fallbacks is fallbacks
assert ch.map_to_parent is map_to_parent
assert ch.per_chat == "per_chat"
assert ch.per_user == "per_user"
assert ch.per_message == "per_message"
assert ch.persistent == "persistent"
assert ch.name == "name"
assert ch.allow_reentry == "allow_reentry"
def test_init_persistent_no_name(self):
with pytest.raises(ValueError, match="can't be persistent when handler is unnamed"):
ConversationHandler(
self.entry_points, states=self.states, fallbacks=[], persistent=True
)
async def test_check_update_returns_non(self, app, user1):
"""checks some cases where updates should not be handled"""
conv_handler = ConversationHandler([], {}, [], per_message=True, per_chat=True)
assert not conv_handler.check_update("not an Update")
assert not conv_handler.check_update(Update(0))
assert not conv_handler.check_update(
Update(0, callback_query=CallbackQuery("1", from_user=user1, chat_instance="1"))
)
async def test_handlers_generate_warning(self, recwarn):
"""this function tests all handler + per_* setting combinations."""
# the warning message action needs to be set to always,
# otherwise only the first occurrence will be issued
filterwarnings(action="always", category=PTBUserWarning)
# this class doesn't do anything, its just not the Update class
class NotUpdate:
pass
recwarn.clear()
# this conversation handler has the string, string_regex, Pollhandler and TypeHandler
# which should all generate a warning no matter the per_* setting. TypeHandler should
# not when the class is Update
ConversationHandler(
entry_points=[StringCommandHandler("code", self.code)],
states={
self.BREWING: [
StringRegexHandler("code", self.code),
PollHandler(self.code),
TypeHandler(NotUpdate, self.code),
],
},
fallbacks=[TypeHandler(Update, self.code)],
)
# these handlers should all raise a warning when per_chat is True
ConversationHandler(
entry_points=[ShippingQueryHandler(self.code)],
states={
self.BREWING: [
InlineQueryHandler(self.code),
PreCheckoutQueryHandler(self.code),
PollAnswerHandler(self.code),
],
},
fallbacks=[ChosenInlineResultHandler(self.code)],
per_chat=True,
)
# the CallbackQueryHandler should *not* raise when per_message is True,
# but any other one should
ConversationHandler(
entry_points=[CallbackQueryHandler(self.code)],
states={
self.BREWING: [CommandHandler("code", self.code)],
},
fallbacks=[CallbackQueryHandler(self.code)],
per_message=True,
)
# the CallbackQueryHandler should raise when per_message is False
ConversationHandler(
entry_points=[CommandHandler("code", self.code)],
states={
self.BREWING: [CommandHandler("code", self.code)],
},
fallbacks=[CallbackQueryHandler(self.code)],
per_message=False,
)
# adding a nested conv to a conversation with timeout should warn
child = ConversationHandler(
entry_points=[CommandHandler("code", self.code)],
states={
self.BREWING: [CommandHandler("code", self.code)],
},
fallbacks=[CommandHandler("code", self.code)],
)
ConversationHandler(
entry_points=[CommandHandler("code", self.code)],
states={
self.BREWING: [child],
},
fallbacks=[CommandHandler("code", self.code)],
conversation_timeout=42,
)
# If per_message is True, per_chat should also be True, since msg ids are not unique
ConversationHandler(
entry_points=[CallbackQueryHandler(self.code, "code")],
states={
self.BREWING: [CallbackQueryHandler(self.code, "code")],
},
fallbacks=[CallbackQueryHandler(self.code, "code")],
per_message=True,
per_chat=False,
)
# the overall number of handlers throwing a warning is 13
assert len(recwarn) == 13
# now we test the messages, they are raised in the order they are inserted
# into the conversation handler
assert str(recwarn[0].message) == (
"The `ConversationHandler` only handles updates of type `telegram.Update`. "
"StringCommandHandler handles updates of type `str`."
)
assert str(recwarn[1].message) == (
"The `ConversationHandler` only handles updates of type `telegram.Update`. "
"StringRegexHandler handles updates of type `str`."
)
assert str(recwarn[2].message) == (
"PollHandler will never trigger in a conversation since it has no information "
"about the chat or the user who voted in it. Do you mean the "
"`PollAnswerHandler`?"
)
assert str(recwarn[3].message) == (
"The `ConversationHandler` only handles updates of type `telegram.Update`. "
"The TypeHandler is set to handle NotUpdate."
)
per_faq_link = (
" Read this FAQ entry to learn more about the per_* settings: "
"https://github.com/python-telegram-bot/python-telegram-bot/wiki"
"/Frequently-Asked-Questions#what-do-the-per_-settings-in-conversationhandler-do."
)
assert str(recwarn[4].message) == (
"Updates handled by ShippingQueryHandler only have information about the user,"
" so this handler won't ever be triggered if `per_chat=True`." + per_faq_link
)
assert str(recwarn[5].message) == (
"Updates handled by ChosenInlineResultHandler only have information about the user,"
" so this handler won't ever be triggered if `per_chat=True`." + per_faq_link
)
assert str(recwarn[6].message) == (
"Updates handled by InlineQueryHandler only have information about the user,"
" so this handler won't ever be triggered if `per_chat=True`." + per_faq_link
)
assert str(recwarn[7].message) == (
"Updates handled by PreCheckoutQueryHandler only have information about the user,"
" so this handler won't ever be triggered if `per_chat=True`." + per_faq_link
)
assert str(recwarn[8].message) == (
"Updates handled by PollAnswerHandler only have information about the user,"
" so this handler won't ever be triggered if `per_chat=True`." + per_faq_link
)
assert str(recwarn[9].message) == (
"If 'per_message=True', all entry points, state handlers, and fallbacks must be "
"'CallbackQueryHandler', since no other handlers have a message context."
+ per_faq_link
)
assert str(recwarn[10].message) == (
"If 'per_message=False', 'CallbackQueryHandler' will not be tracked for "
"every message." + per_faq_link
)
assert str(recwarn[11].message) == (
"Using `conversation_timeout` with nested conversations is currently not "
"supported. You can still try to use it, but it will likely behave differently"
" from what you expect."
)
assert str(recwarn[12].message) == (
"If 'per_message=True' is used, 'per_chat=True' should also be used, "
"since message IDs are not globally unique."
)
# this for loop checks if the correct stacklevel is used when generating the warning
for warning in recwarn:
assert warning.category is PTBUserWarning
assert warning.filename == __file__, "incorrect stacklevel!"
@pytest.mark.parametrize(
"attr",
[
"entry_points",
"states",
"fallbacks",
"per_chat",
"per_user",
"per_message",
"name",
"persistent",
"allow_reentry",
"conversation_timeout",
"map_to_parent",
],
indirect=False,
)
def test_immutable(self, attr):
ch = ConversationHandler(entry_points=[], states={}, fallbacks=[])
with pytest.raises(AttributeError, match=f"You can not assign a new value to {attr}"):
setattr(ch, attr, True)
def test_per_all_false(self):
with pytest.raises(ValueError, match="can't all be 'False'"):
ConversationHandler(
entry_points=[],
states={},
fallbacks=[],
per_chat=False,
per_user=False,
per_message=False,
)
@pytest.mark.parametrize("raise_ahs", [True, False])
async def test_basic_and_app_handler_stop(self, app, bot, user1, user2, raise_ahs):
handler = ConversationHandler(
entry_points=self.entry_points, states=self.states, fallbacks=self.fallbacks
)
app.add_handler(handler)
async def callback(_, __):
self.test_flag = True
app.add_handler(TypeHandler(object, callback), group=100)
self.raise_app_handler_stop = raise_ahs
# User one, starts the state machine.
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.THIRSTY
assert self.test_flag == (not raise_ahs)
# The user is thirsty and wants to brew coffee.
message.text = "/brew"
message.entities[0].length = len("/brew")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.BREWING
assert self.test_flag == (not raise_ahs)
# Lets see if an invalid command makes sure, no state is changed.
message.text = "/nothing"
message.entities[0].length = len("/nothing")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.BREWING
assert self.test_flag is True
self.test_flag = False
# Lets see if the state machine still works by pouring coffee.
message.text = "/pourCoffee"
message.entities[0].length = len("/pourCoffee")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.DRINKING
assert self.test_flag == (not raise_ahs)
# Let's now verify that for another user, who did not start yet,
# the state has not been changed.
message.from_user = user2
await app.process_update(Update(update_id=0, message=message))
with pytest.raises(KeyError):
self.current_state[user2.id]
async def test_conversation_handler_end(self, caplog, app, bot, user1):
handler = ConversationHandler(
entry_points=self.entry_points, states=self.states, fallbacks=self.fallbacks
)
app.add_handler(handler)
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.process_update(Update(update_id=0, message=message))
message.text = "/brew"
message.entities[0].length = len("/brew")
await app.process_update(Update(update_id=0, message=message))
message.text = "/pourCoffee"
message.entities[0].length = len("/pourCoffee")
await app.process_update(Update(update_id=0, message=message))
message.text = "/end"
message.entities[0].length = len("/end")
caplog.clear()
with caplog.at_level(logging.ERROR):
await app.process_update(Update(update_id=0, message=message))
assert len(caplog.records) == 0
assert self.current_state[user1.id] == self.END
# make sure that the conversation has ended by checking that the start command is
# accepted again
message.text = "/start"
message.entities[0].length = len("/start")
assert handler.check_update(Update(update_id=0, message=message))
async def test_conversation_handler_fallback(self, app, bot, user1, user2):
handler = ConversationHandler(
entry_points=self.entry_points, states=self.states, fallbacks=self.fallbacks
)
app.add_handler(handler)
# first check if fallback will not trigger start when not started
message = Message(
0,
None,
self.group,
from_user=user1,
text="/eat",
entities=[MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/eat"))],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.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")
await app.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")
await app.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")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.THIRSTY
async def test_unknown_state_warning(self, app, bot, user1, recwarn):
def build_callback(state):
async def callback(_, __):
return state
return callback
handler = ConversationHandler(
entry_points=[CommandHandler("start", build_callback(1))],
states={
1: [TypeHandler(Update, build_callback(69))],
2: [TypeHandler(Update, build_callback(42))],
},
fallbacks=self.fallbacks,
name="xyz",
)
app.add_handler(handler)
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.process_update(Update(update_id=0, message=message))
try:
await app.process_update(Update(update_id=1, message=message))
except Exception as exc:
print(exc)
raise exc
assert len(recwarn) == 1
assert recwarn[0].category is PTBUserWarning
assert (
Path(recwarn[0].filename)
== PROJECT_ROOT_PATH / "telegram" / "ext" / "_conversationhandler.py"
), "wrong stacklevel!"
assert str(recwarn[0].message) == (
"'callback' returned state 69 which is unknown to the ConversationHandler xyz."
)
async def test_conversation_handler_per_chat(self, app, bot, user1, user2):
handler = ConversationHandler(
entry_points=self.entry_points,
states=self.states,
fallbacks=self.fallbacks,
per_user=False,
)
app.add_handler(handler)
# User one, starts the state machine.
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.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")
await app.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")
await app.process_update(Update(update_id=0, message=message))
# Check that we're in the DRINKING state by checking that the corresponding command
# is accepted
message.from_user = user1
message.text = "/startCoding"
message.entities[0].length = len("/startCoding")
assert handler.check_update(Update(update_id=0, message=message))
message.from_user = user2
assert handler.check_update(Update(update_id=0, message=message))
async def test_conversation_handler_per_user(self, app, bot, user1):
handler = ConversationHandler(
entry_points=self.entry_points,
states=self.states,
fallbacks=self.fallbacks,
per_chat=False,
)
app.add_handler(handler)
# User one, starts the state machine.
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
# First check that updates without user won't be handled
message.from_user = None
assert not handler.check_update(Update(update_id=0, message=message))
message.from_user = user1
async with app:
await app.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")
await app.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")
await app.process_update(Update(update_id=0, message=message))
# Check that we're in the DRINKING state by checking that the corresponding command
# is accepted
message.chat = self.group
message.text = "/startCoding"
message.entities[0].length = len("/startCoding")
assert handler.check_update(Update(update_id=0, message=message))
message.chat = self.second_group
assert handler.check_update(Update(update_id=0, message=message))
@pytest.mark.parametrize("inline", [True, False])
@pytest.mark.filterwarnings("ignore: If 'per_message=True' is used, 'per_chat=True'")
async def test_conversation_handler_per_message(self, app, bot, user1, user2, inline):
async def entry(update, context):
return 1
async def one(update, context):
return 2
async def two(update, context):
return ConversationHandler.END
handler = ConversationHandler(
entry_points=[CallbackQueryHandler(entry)],
states={
1: [CallbackQueryHandler(one, pattern="^1$")],
2: [CallbackQueryHandler(two, pattern="^2$")],
},
fallbacks=[],
per_message=True,
per_chat=not inline,
)
app.add_handler(handler)
# User one, starts the state machine.
message = (
Message(0, None, self.group, from_user=user1, text="msg w/ inlinekeyboard")
if not inline
else None
)
if message:
message.set_bot(bot)
message._unfreeze()
inline_message_id = "42" if inline else None
async with app:
cbq_1 = CallbackQuery(
0,
user1,
None,
message=message,
data="1",
inline_message_id=inline_message_id,
)
cbq_1.set_bot(bot)
cbq_2 = CallbackQuery(
0,
user1,
None,
message=message,
data="2",
inline_message_id=inline_message_id,
)
cbq_2.set_bot(bot)
cbq_2._unfreeze()
await app.process_update(Update(update_id=0, callback_query=cbq_1))
# Make sure that we're in the correct state
assert handler.check_update(Update(0, callback_query=cbq_1))
assert not handler.check_update(Update(0, callback_query=cbq_2))
await app.process_update(Update(update_id=0, callback_query=cbq_1))
# Make sure that we're in the correct state
assert not handler.check_update(Update(0, callback_query=cbq_1))
assert handler.check_update(Update(0, callback_query=cbq_2))
# Let's now verify that for a different user in the same group, the state will not be
# updated
cbq_2.from_user = user2
await app.process_update(Update(update_id=0, callback_query=cbq_2))
cbq_2.from_user = user1
assert not handler.check_update(Update(0, callback_query=cbq_1))
assert handler.check_update(Update(0, callback_query=cbq_2))
async def test_end_on_first_message(self, app, bot, user1):
handler = ConversationHandler(
entry_points=[CommandHandler("start", self.start_end)], states={}, fallbacks=[]
)
app.add_handler(handler)
# User starts the state machine and immediately ends it.
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.process_update(Update(update_id=0, message=message))
assert handler.check_update(Update(update_id=0, message=message))
async def test_end_on_first_message_non_blocking_handler(self, app, bot, user1):
handler = ConversationHandler(
entry_points=[CommandHandler("start", callback=self.start_end, block=False)],
states={},
fallbacks=[],
)
app.add_handler(handler)
# User starts the state machine with a non-blocking function that immediately ends the
# conversation. non-blocking results are resolved when the users state is queried next
# time.
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.process_update(Update(update_id=0, message=message))
# give the task a chance to finish
await asyncio.sleep(0.1)
# Let's check that processing the same update again is accepted. this confirms that
# a) the pending state is correctly resolved
# b) the conversation has ended
assert handler.check_update(Update(0, message=message))
async def test_none_on_first_message(self, app, bot, user1):
handler = ConversationHandler(
entry_points=[MessageHandler(filters.ALL, self.start_none)], states={}, fallbacks=[]
)
app.add_handler(handler)
# User starts the state machine and a callback function returns None
message = Message(0, None, self.group, from_user=user1, text="/start")
message.set_bot(bot)
message._unfreeze()
async with app:
await app.process_update(Update(update_id=0, message=message))
# Check that the same message is accepted again, i.e. the conversation immediately
# ended
assert handler.check_update(Update(0, message=message))
async def test_none_on_first_message_non_blocking_handler(self, app, bot, user1):
handler = ConversationHandler(
entry_points=[CommandHandler("start", self.start_none, block=False)],
states={},
fallbacks=[],
)
app.add_handler(handler)
# User starts the state machine with a non-blocking handler that returns None
# non-blocking results are resolved when the users state is queried next time.
message = Message(
0,
None,
self.group,
text="/start",
from_user=user1,
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.process_update(Update(update_id=0, message=message))
# Give the task a chance to finish
await asyncio.sleep(0.1)
# Let's check that processing the same update again is accepted. this confirms that
# a) the pending state is correctly resolved
# b) the conversation has ended
assert handler.check_update(Update(0, message=message))
async def test_per_chat_message_without_chat(self, bot, user1):
handler = ConversationHandler(
entry_points=[CommandHandler("start", self.start_end)], states={}, fallbacks=[]
)
cbq = CallbackQuery(0, user1, None, None)
cbq.set_bot(bot)
update = Update(0, callback_query=cbq)
assert not handler.check_update(update)
async def test_channel_message_without_chat(self, bot):
handler = ConversationHandler(
entry_points=[MessageHandler(filters.ALL, self.start_end)], states={}, fallbacks=[]
)
message = Message(0, date=None, chat=Chat(0, Chat.CHANNEL, "Misses Test"))
message.set_bot(bot)
message._unfreeze()
update = Update(0, channel_post=message)
assert not handler.check_update(update)
update = Update(0, edited_channel_post=message)
assert not handler.check_update(update)
async def test_all_update_types(self, app, bot, user1):
handler = ConversationHandler(
entry_points=[CommandHandler("start", self.start_end)], states={}, fallbacks=[]
)
message = Message(0, None, self.group, from_user=user1, text="ignore")
message.set_bot(bot)
message._unfreeze()
callback_query = CallbackQuery(0, user1, None, message=message, data="data")
callback_query.set_bot(bot)
chosen_inline_result = ChosenInlineResult(0, user1, "query")
chosen_inline_result.set_bot(bot)
inline_query = InlineQuery(0, user1, "query", offset="")
inline_query.set_bot(bot)
pre_checkout_query = PreCheckoutQuery(0, user1, "USD", 100, [])
pre_checkout_query.set_bot(bot)
shipping_query = ShippingQuery(0, user1, [], None)
shipping_query.set_bot(bot)
assert not handler.check_update(Update(0, callback_query=callback_query))
assert not handler.check_update(Update(0, chosen_inline_result=chosen_inline_result))
assert not handler.check_update(Update(0, inline_query=inline_query))
assert not handler.check_update(Update(0, message=message))
assert not handler.check_update(Update(0, pre_checkout_query=pre_checkout_query))
assert not handler.check_update(Update(0, shipping_query=shipping_query))
@pytest.mark.parametrize("jq", [True, False])
async def test_no_running_job_queue_warning(self, app, bot, user1, recwarn, jq):
handler = ConversationHandler(
entry_points=self.entry_points,
states=self.states,
fallbacks=self.fallbacks,
conversation_timeout=0.5,
)
if not jq:
app = ApplicationBuilder().token(bot.token).job_queue(None).build()
app.add_handler(handler)
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.process_update(Update(update_id=0, message=message))
await asyncio.sleep(0.5)
if jq:
assert len(recwarn) == 1
else:
assert len(recwarn) == 2
assert str(recwarn[0].message if jq else recwarn[1].message).startswith(
"Ignoring `conversation_timeout`"
)
assert ("is not running" if jq else "No `JobQueue` set up.") in str(recwarn[0].message)
for warning in recwarn:
assert warning.category is PTBUserWarning
assert (
Path(warning.filename)
== PROJECT_ROOT_PATH / "telegram" / "ext" / "_conversationhandler.py"
), "wrong stacklevel!"
# now set app.job_queue back to it's original value
async def test_schedule_job_exception(self, app, bot, user1, monkeypatch, caplog):
def mocked_run_once(*a, **kw):
raise Exception("job error")
class DictJB(JobQueue):
pass
app = ApplicationBuilder().token(bot.token).job_queue(DictJB()).build()
monkeypatch.setattr(app.job_queue, "run_once", mocked_run_once)
handler = ConversationHandler(
entry_points=self.entry_points,
states=self.states,
fallbacks=self.fallbacks,
conversation_timeout=100,
)
app.add_handler(handler)
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.start()
with caplog.at_level(logging.ERROR):
await app.process_update(Update(update_id=0, message=message))
await asyncio.sleep(0.5)
assert len(caplog.records) == 1
assert caplog.records[0].message == "Failed to schedule timeout."
assert caplog.records[0].name == "telegram.ext.ConversationHandler"
assert str(caplog.records[0].exc_info[1]) == "job error"
await app.stop()
@pytest.mark.parametrize(argnames="test_type", argvalues=["none", "exception"])
async def test_non_blocking_exception_or_none(self, app, bot, user1, caplog, test_type):
"""Here we make sure that when a non-blocking handler raises an
exception or returns None, the state isn't changed.
"""
error = Exception("task exception")
async def conv_entry(*a, **kw):
return 1
async def raise_error(*a, **kw):
if test_type == "none":
return
raise error
handler = ConversationHandler(
entry_points=[CommandHandler("start", conv_entry)],
states={1: [MessageHandler(filters.Text(["error"]), raise_error)]},
fallbacks=self.fallbacks,
block=False,
)
app.add_handler(handler)
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
# start the conversation
async with app:
await app.process_update(Update(update_id=0, message=message))
await asyncio.sleep(0.1)
message.text = "error"
await app.process_update(Update(update_id=0, message=message))
await asyncio.sleep(0.1)
caplog.clear()
with caplog.at_level(logging.ERROR):
# This also makes sure that we're still in the same state
assert handler.check_update(Update(0, message=message))
if test_type == "exception":
assert len(caplog.records) == 1
assert caplog.records[0].name == "telegram.ext.ConversationHandler"
assert (
caplog.records[0].message
== "Task function raised exception. Falling back to old state 1"
)
assert caplog.records[0].exc_info[1] is None
else:
assert len(caplog.records) == 0
async def test_non_blocking_entry_point_exception(self, app, bot, user1, caplog):
"""Here we make sure that when a non-blocking entry point raises an
exception, the state isn't changed.
"""
error = Exception("task exception")
async def raise_error(*a, **kw):
raise error
handler = ConversationHandler(
entry_points=[CommandHandler("start", raise_error, block=False)],
states={},
fallbacks=self.fallbacks,
)
app.add_handler(handler)
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
# start the conversation
async with app:
await app.process_update(Update(update_id=0, message=message))
await asyncio.sleep(0.1)
caplog.clear()
with caplog.at_level(logging.ERROR):
# This also makes sure that we're still in the same state
assert handler.check_update(Update(0, message=message))
assert len(caplog.records) == 1
assert caplog.records[0].name == "telegram.ext.ConversationHandler"
assert (
caplog.records[0].message
== "Task function raised exception. Falling back to old state None"
)
assert caplog.records[0].exc_info[1] is None
async def test_conversation_timeout(self, app, bot, user1):
handler = ConversationHandler(
entry_points=self.entry_points,
states=self.states,
fallbacks=self.fallbacks,
conversation_timeout=0.5,
)
app.add_handler(handler)
# Start state machine, then reach timeout
start_message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
start_message.set_bot(bot)
brew_message = Message(
0,
None,
self.group,
from_user=user1,
text="/brew",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/brew"))
],
)
brew_message.set_bot(bot)
pour_coffee_message = Message(
0,
None,
self.group,
from_user=user1,
text="/pourCoffee",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/pourCoffee"))
],
)
pour_coffee_message.set_bot(bot)
async with app:
await app.start()
await app.process_update(Update(update_id=0, message=start_message))
assert handler.check_update(Update(0, message=brew_message))
await asyncio.sleep(0.75)
assert handler.check_update(Update(0, message=start_message))
# Start state machine, do something, then reach timeout
await app.process_update(Update(update_id=1, message=start_message))
assert handler.check_update(Update(0, message=brew_message))
# assert handler.conversations.get((self.group.id, user1.id)) == self.THIRSTY
# start_message.text = '/brew'
# start_message.entities[0].length = len('/brew')
await app.process_update(Update(update_id=2, message=brew_message))
assert handler.check_update(Update(0, message=pour_coffee_message))
# assert handler.conversations.get((self.group.id, user1.id)) == self.BREWING
await asyncio.sleep(0.75)
assert handler.check_update(Update(0, message=start_message))
# assert handler.conversations.get((self.group.id, user1.id)) is None
await app.stop()
async def test_timeout_not_triggered_on_conv_end_non_blocking(self, bot, app, user1):
def timeout(*a, **kw):
self.test_flag = True
self.states.update({ConversationHandler.TIMEOUT: [TypeHandler(Update, timeout)]})
handler = ConversationHandler(
entry_points=self.entry_points,
states=self.states,
fallbacks=self.fallbacks,
conversation_timeout=0.5,
block=False,
)
app.add_handler(handler)
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
# start the conversation
await app.process_update(Update(update_id=0, message=message))
await asyncio.sleep(0.1)
message.text = "/brew"
message.entities[0].length = len("/brew")
await app.process_update(Update(update_id=1, message=message))
await asyncio.sleep(0.1)
message.text = "/pourCoffee"
message.entities[0].length = len("/pourCoffee")
await app.process_update(Update(update_id=2, message=message))
await asyncio.sleep(0.1)
message.text = "/end"
message.entities[0].length = len("/end")
await app.process_update(Update(update_id=3, message=message))
await asyncio.sleep(1)
# assert timeout handler didn't get called
assert self.test_flag is False
async def test_conversation_timeout_application_handler_stop(self, app, bot, user1, recwarn):
handler = ConversationHandler(
entry_points=self.entry_points,
states=self.states,
fallbacks=self.fallbacks,
conversation_timeout=0.5,
)
def timeout(*args, **kwargs):
raise ApplicationHandlerStop
self.states.update({ConversationHandler.TIMEOUT: [TypeHandler(Update, timeout)]})
app.add_handler(handler)
# Start state machine, then reach timeout
message = Message(
0,
None,
self.group,
text="/start",
from_user=user1,
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
brew_message = Message(
0,
None,
self.group,
from_user=user1,
text="/brew",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/brew"))
],
)
brew_message.set_bot(bot)
async with app:
await app.start()
await app.process_update(Update(update_id=0, message=message))
# Make sure that we're in the next state
assert handler.check_update(Update(0, message=brew_message))
await app.process_update(Update(0, message=brew_message))
await asyncio.sleep(0.9)
# Check that conversation has ended by checking that the start messages is accepted
# again
assert handler.check_update(Update(0, message=message))
assert len(recwarn) == 1
assert str(recwarn[0].message).startswith("ApplicationHandlerStop in TIMEOUT")
assert recwarn[0].category is PTBUserWarning
assert (
Path(recwarn[0].filename)
== PROJECT_ROOT_PATH / "telegram" / "ext" / "_jobqueue.py"
), "wrong stacklevel!"
await app.stop()
async def test_conversation_handler_timeout_update_and_context(self, app, bot, user1):
context = None
async def start_callback(u, c):
nonlocal context, self
context = c
return await self.start(u, c)
# Start state machine, then reach timeout
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
update = Update(update_id=0, message=message)
async def timeout_callback(u, c):
nonlocal update, context
assert u is update
assert c is context
self.is_timeout = (u is update) and (c is context)
states = self.states
timeout_handler = CommandHandler("start", timeout_callback)
states.update({ConversationHandler.TIMEOUT: [timeout_handler]})
handler = ConversationHandler(
entry_points=[CommandHandler("start", start_callback)],
states=states,
fallbacks=self.fallbacks,
conversation_timeout=0.5,
)
app.add_handler(handler)
async with app:
await app.start()
await app.process_update(update)
await asyncio.sleep(0.9)
# check that the conversation has ended by checking that the start message is accepted
assert handler.check_update(Update(0, message=message))
assert self.is_timeout
await app.stop()
@pytest.mark.flaky(3, 1)
async def test_conversation_timeout_keeps_extending(self, app, bot, user1):
handler = ConversationHandler(
entry_points=self.entry_points,
states=self.states,
fallbacks=self.fallbacks,
conversation_timeout=0.5,
)
app.add_handler(handler)
# Start state machine, wait, do something, verify the timeout is extended.
# t=0 /start (timeout=.5)
# t=.35 /brew (timeout=.85)
# t=.5 original timeout
# t=.6 /pourCoffee (timeout=1.1)
# t=.85 second timeout
# t=1.1 actual timeout
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.start()
await app.process_update(Update(update_id=0, message=message))
message.text = "/brew"
message.entities[0].length = len("/brew")
assert handler.check_update(Update(0, message=message))
await asyncio.sleep(0.35) # t=.35
assert handler.check_update(Update(0, message=message))
await app.process_update(Update(update_id=0, message=message))
message.text = "/pourCoffee"
message.entities[0].length = len("/pourCoffee")
assert handler.check_update(Update(0, message=message))
await asyncio.sleep(0.25) # t=.6
assert handler.check_update(Update(0, message=message))
await app.process_update(Update(update_id=0, message=message))
message.text = "/startCoding"
message.entities[0].length = len("/startCoding")
assert handler.check_update(Update(0, message=message))
await asyncio.sleep(0.4) # t=1.0
assert handler.check_update(Update(0, message=message))
await asyncio.sleep(0.3) # t=1.3
assert not handler.check_update(Update(0, message=message))
message.text = "/start"
message.entities[0].length = len("/start")
assert handler.check_update(Update(0, message=message))
await app.stop()
async def test_conversation_timeout_two_users(self, app, bot, user1, user2):
handler = ConversationHandler(
entry_points=self.entry_points,
states=self.states,
fallbacks=self.fallbacks,
conversation_timeout=0.5,
)
app.add_handler(handler)
# Start state machine, do something as second user, then reach timeout
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.start()
await app.process_update(Update(update_id=0, message=message))
message.text = "/brew"
message.entities[0].length = len("/brew")
assert handler.check_update(Update(0, message=message))
message.from_user = user2
await app.process_update(Update(update_id=0, message=message))
message.text = "/start"
message.entities[0].length = len("/start")
# Make sure that user2s conversation has not yet started
assert handler.check_update(Update(0, message=message))
await app.process_update(Update(update_id=0, message=message))
message.text = "/brew"
message.entities[0].length = len("/brew")
assert handler.check_update(Update(0, message=message))
await asyncio.sleep(0.7)
# check that both conversations have ended by checking that the start message is
# accepted again
message.text = "/start"
message.entities[0].length = len("/start")
message.from_user = user1
assert handler.check_update(Update(0, message=message))
message.from_user = user2
assert handler.check_update(Update(0, message=message))
await app.stop()
async def test_conversation_handler_timeout_state(self, app, bot, user1):
states = self.states
states.update(
{
ConversationHandler.TIMEOUT: [
CommandHandler("brew", self.passout),
MessageHandler(~filters.Regex("oding"), self.passout2),
]
}
)
handler = ConversationHandler(
entry_points=self.entry_points,
states=states,
fallbacks=self.fallbacks,
conversation_timeout=0.5,
)
app.add_handler(handler)
# CommandHandler timeout
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.start()
await app.process_update(Update(update_id=0, message=message))
message.text = "/brew"
message.entities[0].length = len("/brew")
await app.process_update(Update(update_id=0, message=message))
await asyncio.sleep(0.7)
# check that conversation has ended by checking that start cmd is accepted again
message.text = "/start"
message.entities[0].length = len("/start")
assert handler.check_update(Update(0, message=message))
assert self.is_timeout
# MessageHandler timeout
self.is_timeout = False
message.text = "/start"
message.entities[0].length = len("/start")
await app.process_update(Update(update_id=1, message=message))
await asyncio.sleep(0.7)
# check that conversation has ended by checking that start cmd is accepted again
assert handler.check_update(Update(0, message=message))
assert self.is_timeout
# Timeout but no valid handler
self.is_timeout = False
await app.process_update(Update(update_id=0, message=message))
message.text = "/brew"
message.entities[0].length = len("/brew")
await app.process_update(Update(update_id=0, message=message))
message.text = "/startCoding"
message.entities[0].length = len("/startCoding")
await app.process_update(Update(update_id=0, message=message))
await asyncio.sleep(0.7)
# check that conversation has ended by checking that start cmd is accepted again
message.text = "/start"
message.entities[0].length = len("/start")
assert handler.check_update(Update(0, message=message))
assert not self.is_timeout
await app.stop()
async def test_conversation_handler_timeout_state_context(self, app, bot, user1):
states = self.states
states.update(
{
ConversationHandler.TIMEOUT: [
CommandHandler("brew", self.passout_context),
MessageHandler(~filters.Regex("oding"), self.passout2_context),
]
}
)
handler = ConversationHandler(
entry_points=self.entry_points,
states=states,
fallbacks=self.fallbacks,
conversation_timeout=0.5,
)
app.add_handler(handler)
# CommandHandler timeout
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.start()
await app.process_update(Update(update_id=0, message=message))
message.text = "/brew"
message.entities[0].length = len("/brew")
await app.process_update(Update(update_id=0, message=message))
await asyncio.sleep(0.7)
# check that conversation has ended by checking that start cmd is accepted again
message.text = "/start"
message.entities[0].length = len("/start")
assert handler.check_update(Update(0, message=message))
assert self.is_timeout
# MessageHandler timeout
self.is_timeout = False
message.text = "/start"
message.entities[0].length = len("/start")
await app.process_update(Update(update_id=1, message=message))
await asyncio.sleep(0.7)
# check that conversation has ended by checking that start cmd is accepted again
assert handler.check_update(Update(0, message=message))
assert self.is_timeout
# Timeout but no valid handler
self.is_timeout = False
await app.process_update(Update(update_id=0, message=message))
message.text = "/brew"
message.entities[0].length = len("/brew")
await app.process_update(Update(update_id=0, message=message))
message.text = "/startCoding"
message.entities[0].length = len("/startCoding")
await app.process_update(Update(update_id=0, message=message))
await asyncio.sleep(0.7)
# check that conversation has ended by checking that start cmd is accepted again
message.text = "/start"
message.entities[0].length = len("/start")
assert handler.check_update(Update(0, message=message))
assert not self.is_timeout
await app.stop()
async def test_conversation_timeout_cancel_conflict(self, app, 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
async def slowbrew(_update, context):
await asyncio.sleep(0.25)
# Let's give to the original timeout a chance to execute
await asyncio.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,
)
app.add_handler(handler)
# CommandHandler timeout
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.start()
await app.process_update(Update(update_id=0, message=message))
await asyncio.sleep(0.25)
message.text = "/slowbrew"
message.entities[0].length = len("/slowbrew")
await app.process_update(Update(update_id=0, message=message))
# Check that conversation has not ended by checking that start cmd is not accepted
message.text = "/start"
message.entities[0].length = len("/start")
assert not handler.check_update(Update(0, message=message))
assert not self.is_timeout
await asyncio.sleep(0.7)
# Check that conversation has ended by checking that start cmd is accepted again
message.text = "/start"
message.entities[0].length = len("/start")
assert handler.check_update(Update(0, message=message))
assert self.is_timeout
await app.stop()
async def test_nested_conversation_handler(self, app, bot, user1, user2):
self.nested_states[self.DRINKING] = [
ConversationHandler(
entry_points=self.drinking_entry_points,
states=self.drinking_states,
fallbacks=self.drinking_fallbacks,
map_to_parent=self.drinking_map_to_parent,
)
]
handler = ConversationHandler(
entry_points=self.entry_points, states=self.nested_states, fallbacks=self.fallbacks
)
app.add_handler(handler)
# User one, starts the state machine.
message = Message(
0,
None,
self.group,
from_user=user1,
text="/start",
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.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")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.BREWING
# Lets pour some coffee.
message.text = "/pourCoffee"
message.entities[0].length = len("/pourCoffee")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.DRINKING
# The user is holding the cup
message.text = "/hold"
message.entities[0].length = len("/hold")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.HOLDING
# The user is sipping coffee
message.text = "/sip"
message.entities[0].length = len("/sip")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.SIPPING
# The user is swallowing
message.text = "/swallow"
message.entities[0].length = len("/swallow")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.SWALLOWING
# The user is holding the cup again
message.text = "/hold"
message.entities[0].length = len("/hold")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.HOLDING
# The user wants to replenish the coffee supply
message.text = "/replenish"
message.entities[0].length = len("/replenish")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.REPLENISHING
# check that we're in the right state now by checking that the update is accepted
message.text = "/pourCoffee"
message.entities[0].length = len("/pourCoffee")
assert handler.check_update(Update(0, message=message))
# The user wants to drink their coffee again)
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.DRINKING
# The user is now ready to start coding
message.text = "/startCoding"
message.entities[0].length = len("/startCoding")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.CODING
# The user decides it's time to drink again
message.text = "/drinkMore"
message.entities[0].length = len("/drinkMore")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.DRINKING
# The user is holding their cup
message.text = "/hold"
message.entities[0].length = len("/hold")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.HOLDING
# The user wants to end with the drinking and go back to coding
message.text = "/end"
message.entities[0].length = len("/end")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.END
# check that we're in the right state now by checking that the update is accepted
message.text = "/drinkMore"
message.entities[0].length = len("/drinkMore")
assert handler.check_update(Update(0, message=message))
# The user wants to drink once more
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.DRINKING
# The user wants to stop altogether
message.text = "/stop"
message.entities[0].length = len("/stop")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.STOPPING
# check that the conversation has ended by checking that the start cmd is accepted
message.text = "/start"
message.entities[0].length = len("/start")
assert handler.check_update(Update(0, message=message))
async def test_nested_conversation_application_handler_stop(self, app, bot, user1, user2):
self.nested_states[self.DRINKING] = [
ConversationHandler(
entry_points=self.drinking_entry_points,
states=self.drinking_states,
fallbacks=self.drinking_fallbacks,
map_to_parent=self.drinking_map_to_parent,
)
]
handler = ConversationHandler(
entry_points=self.entry_points, states=self.nested_states, fallbacks=self.fallbacks
)
def test_callback(u, c):
self.test_flag = True
app.add_handler(handler)
app.add_handler(TypeHandler(Update, test_callback), group=1)
self.raise_app_handler_stop = True
# User one, starts the state machine.
message = Message(
0,
None,
self.group,
text="/start",
from_user=user1,
entities=[
MessageEntity(type=MessageEntity.BOT_COMMAND, offset=0, length=len("/start"))
],
)
message.set_bot(bot)
message._unfreeze()
message.entities[0]._unfreeze()
async with app:
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.THIRSTY
assert not self.test_flag
# The user is thirsty and wants to brew coffee.
message.text = "/brew"
message.entities[0].length = len("/brew")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.BREWING
assert not self.test_flag
# Lets pour some coffee.
message.text = "/pourCoffee"
message.entities[0].length = len("/pourCoffee")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.DRINKING
assert not self.test_flag
# The user is holding the cup
message.text = "/hold"
message.entities[0].length = len("/hold")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.HOLDING
assert not self.test_flag
# The user is sipping coffee
message.text = "/sip"
message.entities[0].length = len("/sip")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.SIPPING
assert not self.test_flag
# The user is swallowing
message.text = "/swallow"
message.entities[0].length = len("/swallow")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.SWALLOWING
assert not self.test_flag
# The user is holding the cup again
message.text = "/hold"
message.entities[0].length = len("/hold")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.HOLDING
assert not self.test_flag
# The user wants to replenish the coffee supply
message.text = "/replenish"
message.entities[0].length = len("/replenish")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.REPLENISHING
# check that we're in the right state now by checking that the update is accepted
message.text = "/pourCoffee"
message.entities[0].length = len("/pourCoffee")
assert handler.check_update(Update(0, message=message))
assert not self.test_flag
# The user wants to drink their coffee again
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.DRINKING
assert not self.test_flag
# The user is now ready to start coding
message.text = "/startCoding"
message.entities[0].length = len("/startCoding")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.CODING
assert not self.test_flag
# The user decides it's time to drink again
message.text = "/drinkMore"
message.entities[0].length = len("/drinkMore")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.DRINKING
assert not self.test_flag
# The user is holding their cup
message.text = "/hold"
message.entities[0].length = len("/hold")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.HOLDING
assert not self.test_flag
# The user wants to end with the drinking and go back to coding
message.text = "/end"
message.entities[0].length = len("/end")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.END
# check that we're in the right state now by checking that the update is accepted
message.text = "/drinkMore"
message.entities[0].length = len("/drinkMore")
assert handler.check_update(Update(0, message=message))
assert not self.test_flag
# The user wants to drink once more
message.text = "/drinkMore"
message.entities[0].length = len("/drinkMore")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.DRINKING
assert not self.test_flag
# The user wants to stop altogether
message.text = "/stop"
message.entities[0].length = len("/stop")
await app.process_update(Update(update_id=0, message=message))
assert self.current_state[user1.id] == self.STOPPING
# check that the conv has ended by checking that the start cmd is accepted
message.text = "/start"
message.entities[0].length = len("/start")
assert handler.check_update(Update(0, message=message))
assert not self.test_flag
@pytest.mark.parametrize("callback_raises", [True, False])
async def test_timeout_non_block(self, app, user1, callback_raises):
event = asyncio.Event()
async def callback(_, __):
await event.wait()
if callback_raises:
raise RuntimeError
return 1
conv_handler = ConversationHandler(
entry_points=[MessageHandler(filters.ALL, callback=callback, block=False)],
states={ConversationHandler.TIMEOUT: [TypeHandler(Update, self.passout2)]},
fallbacks=[],
conversation_timeout=0.5,
)
app.add_handler(conv_handler)
async with app:
await app.start()
message = Message(
0,
None,
self.group,
text="/start",
from_user=user1,
)
assert conv_handler.check_update(Update(0, message=message))
await app.process_update(Update(0, message=message))
await asyncio.sleep(0.7)
assert not self.is_timeout
event.set()
await asyncio.sleep(0.7)
assert self.is_timeout == (not callback_raises)
await app.stop()
async def test_no_timeout_on_end(self, app, user1):
conv_handler = ConversationHandler(
entry_points=[MessageHandler(filters.ALL, callback=self.start_end)],
states={ConversationHandler.TIMEOUT: [TypeHandler(Update, self.passout2)]},
fallbacks=[],
conversation_timeout=0.5,
)
app.add_handler(conv_handler)
async with app:
await app.start()
message = Message(
0,
None,
self.group,
text="/start",
from_user=user1,
)
assert conv_handler.check_update(Update(0, message=message))
await app.process_update(Update(0, message=message))
await asyncio.sleep(0.7)
assert not self.is_timeout
await app.stop()
async def test_conversation_handler_block_dont_override(self, app):
"""This just makes sure that we don't change any attributes of the handlers of the conv"""
conv_handler = ConversationHandler(
entry_points=self.entry_points,
states=self.states,
fallbacks=self.fallbacks,
block=False,
)
all_handlers = conv_handler.entry_points + conv_handler.fallbacks
for state_handlers in conv_handler.states.values():
all_handlers += state_handlers
for handler in all_handlers:
assert handler.block
conv_handler = ConversationHandler(
entry_points=[CommandHandler("start", self.start_end, block=False)],
states={1: [CommandHandler("start", self.start_end, block=False)]},
fallbacks=[CommandHandler("start", self.start_end, block=False)],
block=True,
)
all_handlers = conv_handler.entry_points + conv_handler.fallbacks
for state_handlers in conv_handler.states.values():
all_handlers += state_handlers
for handler in all_handlers:
assert handler.block is False
@pytest.mark.parametrize("default_block", [True, False, None])
@pytest.mark.parametrize("ch_block", [True, False, None])
@pytest.mark.parametrize("handler_block", [True, False, None])
@pytest.mark.parametrize("ext_bot", [True, False], ids=["ExtBot", "Bot"])
async def test_blocking_resolution_order(
self, bot_info, default_block, ch_block, handler_block, ext_bot
):
event = asyncio.Event()
async def callback(_, __):
await event.wait()
event.clear()
self.test_flag = True
return 1
if handler_block is not None:
handler = CommandHandler("start", callback=callback, block=handler_block)
fallback = MessageHandler(filters.ALL, callback, block=handler_block)
else:
handler = CommandHandler("start", callback=callback)
fallback = MessageHandler(filters.ALL, callback, block=handler_block)
defaults = Defaults(block=default_block) if default_block is not None else None
if ch_block is not None:
conv_handler = ConversationHandler(
entry_points=[handler],
states={1: [handler]},
fallbacks=[fallback],
block=ch_block,
)
else:
conv_handler = ConversationHandler(
entry_points=[handler],
states={1: [handler]},
fallbacks=[fallback],
)
bot = make_bot(bot_info, defaults=defaults) if ext_bot else PytestBot(bot_info["token"])
app = ApplicationBuilder().bot(bot).build()
app.add_handler(conv_handler)
async with app:
start_message = make_command_message("/start")
start_message.set_bot(bot)
fallback_message = make_command_message("/fallback")
fallback_message.set_bot(bot)
# This loop makes sure that we test all of entry points, states handler & fallbacks
for message in [start_message, fallback_message]:
process_update_task = asyncio.create_task(
app.process_update(Update(0, message=message))
)
if (
# resolution order is handler_block -> ch_block -> default_block
# setting block=True/False on a lower priority setting may only have an effect
# if it wasn't set for the higher priority settings
(handler_block is False)
or ((handler_block is None) and (ch_block is False))
or (
(handler_block is None)
and (ch_block is None)
and ext_bot
and (default_block is False)
)
):
# check that the handler was called non-blocking by checking that
# `process_update` has finished
await asyncio.sleep(0.01)
assert process_update_task.done()
else:
# the opposite
assert not process_update_task.done()
# In any case, the callback must not have finished
assert not self.test_flag
# After setting the event, the callback must have finished and in the blocking
# case this leads to `process_update` finishing.
event.set()
await asyncio.sleep(0.01)
assert process_update_task.done()
assert self.test_flag
self.test_flag = False
async def test_waiting_state(self, app, user1):
event = asyncio.Event()
async def callback_1(_, __):
self.test_flag = 1
async def callback_2(_, __):
self.test_flag = 2
async def callback_3(_, __):
self.test_flag = 3
async def blocking(_, __):
await event.wait()
return 1
conv_handler = ConversationHandler(
entry_points=[MessageHandler(filters.ALL, callback=blocking, block=False)],
states={
ConversationHandler.WAITING: [
MessageHandler(filters.Regex("1"), callback_1),
MessageHandler(filters.Regex("2"), callback_2),
],
1: [MessageHandler(filters.Regex("2"), callback_3)],
},
fallbacks=[],
)
app.add_handler(conv_handler)
message = Message(
0,
None,
self.group,
text="/start",
from_user=user1,
)
message._unfreeze()
async with app:
await app.process_update(Update(0, message=message))
assert not self.test_flag
message.text = "1"
await app.process_update(Update(0, message=message))
assert self.test_flag == 1
message.text = "2"
await app.process_update(Update(0, message=message))
assert self.test_flag == 2
event.set()
await asyncio.sleep(0.05)
self.test_flag = None
await app.process_update(Update(0, message=message))
assert self.test_flag == 3