Как я пагинацию на telebot делал

Введение

Напишу сразу, о том что данную статью я изначально выложил на habr — с целью узнать популярное мнение к такому подходу, методы улучшения и просто разные мнения. Однако получил всего пять комментариев, из которых только один информативный, но не по теме. Проанализировал немного статистику и увидев, что за последний год посты новых акаунтов не читаются и не поднимаются в рейтинге. Я решил на Хабре быть ReadOnly пользователем, а здесь буду писать. Я буду рад получить критику и советы по улучшению данного метода.

Разберемся с начала, что это за статья зачем она и для кого. Пришлось мне в рамках хакатона «Поколение ИТ» писать бота для telegram. (Хотел оставить сайт организаторов, но он не работает. Хакатон организовывало министерство образования Москвы, совместно с тремя колледжами)

Но готового решения для пагинации, которое бы нам подходило мы не нашли. Поэтому было принято решение изобретать велосипед. Решение моих товарищей было максимально странным, брать количество записей и перебирать их в цикле от 1 до N (конца, записей), но данная идея сразу была отброшена. Поэтому предоставляю вашему вниманию наше творчество, которое мы изобрели.

Демонстрация результата

Покажу для начала, что мы вообще сделали и о чем пойдет речь. Времени было мало (6 часов на все про все), и нужен был алгоритм, который позволит быстро и просто реализовать пагинацию, с возможностью отправлять условия на выборку — под разные таблицы.

Рис. 1. Демонстрация результата

Создание «шаблонного» бота

Для упрощения описания алгоритма, я буду описывать его на шаблоном боте. Который будет отвечать на любое отправленное ему сообщение — «Привет»ю

import telebot; bot = telebot.TeleBot('Токен'); @bot.message_handler(content_types=['text']) def start(m): bot.send_message(m.from_user.id, "Привет"); if __name__ == '__main__': bot.polling(none_stop=True)

Создание inline кнопок

Сначало прописываем зависимость в начале кода:

from telebot.types import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton

Реализуем кнопку скрыть

Перед тем, как отправить сообщение пользователю создадим markup с inline кнопкой «скрыть» после чего отправим пользователю сообщение с данной кнопкой.

markup = InlineKeyboardMarkup() markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen')) bot.send_message(m.from_user.id, "Привет", reply_markup = markup)

Чтобы кнопка работала, нам для нее нужен отдельный обработчик он выглядит так:

@bot.callback_query_handler(func=lambda call:True) def callback_query(call): req = call.data.split('_') if req[0] == 'unseen': bot.delete_message(call.message.chat.id, call.message.message_id)

Необходимо учитывать, что здесь может возникнуть эксепшн, т. к у телеграмма есть ограничения на удаление. Например старые сообщения бот не сможет удалить. Поэтому нужно писать отдельное исключение. Но цель моей статьи заключается в том, что бы показать мой подход к демонстрации алгоритма пагинации.

Проверяем:

Рис 2. Реализации кнопки "скрыть"
Рис 2. Реализации кнопки "скрыть"

При нажатии на кнопку скрыть сообщение удаляется, а значит не будем зацикливаться больше на этом.

Кнопки навигации

Теперь перейдем непосредственно к кнопкам «Вперед« и «Назад» для перехода по страницам? которые будут располагаться под кнопкой »скрыть».

К уже существующему markup добавим еще две новые кнопки. Т. к при первом сообщение от бота приходит первая страница, добавим пока что только кнопку «Вперед».

Кнопка вперед будет в callback_data отправлять строку «next-page«, а в обработчике мы будем прибавлять к page 1. После чего пересоздадим markap уже с кнопкой назад и новым сообщением. Аналогичным образом для кнопки назад в callback_data будем отправлять строку »back-page»

Теперь наш код выглядит вот так:

На данном этапе для простоты демонстрации я делаю count и page глобальными переменными, позже я заменю их.

