Заметки по grammY

в 14:15, , рубрики: grammy, node.js

Я работаю бэкенд-инженером на 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, поэтому для создания адаптера установлен плагин @grammyjs/storage-psql
(К слову, 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 будет доступно из контекста middlewareSubmenuctx.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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js