Как я пагинацию на 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, обработчику, лучше всё-таки словарь глобальный завести.