import telebot; from telebot.types import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton bot = telebot.TeleBot('token') page = 1 count = 10 @bot.callback_query_handler(func=lambda call:True) def callback_query(call): req = call.data.split('_') global count global page #Обработка кнопки - скрыть if req[0] == 'unseen': bot.delete_message(call.message.chat.id, call.message.message_id) #Обработка кнопки - вперед elif req[0] == 'next-page': if page < count: page = page + 1 markup = InlineKeyboardMarkup() markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen')) markup.add(InlineKeyboardButton(text=f'<--- Назад', callback_data=f'back-page'),InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '), InlineKeyboardButton(text=f'Вперёд --->', callback_data=f'next-page')) bot.edit_message_text(f'Страница {page} из {count}', reply_markup = markup, chat_id=call.message.chat.id, message_id=call.message.message_id) #Обработка кнопки - назад elif req[0] == 'back-page': if page > 1: page = page - 1 markup = InlineKeyboardMarkup() markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen')) markup.add(InlineKeyboardButton(text=f'<--- Назад', callback_data=f'back-page'),InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '), InlineKeyboardButton(text=f'Вперёд --->', callback_data=f'next-page')) bot.edit_message_text(f'Страница {page} из {count}', reply_markup = markup, chat_id=call.message.chat.id, message_id=call.message.message_id) #Обработчик входящих сообщений @bot.message_handler(content_types=['text']) def start(m): global count global page markup = InlineKeyboardMarkup() markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen')) markup.add(InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '), InlineKeyboardButton(text=f'Вперёд --->', callback_data=f'next-page')) bot.send_message(m.from_user.id, "Привет!!!", reply_markup = markup) if __name__ == '__main__': bot.polling(none_stop=True)

Теперь мы можем перемещаться по страницам:

Рис. 3. Демонстрация внешнего вида inline кнопок
Рис. 3. Демонстрация внешнего вида inline кнопок

Да, но перед нами встает две проблемы:

  • Нужно писать отдельные исключения, чтобы при возврате кнопкой назад на первую страницу — кнопка назад больше не отображалась. А при переходе на последнюю не было кнопки вперед.
  • Проблема передачи локальной переменной (page и count) от главной функции в обработчик нажатия кнопки. Telebot в отличие от например aiogramm не может передать другие параметры вместе с callback_data. А callback_data – это строка. Решение этой проблемы, я увидел в передаче в callback_data склеенного через разделитель json в который и запишу count и page. Мой товарищ вышел из данной ситуации более странным на мой взгляд решением – он записывал во временную таблицу бд id узера, id сообщение и страницу, которую он смотрит и потом удалял их. Достаточно радикальный способ по ряду многих причин (что если не удалиться запись из БД; БД вообще не для этого сделана; нам нужно будет столько таблиц, во скольких местах будет пагинация), но переубедить я его не смог) .Как вариант еще можно создать публичный словарь, но ради двух переменных это странно + способ с передачей json с двумя параметрами, как строку в callback_data на мой взгляд кажется самым универсальным и адекватным решением при данной проблеме.

Для начала решим 2 проблему:

  • Удалим глобальные переменный page и count и создадим их внутри функции start
  • В callback будем отправлять такую строку:

    {'method': 'page', 'NumberPage': 1, 'CountPage': 10}

Для кнопки вперед, это выглядит вот так:

markup.add(InlineKeyboardButton(text=f'Вперёд --->', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page+1) + ",\"CountPage\":" + str(count) + "}"))

Для кнопки назад, это выглядит, так:

markup.add(InlineKeyboardButton(text=f'Вперёд --->', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page-1) + ",\"CountPage\":" + str(count) + "}"))

На данном этапе видно, что я начал отдавать номер строки, на которую перехожу. Если нажму "Назад" со второй строки в обработчик уйдет 1. Это избавляет меня от необходимости использовать теперь разные обработчик для кнопки назад (back-page) и вперед (next-page) (их можно просто удалить)

Теперь за пагинацию будет отвечать новый обработчик, который увидит в полученной строке вхождение 'pagination':

После чего строка полученная в callback_data будет распаршена в json. И уже из JSON мы получим необходимые нам Count и Page.

elif 'pagination' in req[0]: json_string = json.loads(req[0]) count = json_string['CountPage'] page = json_string['NumberPage']

Пока пишу новый обработчик, сразу решу проблему 1. Сделаю три условия вывода кнопок.

  • Вывод кнопки "Назад" для последний страницы

  • Вывод "Вперед" и "Назад" для всех страниц между первой и последней

  • Вывод кнопки "Назад" для последний страницы
Рис. 4. Демонстрация конечного этапа реализации кнопок пагинации
Рис. 4. Демонстрация конечного этапа реализации кнопок пагинации

А вот и полученный код:

