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

Issue:
[FEATURE] Auto-pagination for inline results
#### Is your feature request related to a problem? Please describe.
TG requires pagination for inline results using the `next_offset` parameter. Currently this has to be implemented manually, but the logic is always the same, so it's basically copy paste.

I think it would fit well into PTBs "more than just a wrapper" credo to provide a convenience method for that.

#### Describe the solution you'd like

Add a parameters `pagination` and `current_offset` to `answer_inline_query` to make PTB take care of that automically. it should be along the lines

``` python
offset = current_offset
max_length = 50
next_offset = None

if not offset:
    if len(results) > max_length :
        next_offset = len(results) - max_length 
        next_offset = next_offset or None
        results = results[:max_length]
elif len(results) > offset:
    results = results[offset:offset + max_length]
    if len(results) > offset + max_length:
        next_offset = offset + max_length
else:
    next_offset = ''
```

#### Describe alternatives you've considered
If we don't want to integrate this directly into `answer_inline_query`, we could at least add a function to `utils.helpers` , along the lines
```python
def paginate_inline_results(reslts: List[InlineQueryResult], current_offset: int) -> {'results': …, 'next_offset': …}
```
so one could call it like

```python
answer_inline_query(**paginate_inline_results(results, offset), …)
```



Discussion:
@Bibo-Joshi I think I would preferer an opt out implementation with a flag into the function
Consider that expensive result generators might slow the execution down and be wasteful overall. I love the idea, and maybe an inbetween step should be a result generator that is passed the current `page` index, so that the developer can avoid overfetching but doesn't have to bother with the offset parameter. 

```
ResultFetcher = Callable[[int], Iterable[InlineResult]]
```


```
answer_ilq(my_get_more_results_callback)
```

This would become especially useful in an asyncio environment (in the future).
> Consider that expensive result generators might slow the execution down and be wasteful overall.

You mean "don't truncate a list of results, but just build the required results in the first place"? Makes sense for advanced use cases, for sure.

My concern with adding parameters to `answer_ilq` is that we're currently not fiddling with the official methods too much (everything has `timeout`, `Message` classmethods have `quote` and for some parameters we are more genereous, like `open()` instead of `InputFile` and `['button']` instead of `[ReplyButton()]`) and I wouldn't wanna go in a direction where just add a bunch of parameters everywhere. That's why I wanted this to be discussed before PRing :D

Adding to `answer_ilq`, we could go with adding `current_offset` and allowing for `results` to be a callback as @JosXa suggested. Then passing `current_offset` would mean that auto-pagination is to be used and only then would a callback be acceptable for `results`. This would me relatively minimal invasive.
In the `utils.helpers` scenario, we could basically do the same, or make `results` and `results_generator` two mutually exclusive parameters
> Adding to answer_ilq, we could go with adding current_offset and allowing for results to be a callback as @JosXa suggested. Then passing current_offset would mean that auto-pagination is to be used and only then would a callback be acceptable for results. 

For the `InlineQuery.answer()` shortcut we would have to replace the `current_offset` parameter by an `auto_pagination` boolean flag (or similar naming), as we'd use `current_offset=self.next_offset` for `self.bot.answer_inline_query` automatically.

Resolving patch:
diff --git a/telegram/bot.py b/telegram/bot.py
--- a/telegram/bot.py
+++ b/telegram/bot.py
@@ -39,6 +39,7 @@
                       ReplyMarkup, TelegramObject, WebhookInfo, GameHighScore, StickerSet,
                       PhotoSize, Audio, Document, Sticker, Video, Animation, Voice, VideoNote,
                       Location, Venue, Contact, InputFile, Poll, BotCommand)
+from telegram.constants import MAX_INLINE_QUERY_RESULTS
 from telegram.error import InvalidToken, TelegramError
 from telegram.utils.helpers import to_timestamp, DEFAULT_NONE
 from telegram.utils.request import Request
