Repository: python-telegram-bot/python-telegram-bot

Issue:
how to set conversation no-operation timeout
<!--
Thanks for reporting issues of python-telegram-bot!
To make it easier for us to help you please enter detailed information below.

Please note, we only support the latest version of python-telegram-bot and
master branch. Please make sure to upgrade & recreate the issue on the latest
version prior to opening an issue.
-->
### Steps to reproduce
1. use telegram.ext.conversationhandler

2. set run_async_timeout = 90

### Expected behaviour
Tell us what should happen
after 90s, the conversation should be stopped
### Actual behaviour
Tell us what happens instead
after 90s, i still in the conversation and can use command set in conversation states list
### Configuration
**Operating System:**
arch linux arm, PRi 3

**Version of Python, python-telegram-bot & dependencies:**

python 3.6.1 python-telegram-bot 6.0.3

### Logs
Insert logs here (if necessary)


i want to know how to set no-operation timeout but after reading documents, i don't find or i just find `run_async_timeout`


Discussion:
@Sherlock-Holo There is no such feature.
telegram doesn't have API about it？

Jannes Höke <notifications@github.com>于2017年6月6日周二 22:03写道：

> @Sherlock-Holo <https://github.com/sherlock-holo> There is no such
> feature.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/python-telegram-bot/python-telegram-bot/issues/648#issuecomment-306492210>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AJoPKT0oFy2VKamfR_STF__zfxwgcf6Nks5sBVk1gaJpZM4NxXX3>
> .
>

@Sherlock-Holo The conversation handler is not part of the Telegram Bot API. It's a library feature.
got it, is there any plan to support no-operation timeout？

Jannes Höke <notifications@github.com>于2017年6月7日周三 00:55写道：

> @Sherlock-Holo <https://github.com/sherlock-holo> The conversation
> handler is not part of the Telegram Bot API. It's a library feature.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/python-telegram-bot/python-telegram-bot/issues/648#issuecomment-306549637>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AJoPKSQo7Wdv6FbfnDunbD9gPIHaIIpSks5sBYSMgaJpZM4NxXX3>
> .
>

i read the tg API documents, message have 'date' field, can we add time check, i mean we can get current time and compare it with message time, if it is longer than timeout which user sets, we can return  a state means timeout and let user define action how to deal it
@Sherlock-Holo I agree, and we could use the job queue to implement this. That's why I tagged it as enhancement :)
oh, I am very happy to hear that😋👍

Jannes Höke <notifications@github.com>于2017年6月7日周三 14:47写道：

> @Sherlock-Holo <https://github.com/sherlock-holo> I agree, and we could
> use the job queue to implement this. That's why I tagged it as enhancement
> :)
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/python-telegram-bot/python-telegram-bot/issues/648#issuecomment-306702806>,
> or mute the thread
> <https://github.com/notifications/unsubscribe-auth/AJoPKRupmQlougnUuGNG1zV2crD2ZBwpks5sBkeGgaJpZM4NxXX3>
> .
>


Resolving patch:
diff --git a/telegram/ext/conversationhandler.py b/telegram/ext/conversationhandler.py
--- a/telegram/ext/conversationhandler.py
+++ b/telegram/ext/conversationhandler.py
@@ -55,7 +55,7 @@ class ConversationHandler(Handler):
     To change the state of conversation, the callback function of a handler must return the new
     state after responding to the user. If it does not return anything (returning ``None`` by
     default), the state will not change. To end the conversation, the callback function must
-    return :attr`END` or ``-1``.
+    return :attr:`END` or ``-1``.
 
     Attributes:
         entry_points (List[:class:`telegram.ext.Handler`]): A list of ``Handler`` objects that can
@@ -76,6 +76,9 @@ class ConversationHandler(Handler):
         per_user (:obj:`bool`): Optional. If the conversationkey should contain the User's ID.
         per_message (:obj:`bool`): Optional. If the conversationkey should contain the Message's
             ID.
+        conversation_timeout (:obj:`float`|:obj:`datetime.timedelta`): Optional. When this handler
+            is inactive more than this timeout (in seconds), it will be automatically ended. If
+            this value is 0 (default), there will be no timeout.
 
     Args:
         entry_points (List[:class:`telegram.ext.Handler`]): A list of ``Handler`` objects that can