import json import telebot; from telebot.types import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton bot = telebot.TeleBot('TOKEN') @bot.callback_query_handler(func=lambda call:True) def callback_query(call): req = call.data.split('_') #Обработка кнопки - скрыть if req[0] == 'unseen': bot.delete_message(call.message.chat.id, call.message.message_id) #Обработка кнопок - вперед и назад elif 'pagination' in req[0]: #Расспарсим полученный JSON json_string = json.loads(req[0]) count = json_string['CountPage'] page = json_string['NumberPage'] #Пересоздаем markup markup = InlineKeyboardMarkup() markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen')) #markup для первой страницы if page == 1: markup.add(InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '), InlineKeyboardButton(text=f'Вперёд --->', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str( page + 1) + ",\"CountPage\":" + str(count) + "}")) #markup для второй страницы elif page == count: markup.add(InlineKeyboardButton(text=f'<--- Назад', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str( page - 1) + ",\"CountPage\":" + str(count) + "}"), InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' ')) #markup для остальных страниц else: markup.add(InlineKeyboardButton(text=f'<--- Назад', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page-1) + ",\"CountPage\":" + str(count) + "}"), InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '), InlineKeyboardButton(text=f'Вперёд --->', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page+1) + ",\"CountPage\":" + str(count) + "}")) bot.edit_message_text(f'Страница {page} из {count}', reply_markup = markup, chat_id=call.message.chat.id, message_id=call.message.message_id) @bot.message_handler(content_types=['text']) def start(m): count = 10 page = 1 markup = InlineKeyboardMarkup() markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen')) markup.add(InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '), InlineKeyboardButton(text=f'Вперёд --->', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page+1) + ",\"CountPage\":" + str(count) + "}")) bot.send_message(m.from_user.id, "Привет!!!", reply_markup = markup) if __name__ == '__main__': bot.polling(none_stop=True)

Желательно в обработчик добавить исключение, что page > 0 и page <=count, а так же строку c bot.edit_message_text занести в блок try. На случай, если телеграмм зависнет и пользователь, сможет сделать двойной клик по одной кнопке. Но статья направлена на описания подхода реализации. Поэтому на этом я не буду останавливаться.

Получения нужных строк по страницам из БД

Вернемся к "главной" функции start, которая принимает сообщения от пользователя. Я оставил count = 10 и page = 1; Пора это исправить!

count - необходимо будет получать из БД, а в сообщение пользователю отдавать необходимые строки.

Для этого создадим новый класс database, который будет отвечать за подключение к БД и вывод нужных строк из БД.

Что бы выводить построчно записи из БД я буду использовать конструкцию SQL: OFFSET-FETCH. Она предназначена, как раз для разбиения результирующего набора на части (страницы)

У меня с хакатона осталась таблица учебных организаций Москвы. На ней и покажу, как это будет выглядеть:

Рис. 5. Демонстрация запроса с использованием конструкции OFFSET-FETCH.
Рис. 5. Демонстрация запроса с использованием конструкции OFFSET-FETCH.

Делаем сортировку по id и отображаем 15 записей от 0 (т.е самой первой). Это будет наша первая страница, что бы показать 2 страницу нужно будет пропустить столько записей, сколько было на первой странице.

Таким образом число после NEXT – является всегда статичным это будет переменная SkipSize. А число после OFFSET - то после которой надо забрать следующие (SkipSize) строк. Записать это можно, как (Номер страницы – 1)*SkipSize

На псевдо-коде это выглядит вот так:

Рис. 6. Реализация запроса, для пагинации на псевдо коде.
Рис. 6. Реализация запроса, для пагинации на псевдо коде.

Теперь перейдем к написанию самого класса. Он вышел вот таким:

import psycopg2 from psycopg2 import sql from psycopg2._psycopg import AsIs class Database: def __init__(self): self.conn = psycopg2.connect(database='myDataBase', user='MyUsers', password=SECRET', host='ip_host', port=5432) self.cursor = self.conn.cursor() #Функцию пытался сделать максимально адаптивной под разные потребности и таблицы, поэтому у нее есть такие параметры, как: # tables – имя самой таблицы # schema – схема, по умолчанию organization т.к большая часть таблиц лежит именно в этой схеме # Page – непосредственно номер страницы, который нужно вывести # SkipSize – сколько строк, необходимо вывести # order – аргумент, по которому происходит сортировка # where строка в которую можно передать строку where (по хорошему так делать не надо, это слишком костыльно) def listColledjeForPage(self, tables, order, schema='organization', Page=1, SkipSize=1, wheres=''): sql = f"""select * from %(schemas)s.%(tables)s o %(wheres)s ORDER BY o.%(orders)s OFFSET %(skipsPage)s ROWS FETCH NEXT %(SkipSizes)s ROWS only;""" self.cursor.execute(sql, {'schemas': AsIs(schema), 'tables': AsIs(tables), 'orders': AsIs(order), 'skipsPage': ((Page - 1) * SkipSize), 'SkipSizes': SkipSize, 'wheres': AsIs(wheres)}) res = self.cursor.fetchall() return res, len(res)

Теперь в main нам надо объявить наш новый класс:

from database import Database database = Database()

А вот так к нему можно обратиться:

stringsearch = 'колледж связи' sqlTransaction = database.listColledjeForPage(tables = 'organization', order='title_full', Page=1, SkipSize=1, wheres=f"where lower(title_full) like lower('%{stringsearch}%') OR lower(title) like lower('%{stringsearch}%')") data = sqlTransaction[0] #Набор строк count = sqlTransaction[1] #Количество строк print(data) print(count)

Или вот так

sqlTransaction = database.listColledjeForPage(tables = 'organization', order='title_full', Page=1, SkipSize=15) data = sqlTransaction[0] # Набор строк count = sqlTransaction[1] # Количество строк print(data) print(count)

Вот такой вывод получаем по итогу:

Рис. 7. Ответ от функции на запрос 15 записей с 1 страницы из БД
Рис. 7. Ответ от функции на запрос 15 записей с 1 страницы из БД

И тут я понял, что общие количество записей так и не получил (тот самый count). В return к функции (listColledjeForPage) вывода записей добавлю еще вывод общего количества записей в запросе.

self.cursor.execute(f"""select Count(*) from %(schemas)s.%(tables)s o %(wheres)s;""", {'schemas': AsIs(schema), 'tables': AsIs(tables),'wheres': AsIs(wheres)}) count = self.cursor.fetchone()[0] return res, len(res), count

Я еще раз убедился, что все работает и пошел дописывать main

Сделаем вывод уже полученной строки из нашего нового класса. В "главной функции" переменную page оставляем равной 1, а count получаем из функции класса database, которая выполняет нужный нам запрос.

page = 1 sqlTransaction = database.listColledjeForPage(tables = 'organization', order='title', Page=1, SkipSize=1) # SkipSize - т.к я буду отображать по одной записи data = sqlTransaction[0] # Набор строк count = sqlTransaction[2] # Количество строк print() markup = InlineKeyboardMarkup() markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen')) markup.add(InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '), InlineKeyboardButton(text=f'Вперёд --->', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page+1) + ",\"CountPage\":" + str(count) + "}")) bot.send_message(m.from_user.id, str(data[0]), reply_markup = markup)

Вот, что получили:

Рис. 8. Вывод информации из БД в не отформатированном виде
Рис. 8. Вывод информации из БД в не отформатированном виде

Осталось только вывести этот текст в отформатированном виде и сделать такой же вывод в обработчики, который отвечает за перелистывание страниц вперед, назад.

Форматируем тест сообщения с помощью HTML

bot.send_message(m.from_user.id, f'<b>{data[3]}</b>\n\n' f'<b>Короткое название:</b> <i>{data[4]}</i>\n' f'<b>Email:</b><i>{data[6]}</i>\n' f'<b>Сайт:</b><i> {data[8]}</i>', parse_mode="HTML", reply_markup = markup)
Рис. 9. Вывод информации из БД в отформатированном виде
Рис. 9. Вывод информации из БД в отформатированном виде

Теперь такое же форматирование вставляем в обработчик при редактировании сообщения после нажатия кнопок "Впред"/"Назад".

Итог

Рис. 10. Итоговый результат
Рис. 10. Итоговый результат

Код

main.py

import json import telebot; from telebot.types import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton from database import Database database = Database() bot = telebot.TeleBot('token') @bot.callback_query_handler(func=lambda call:True) def callback_query(call): req = call.data.split('_') if req[0] == 'unseen': bot.delete_message(call.message.chat.id, call.message.message_id) elif 'pagination' in req[0]: json_string = json.loads(req[0]) count = json_string['CountPage'] page = json_string['NumberPage'] sqlTransaction = database.listColledjeForPage(tables='organization', order='title', Page=page, SkipSize=1) # SkipSize - т.к я буду отображать по одной записи data = sqlTransaction[0][0] count = sqlTransaction[2] markup = InlineKeyboardMarkup() markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen')) if page == 1: markup.add(InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '), InlineKeyboardButton(text=f'Вперёд --->', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str( page + 1) + ",\"CountPage\":" + str(count) + "}")) elif page == count: markup.add(InlineKeyboardButton(text=f'<--- Назад', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str( page - 1) + ",\"CountPage\":" + str(count) + "}"), InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' ')) else: markup.add(InlineKeyboardButton(text=f'<--- Назад', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page-1) + ",\"CountPage\":" + str(count) + "}"), InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '), InlineKeyboardButton(text=f'Вперёд --->', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page+1) + ",\"CountPage\":" + str(count) + "}")) bot.edit_message_text(f'<b>{data[3]}</b>\n\n' f'<b>Короткое название:</b> <i>{data[4]}</i>\n' f'<b>Email:</b><i>{data[6]}</i>\n' f'<b>Сайт:</b><i> {data[8]}</i>', parse_mode="HTML",reply_markup = markup, chat_id=call.message.chat.id, message_id=call.message.message_id) @bot.message_handler(content_types=['text']) def start(m): page = 1 sqlTransaction = database.listColledjeForPage(tables = 'organization', order='title', Page=page, SkipSize=1) # SkipSize - т.к я буду отображать по одной записи data = sqlTransaction[0][0] # Набор строк count = sqlTransaction[2] # Количество строк print() markup = InlineKeyboardMarkup() markup.add(InlineKeyboardButton(text='Скрыть', callback_data='unseen')) markup.add(InlineKeyboardButton(text=f'{page}/{count}', callback_data=f' '), InlineKeyboardButton(text=f'Вперёд --->', callback_data="{\"method\":\"pagination\",\"NumberPage\":" + str(page+1) + ",\"CountPage\":" + str(count) + "}")) bot.send_message(m.from_user.id, f'<b>{data[3]}</b>\n\n' f'<b>Короткое название:</b> <i>{data[4]}</i>\n' f'<b>Email:</b><i>{data[6]}</i>\n' f'<b>Сайт:</b><i> {data[8]}</i>', parse_mode="HTML", reply_markup = markup) if __name__ == '__main__': bot.polling(none_stop=True)

database.py

import psycopg2 from psycopg2 import sql from psycopg2._psycopg import AsIs class Database: def __init__(self): self.conn = psycopg2.connect(database='MyDataBase', user='MyUser', password='SECRET', host='MeServ', port=5432) self.cursor = self.conn.cursor() def listColledjeForPage(self, tables, order, schema='organization', Page=1, SkipSize=1, wheres=''): sql = f"""select * from %(schemas)s.%(tables)s o %(wheres)s ORDER BY o.%(orders)s OFFSET %(skipsPage)s ROWS FETCH NEXT %(SkipSizes)s ROWS only;""" self.cursor.execute(sql, {'schemas': AsIs(schema), 'tables': AsIs(tables), 'orders': AsIs(order), 'skipsPage': ((Page - 1) * SkipSize), 'SkipSizes': SkipSize, 'wheres': AsIs(wheres)}) res = self.cursor.fetchall() self.cursor.execute(f"""select Count(*) from %(schemas)s.%(tables)s o %(wheres)s;""", {'schemas': AsIs(schema), 'tables': AsIs(tables),'wheres': AsIs(wheres)}) count = self.cursor.fetchone()[0] return res, len(res), count
33
10 комментариев

ай да человек, спасибо)
почти узнал то что хотел, нужно днем еще разок почитать
Как раз на днях придумал похожую штуку для себя, а вот как переключать и сохранять страницы, над этим задумался. были варианты как на сайтах
<<8, 9, 10, 11, 12...55 >> вроде красиво, привычно, но ваше, минимализм и простота, наверное сделаю так-же.

1
Ответить
Автор

Пасиб )

Ответить

Спасибо!
Узнал для себя новое: формат сообщения в HTML, а также OFFSET-FETCH

1
Ответить

Отличная статья, спасибо.
Сижу изучаю кнопки. Начал с ReplyKeyboard, но не нравится дизайн, смотрю сейчас, как просто реализовать Inline

Ответить

У меня вопрос, а как сделать подобное меня, как на Рис. 1. Демонстрация результата вначале

Ответить

это?

Ответить

Доброго времени суток.
Понравилась мне эта замутка - данные JSON через callback отправлять.
Дешево и сердито. Как Беломорканал.
Токо я вот себе еще под стрелочки две стрелочки добавил - чтобы "выборки" - страницы найденых страниц просматривать.
Т.е. в словарь ещё пару ключей и значений добавилось.
Сразу же полетели ошибки BUTTON_DATA_INVALID
Сделал названия ключей по две буквы (NC вместо NumberCount например)- заработало.
В callback_data можно очень ограниченую строку добавить.
Т.е. если нужно более менее большие данные перебрасывать, хотя бы переменных 10, обработчику, лучше всё-таки словарь глобальный завести.

Ответить