@@ -1526,15 +1527,24 @@ def answer_inline_query(self,
                             switch_pm_text=None,
                             switch_pm_parameter=None,
                             timeout=None,
+                            current_offset=None,
                             **kwargs):
         """
         Use this method to send answers to an inline query. No more than 50 results per query are
         allowed.
 
+        Warning:
+            In most use cases :attr:`current_offset` should not be passed manually. Instead of
+            calling this method directly, use the shortcut :meth:`telegram.InlineQuery.answer` with
+            ``auto_pagination=True``, which will take care of passing the correct value.
+
         Args:
             inline_query_id (:obj:`str`): Unique identifier for the answered query.
-            results (List[:class:`telegram.InlineQueryResult`)]: A list of results for the inline
-                query.
+            results (List[:class:`telegram.InlineQueryResult`] | Callable): A list of results for
+                the inline query. In case :attr:`current_offset` is passed, ``results`` may also be
+                a callable accepts the current page index starting from 0. It must return either a
+                list of :class:`telegram.InlineResult` instances or :obj:`None` if there are no
+                more results.
             cache_time (:obj:`int`, optional): The maximum amount of time in seconds that the
                 result of the inline query may be cached on the server. Defaults to 300.
             is_personal (:obj:`bool`, optional): Pass :obj:`True`, if results may be cached on
@@ -1550,6 +1560,10 @@ def answer_inline_query(self,
             switch_pm_parameter (:obj:`str`, optional): Deep-linking parameter for the /start
                 message sent to the bot when user presses the switch button. 1-64 characters,
                 only A-Z, a-z, 0-9, _ and - are allowed.
+            current_offset (:obj:`str`, optional): The :attr:`telegram.InlineQuery.offset` of
+                the inline query to answer. If passed, PTB will automatically take care of
+                the pagination for you, i.e. pass the correct ``next_offset`` and truncate the
+                results list/get the results from the callable you passed.
             timeout (:obj:`int` | :obj:`float`, optional): If this value is specified, use it as
                 the read timeout from the server (instead of the one specified during creation of
                 the connection pool).
@@ -1573,7 +1587,35 @@ def answer_inline_query(self,
         """
         url = '{}/answerInlineQuery'.format(self.base_url)
 
-        for res in results:
+        if current_offset is not None and next_offset is not None:
+            raise ValueError('`current_offset` and `next_offset` are mutually exclusive!')
+
+        if current_offset is not None:
+            if current_offset == '':
+                current_offset = 0
+            else:
+                current_offset = int(current_offset)
+
+            next_offset = ''
+
+            if callable(results):
+                effective_results = results(current_offset)
+                if not effective_results:
+                    effective_results = []
+                else:
+                    next_offset = current_offset + 1
+            else:
+                if len(results) > (current_offset + 1) * MAX_INLINE_QUERY_RESULTS:
+                    next_offset = current_offset + 1
+                    effective_results = results[
+                        current_offset * MAX_INLINE_QUERY_RESULTS:
+                        next_offset * MAX_INLINE_QUERY_RESULTS]
+                else:
+                    effective_results = results[current_offset * MAX_INLINE_QUERY_RESULTS:]
+        else:
+            effective_results = results
+
+        for res in effective_results:
             if res._has_parse_mode and res.parse_mode == DEFAULT_NONE:
                 if self.defaults:
                     res.parse_mode = self.defaults.parse_mode