@@ -107,6 +110,9 @@ class ConversationHandler(Handler):
             Default is ``True``.
         per_message (:obj:`bool`, optional): If the conversationkey should contain the Message's
             ID. Default is ``False``.
+        conversation_timeout (:obj:`float`|:obj:`datetime.timedelta`, optional): When this handler
+            is inactive more than this timeout (in seconds), it will be automatically ended. If
+            this value is 0 or None (default), there will be no timeout.
 
     Raises:
         ValueError
@@ -124,7 +130,8 @@ def __init__(self,
                  timed_out_behavior=None,
                  per_chat=True,
                  per_user=True,
-                 per_message=False):
+                 per_message=False,
+                 conversation_timeout=None):
 
         self.entry_points = entry_points
         self.states = states
@@ -136,7 +143,9 @@ def __init__(self,
         self.per_user = per_user
         self.per_chat = per_chat
         self.per_message = per_message
+        self.conversation_timeout = conversation_timeout
 
+        self.timeout_jobs = dict()
         self.conversations = dict()
         self.current_conversation = None
         self.current_handler = None
@@ -294,6 +303,16 @@ def handle_update(self, update, dispatcher):
 
         """
         new_state = self.current_handler.handle_update(update, dispatcher)
+        timeout_job = self.timeout_jobs.get(self.current_conversation)
+
+        if timeout_job is not None or new_state == self.END:
+            timeout_job.schedule_removal()
+            del self.timeout_jobs[self.current_conversation]
+        if self.conversation_timeout and new_state != self.END:
+            self.timeout_jobs[self.current_conversation] = dispatcher.job_queue.run_once(
+                self._trigger_timeout, self.conversation_timeout,
+                context=self.current_conversation
+            )
 
         self.update_state(new_state, self.current_conversation)
 
@@ -309,3 +328,6 @@ def update_state(self, new_state, key):
 
         elif new_state is not None:
             self.conversations[key] = new_state
+
+    def _trigger_timeout(self, bot, job):
+        self.update_state(self.END, job.context)


Test patch:
diff --git a/tests/test_conversationhandler.py b/tests/test_conversationhandler.py
--- a/tests/test_conversationhandler.py
+++ b/tests/test_conversationhandler.py
@@ -294,3 +294,50 @@ def test_all_update_types(self, dp, bot, user1):
         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))
+
+    def test_conversation_timeout(self, dp, bot, user1):
+        handler = ConversationHandler(entry_points=self.entry_points, states=self.states,
+                                      fallbacks=self.fallbacks, conversation_timeout=0.5)
+        dp.add_handler(handler)
+
+        # Start state machine, then reach timeout
+        message = Message(0, user1, None, self.group, text='/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)
+        dp.job_queue.tick()
+        assert handler.conversations.get((self.group.id, user1.id)) is None
+
+        # Start state machine, do something, then reach timeout
+        dp.process_update(Update(update_id=0, message=message))
+        assert handler.conversations.get((self.group.id, user1.id)) == self.THIRSTY
+        message.text = '/brew'
+        dp.job_queue.tick()
+        dp.process_update(Update(update_id=0, message=message))
+        assert handler.conversations.get((self.group.id, user1.id)) == self.BREWING
+        sleep(0.5)
+        dp.job_queue.tick()
+        assert handler.conversations.get((self.group.id, user1.id)) is None
+
+    def test_conversation_timeout_two_users(self, dp, bot, user1, user2):
+        handler = ConversationHandler(entry_points=self.entry_points, states=self.states,
+                                      fallbacks=self.fallbacks, conversation_timeout=0.5)
+        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)
+        dp.process_update(Update(update_id=0, message=message))
+        assert handler.conversations.get((self.group.id, user1.id)) == self.THIRSTY
+        message.text = '/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'
+        dp.job_queue.tick()
+        dp.process_update(Update(update_id=0, message=message))
+        assert handler.conversations.get((self.group.id, user2.id)) == self.THIRSTY
+        sleep(0.5)
+        dp.job_queue.tick()
+        assert handler.conversations.get((self.group.id, user1.id)) is None
+        assert handler.conversations.get((self.group.id, user2.id)) is None