Я работаю бэкенд-инженером на Scala, и иногда пишу свои пет-прожекты с экспериментами, которые зачастую никуда не уходили. Но в этот раз я решил поделиться своим опытом.
Недавно мне пришла идея написать телеграмм бота для общения за определенную плату. Я погуглил подобные сервисы, и да, это уже реализовано на некоторых площадках, но мало кто активно этим пользуется.
Поскольку основным элементом взаимодействия является оплата, необходимо предусмотреть возможные варианты. И первое время я решил принимать оплату в звёздах, но здесь есть свои недостатки:
-
высокие комиссии.
-
прямой конвертации через Telegram API из звёзд в TON не реализовано.
-
ручной вывод звёзд в TON может занимать до 21 дня.
Несмотря на это, API из коробки позволяет выставлять чеки для оплаты звёздами. Для начала этого будет достаточно, но со временем можно будет посмотреть в сторону интеграции с эквайрингом.
Для проверки своей гипотезы при выборе стека я остановился на node.js и фреймворке grammY.
Я написал бОльшую часть логики, и сегодня, на момент написания статьи, выходит новость о том, что Телеграмм тестирует платные сообщения за звёзды.
Тогда я понял, что дальнейшая гонка с нативным механизмом не имеет смысла, и принял решение выложить код в опенсорс для тех, кому это может пригодиться в своих проектах. Там есть примеры вложенного меню, работа с сессиями, командами, middleware слоями, переключение контекста ввода.
Итак, в этой статье я расскажу о некоторых компонентах бота и их особенностях, которые помогут сэкономить время на разработку. Для более глубокого изучения grammY есть достаточно детальная документация на русском языке с примерами, поэтому порог входа становится ниже, даже если до этого не было подобного опыта.
Процесс инициализации пакетов я опущу, вместо этого мы продвинемся дальше и рассмотрим примерную структуру проекта.
.
├── telegram/
├── ├── commands/ - слой обработки команд
├── ├── common/ - для хранения настроек сессий, констант
├── ├── menu/ - слой интерактивного меню
├── ├── ├── ... может содержать вложенные меню
├── ├── └── index.mjs
├── ├── messages/ - слой обработчиков сообщений
├── ├── middleware/ - слой middleware бота
├── ├── persistence/ - БД слой
├── └── bot.mjs
├── package.json
└── package-lock.json
Здесь хочется отметить, что наиболее удобно работать с вложенностью папок меню так, как оно отображается в интерфейсе.
Persistence слой и другие интеграции с внешними сервисами удобно выделять в отдельные директории и обращаться к ним из любого слоя приложения.
Экземпляр класса Bot
После инициализации проекта и организации файловой структуры рассмотрим некоторые возможности экземпляра класса Bot.
const bot = new Bot(token)
bot.use(...)
— метод, который используется для обогащения контекста через middleware функции. Он возвращает экземпляр Composer. В свою очередь, Composer объединяет части middleware, поэтому вместо bot.use(...).use(...).use(...)
можно объединить их в логические компоненты, в один экземпляр Composer и зарегистрировать его в bot.
const composer = new Composer()
composer.use(middleware1).use(middleware2).use(middleware3)
bot.use(composer)
bot.filter(...)
— можно использовать для комбинации фильтрующих предикатов.
bot.use(...).filter((ctx) => !ctx.isBlockedUser).use(...)
Работа с сессиями
В grammY есть механизм для работы со стейтом, в котором, например, удобно хранить текущее состояние для переключения режима ввода данных.
bot.use(lazySession({
initial: () => {},
storage: await PsqlAdapter.create({ tableName: 'sessions', client: databaseClient })
}))
Здесь я использую хранилище PostgreSQL, поэтому для создания адаптера установлен плагин
(К слову, grammY поддерживает официальный набор плагинов, которые можно добавлять по мере необходимости и не тянуть ненужные артефакты в продакшн билд).
После регистрации сессии, контекст обогащается свойством, возвращающим Promise: await ctx.session
Регистрация интерактивного меню
В экземпляр бота рекомендуется регистрировать только корень дерева меню. Кроме случаев, когда используются несколько независимых типов меню.
const menuFoo = new Menu('menu_lable_foo')
const labelFooSubmenu = new Menu('label_foo_submenu')
const menuBar = new Menu('menu_lable_bar')
bot.use(menuFoo)
bot.use(menuBar)
Экземпляр menuFoo
содержит функцию dynamic
, которая принимает билдер функцию для описания логики отображения кнопок.
menuFoo.dynamic((ctx, range) => {
return range
.submenu(
{ text: 'Submenu Btn', payload: 'some_element_id' },
'label_foo_submenu',
middlewareSubmenu
)
.text(
'Text Btn',
middlewareText
)
})
Если нужно изменить контент сообщения, это необходимо делать в middleware. Если из контекста билдера вызывать editMessageText
, произойдет обновление контекста меню, и текущее меню будет бесконечно перестраиваться заново, что приведет к утечке памяти. То же самое касается ctx.menu.update()
В middleware можно обогатить контекст свойствами, которые в данном случае будут доступны в контексте билдера labelFooSubmenu
, но из практики этого делать не стоит, так как контекст обогатится один раз и при повторном рендеринге labelFooSubmenu
контекст уже не будет содержать этих свойств.
const middlewareSubmenu = async (ctx, next) => {
await ctx.editMessageText(ctx.match)
}
Передача свойств между слоями меню
Свойство payload
будет доступно из контекста middlewareSubmenu
— ctx.match
. В это поле, как правило, передается id сущности, к которой привязана кнопка. Но я чаще всего использую для этого контекст сессии, либо передачу объектов напрямую в middleware, тогда middlewareSubmenu
будет выглядеть следующим образом:
function middlewareSubmenu(entity) {
return async (ctx, next) => {
...
}
}
editMessageText затем editMessageMedia - не наоборот
Когда меню привязано к текстовому сообщению, можно заменить содержимое на медиа контент await ctx.editMessageMedia(...)
Но, когда над меню располагается медиа, при попытке поменять текст сообщения через await ctx.editMessageText('some text')
получаем ошибку:
there is no text in the message to edit
Я обошел это через удаление текущего сообщения и создание нового через контекст меню:
await ctx.deleteMessage()
await ctx.api.sendMessage(chat_id, 'some text', { reply_markup: labelFooSubmenu })
Однако, если попытаться отправить сообщение через bot.api.sendMessage(...), то тоже получим ошибку:
Cannot send menu 'label_foo_submenu'! Did you forget to use bot.use() for it or try to send it through bot.api?
Функция back
range.back('Назад', backMiddleware)
— регистрирует кнопку для перенаправления на предыдущий слой меню. Здесь backMiddleware
зачастую полностью или частично повторяет middleware меню, в которое идет перенаправление, поэтому в некоторых случаях достаточно переиспользовать один и тот же родительский middleware.
Итог
Опыт с grammY показал, что если нужно проверить гипотезу и быстро накидать код, то это хороший выбор. С одной стороны — это простой, с другой — гибко настраиваемый с достаточным набором инструментов для оптимизации, интернационализации и деплоя фреймворк.
Я продолжу его использовать в своих проектах и, вероятно, напишу еще несколько статей по этой теме.
Надеюсь, мои наблюдения пригодятся при вашем знакомстве с grammY.
Автор: m_bessarab