@@ -1594,10 +1636,8 @@ def answer_inline_query(self,
                     else:
                         res.input_message_content.disable_web_page_preview = None
 
-        results = [res.to_dict() for res in results]
-
-        data = {'inline_query_id': inline_query_id, 'results': results}
-
+        effective_results = [res.to_dict() for res in effective_results]
+        data = {'inline_query_id': inline_query_id, 'results': effective_results}
         if cache_time or cache_time == 0:
             data['cache_time'] = cache_time
         if is_personal:
diff --git a/telegram/inline/inlinequery.py b/telegram/inline/inlinequery.py
--- a/telegram/inline/inlinequery.py
+++ b/telegram/inline/inlinequery.py
@@ -75,14 +75,21 @@ def de_json(cls, data, bot):
 
         return cls(bot=bot, **data)
 
-    def answer(self, *args, **kwargs):
+    def answer(self, *args, auto_pagination=False, **kwargs):
         """Shortcut for::
 
-            bot.answer_inline_query(update.inline_query.id, *args, **kwargs)
+            bot.answer_inline_query(update.inline_query.id,
+                                    *args,
+                                    current_offset=self.offset if auto_pagination else None,
+                                    **kwargs)
 
         Args:
-            results (List[:class:`telegram.InlineQueryResult`]): A list of results for the inline
-                query.
+            results (List[:class:`telegram.InlineQueryResult`] | Callable): A list of results for
+                the inline query. In case :attr:`auto_pagination` is set to :obj:`True`,
+                ``results`` may also be a callable may also be a callable accepts the current page
+                index starting from 0. It must return either a list of
+                :class:`telegram.InlineResult` instances or :obj:`None` if there are no more
+                results.
             cache_time (:obj:`int`, optional): The maximum amount of time in seconds that the
                 result of the inline query may be cached on the server. Defaults to 300.
             is_personal (:obj:`bool`, optional): Pass :obj:`True`, if results may be cached on the
@@ -98,6 +105,14 @@ def answer(self, *args, **kwargs):
             switch_pm_parameter (:obj:`str`, optional): Deep-linking parameter for the /start
                 message sent to the bot when user presses the switch button. 1-64 characters,
                 only A-Z, a-z, 0-9, _ and - are allowed.
+            auto_pagination (:obj:`bool`, optional): If set to :obj:`True`, :attr:`offset` will be
+                passed as :attr:`current_offset` to :meth:telegram.Bot.answer_inline_query`.
+                Defaults to :obj:`False`.
 
         """
-        return self.bot.answer_inline_query(self.id, *args, **kwargs)
+        return self.bot.answer_inline_query(
+            self.id,
+            *args,
+            current_offset=self.offset if auto_pagination else None,
+            **kwargs
+        )


Test patch:
diff --git a/tests/test_bot.py b/tests/test_bot.py
--- a/tests/test_bot.py
+++ b/tests/test_bot.py
@@ -27,6 +27,7 @@
                       InlineKeyboardButton, InlineQueryResultArticle, InputTextMessageContent,
                       ShippingOption, LabeledPrice, ChatPermissions, Poll, BotCommand,
                       InlineQueryResultDocument, Dice, MessageEntity, ParseMode)
+from telegram.constants import MAX_INLINE_QUERY_RESULTS
 from telegram.error import BadRequest, InvalidToken, NetworkError, RetryAfter
 from telegram.utils.helpers import from_timestamp, escape_markdown
 from tests.conftest import expect_bad_request
@@ -54,6 +55,20 @@ def chat_permissions():
     return ChatPermissions(can_send_messages=False, can_change_info=False, can_invite_users=False)
 
 
+def inline_results_callback(page=None):
+    if not page:
+        return [InlineQueryResultArticle(i, str(i), None) for i in range(1, 254)]
+    elif page <= 5:
+        return [InlineQueryResultArticle(i, str(i), None)
+                for i in range(page * 5 + 1, (page + 1) * 5 + 1)]
+    return None
+
+
+@pytest.fixture(scope='class')
+def inline_results():
+    return inline_results_callback()
+
+
 class TestBot:
     @pytest.mark.parametrize('token', argvalues=[
         '123',
@@ -385,6 +400,86 @@ def test(_, url, data, *args, **kwargs):
                                                switch_pm_text='switch pm',
                                                switch_pm_parameter='start_pm')
 
+    def test_answer_inline_query_current_offset_error(self, bot, inline_results):
+        with pytest.raises(ValueError, match=('`current_offset` and `next_offset`')):
+            bot.answer_inline_query(1234,
+                                    results=inline_results,
+                                    next_offset=42,
+                                    current_offset=51)
+
+    @pytest.mark.parametrize('current_offset,num_results,id_offset,expected_next_offset',
+                             [('', MAX_INLINE_QUERY_RESULTS, 1, 1),
+                              (1, MAX_INLINE_QUERY_RESULTS, 51, 2),
+                              (5, 3, 251, '')])
+    def test_answer_inline_query_current_offset_1(self,
+                                                  monkeypatch,
+                                                  bot,
+                                                  inline_results,
+                                                  current_offset,
+                                                  num_results,
+                                                  id_offset,
+                                                  expected_next_offset):
+        # For now just test that our internals pass the correct data
+        def make_assertion(_, url, data, *args, **kwargs):
+            results = data['results']
+            length_matches = len(results) == num_results
+            ids_match = all([int(res['id']) == id_offset + i for i, res in enumerate(results)])
+            next_offset_matches = data['next_offset'] == expected_next_offset
+            return length_matches and ids_match and next_offset_matches
+
+        monkeypatch.setattr('telegram.utils.request.Request.post', make_assertion)
+
+        assert bot.answer_inline_query(1234, results=inline_results, current_offset=current_offset)
+
+    def test_answer_inline_query_current_offset_2(self, monkeypatch, bot, inline_results):
+        # For now just test that our internals pass the correct data
+        def make_assertion(_, url, data, *args, **kwargs):
+            results = data['results']
+            length_matches = len(results) == MAX_INLINE_QUERY_RESULTS
+            ids_match = all([int(res['id']) == 1 + i for i, res in enumerate(results)])
+            next_offset_matches = data['next_offset'] == 1
+            return length_matches and ids_match and next_offset_matches
+
+        monkeypatch.setattr('telegram.utils.request.Request.post', make_assertion)
+
+        assert bot.answer_inline_query(1234, results=inline_results, current_offset=0)
+
+        inline_results = inline_results[:30]
+
+        def make_assertion(_, url, data, *args, **kwargs):
+            results = data['results']
+            length_matches = len(results) == 30
+            ids_match = all([int(res['id']) == 1 + i for i, res in enumerate(results)])
+            next_offset_matches = data['next_offset'] == ''
+            return length_matches and ids_match and next_offset_matches
+
+        monkeypatch.setattr('telegram.utils.request.Request.post', make_assertion)
+
+        assert bot.answer_inline_query(1234, results=inline_results, current_offset=0)
+
+    def test_answer_inline_query_current_offset_callback(self, monkeypatch, bot, caplog):
+        # For now just test that our internals pass the correct data
+        def test(_, url, data, *args, **kwargs):
+            results = data['results']
+            length = len(results) == 5
+            ids = all([int(res['id']) == 6 + i for i, res in enumerate(results)])
+            next_offset = data['next_offset'] == 2
+            return length and ids and next_offset
+
+        monkeypatch.setattr('telegram.utils.request.Request.post', test)
+
+        assert bot.answer_inline_query(1234, results=inline_results_callback, current_offset=1)
+
+        def test(_, url, data, *args, **kwargs):
+            results = data['results']
+            length = results == []
+            next_offset = data['next_offset'] == ''
+            return length and next_offset
+
+        monkeypatch.setattr('telegram.utils.request.Request.post', test)
+
+        assert bot.answer_inline_query(1234, results=inline_results_callback, current_offset=6)
+
     @flaky(3, 1)
     @pytest.mark.timeout(10)
     def test_get_user_profile_photos(self, bot, chat_id):
diff --git a/tests/test_inlinequery.py b/tests/test_inlinequery.py
--- a/tests/test_inlinequery.py
+++ b/tests/test_inlinequery.py
@@ -68,6 +68,15 @@ def test(*args, **kwargs):
         monkeypatch.setattr(inline_query.bot, 'answer_inline_query', test)
         assert inline_query.answer()
 
+    def test_answer_auto_pagination(self, monkeypatch, inline_query):
+        def make_assertion(*args, **kwargs):
+            inline_query_id_matches = args[0] == inline_query.id
+            offset_matches = kwargs.get('current_offset') == inline_query.offset
+            return offset_matches and inline_query_id_matches
+
+        monkeypatch.setattr(inline_query.bot, 'answer_inline_query', make_assertion)
+        assert inline_query.answer(auto_pagination=True)
+
     def test_equality(self):
         a = InlineQuery(self.id_, User(1, '', False), '', '')
         b = InlineQuery(self.id_, User(1, '', False), '', '')
diff --git a/tests/test_official.py b/tests/test_official.py
--- a/tests/test_official.py
+++ b/tests/test_official.py
@@ -78,6 +78,8 @@ def check_method(h4):
         ignored |= {'location'}  # Added for ease of use
     elif name == 'sendVenue':
         ignored |= {'venue'}  # Added for ease of use
+    elif name == 'answerInlineQuery':
+        ignored |= {'current_offset'}  # Added for ease of use
 
     assert (sig.parameters.keys() ^ checked) - ignored == set()