Разработка Telegram бота на NodeJs + Telegraf.js

Разработка Telegram бота на NodeJs + Telegraf.js

Разработка ботов тема не новая и затертая до дыр. В сети полно туториалов как сделать простого Телеграм-бота или не простого, и каждый спикер предлагает какой-то свой подход. Ведь, действительно, один и тот же функционал можно реализовать по-разному. И, к сожалению, каких-то best practices по разработке ботов я не нашел, а смотреть кучу уроков - времени не напасешься. Поэтому я решил сам искать решения и подходы, которые можно было бы переиспользовать в дальнейшем.
Как в итоге это работает, можно посмотреть в боте АстроКот (есть демо подписка на 7 дней)

Пару слов о Telegraf.js.

Когда я только начинал разрабатывать своих ботов для пет-проектов, я писал с минимальным набором библиотек, даже без telegram-node. И в этих проектах мне приходилось при вызове каждого обработчика пользовательского ввода или реакции устанавливать обработчик, который будет отлавливать следующий ввод. Много когда, очень неудобно, просто запутаться. Эта задача отлично решается с помощью подхода конечных автоматов (FSM). Для следующих проектов я использовал уже XState. И недавно наткнулся на Telegraf.js, который предлагает функционал FSM (реализованный при помощи сцен), совместно с Telegram Bot API. В общем - это то, что нужно. Плюс, есть возможность подключать различные middleware (как в Express), чем мы потом и будем пользоваться.

Задача

Пишем бота, который присылает каждый день гороскоп, а так же предоставляет пользователям возможности:

  • посмотреть разные виды гороскопов,
  • сделать расклад Оракула,
  • выполнить запрос ко Вселенной,
  • заговор на деньги,
  • настроить бота под себя - настроить локальное время, время уведомлений,
  • и конечно ОФОРМИТЬ ПОДПИСКУ!

Без паники, обо всем в подробностях я рассказывать не буду. Хотелось бы сфокусироваться на том функционале, который можно вынести в шаблон для бота, на основе которого уже можно реализовывать любую предментную область.
Что должно быть в шаблоне:

  • сохранение состояния пользователя и его восстановление при общении с ботом (на случай перезапуска бота при деплое)
  • возможность взаимодействия пользователя с некими глобальными сообщениями, которые выбиваются из стандартного флоу общения пользователя с ботом (запрос-ответ)
  • возможность для пользователя работать с reply клавиатурой, которая всегда доступна
  • реализация удобного интерфейса оплаты подписки
  • сбор метрик
  • реализация возможности приема обращений пользователей о багах

Быстрое введение в создание бота на Telegraf.js

Создаем бота let bot = new Telegraf(token);

При помощи use мы можем добавлять различные middleware, которые будут выполняться в том порядке, в котором их добавили. Например можем добавить сессии для пользователей, в которых будем хранить какую то информацию (в объекте ctx.session, который доступен для записи и чтения), связанную со сценой, в которой находится пользователь.
bot.use(session())

Мы можем навесить обработчики комманд (сообщений от пользователя которые начинаются с /) при помощи
bot.command('subscription', async (ctx) => {...})

Обработчик сообщения
bot.on(message("text"), async (ctx) => {...});

Oбработчик нажания по колбек кнопке
bot.on(callbackQuery(), async (ctx) => {...});

Можем переводить пользователя в определенную сцену. Например создадим сцену отображения гороскопов и при клике на кнопку переведем пользователя в нее.

const horoMainScene = new Scenes.BaseScene("horoMainScene");

horoMainScene.enter(async (ctx) => {
    ///обработчик, вызываемый, когда пользователь зашел в хату сцену
    /// можем тут проверить подписку пользователя или запросить данные гороскопов и вывести
})

bot.on(callbackQuery(), async (ctx) => {...
    await ctx.scene.enter("currentHoroScene");
});

Сцены подключаются как middleware в бот this.bot.use(getStage().middleware());

где функция getStage() возвращает все сцены для бота

Фичи

Сохранение стейта пользователя.

Фича важная, как по мне, на случай, если приложение упало или ты его деплоешь не с zero downtime. После того как бот поднялся, пользователь может общаться с ботом дальше как ни в чем не бывало (а не тыкаться и в итоге слать /start, чтобы начать общение с начала), потому что хендлеры обработки пользовательского инпута восстановились на той же сцене, где и был пользователь.

Для этого в обработчике входа в сцену ( scene.enter(() => {}) ) в самом конце добавляем сохранение всего объекта ctx.session и имени сцены. Почему в конце - чтобы знать, что сцена обработалась без ошибок и можно сохранять пользователя тут. ctx.session и имя сцены - полная информация, которая нам поможет восстановить позицию пользователя. Возможно, сохранение можно добавить и каких то особых случая, например, когда обработка нажатия на кнопку, не уводит пользователя в другую сцену, но меняет какое-то состояние.

У Telegraf.js есть возможность хранения сцены в Redis или другиe бд в разных форматах (например с middleware session-local). Я же сделал свой велосипед с хранением данных в mongoDB, т к и остальные данные тоже хранятся в монге. Если бот не использует бд, то можно было бы обойтись и session-local.

Сама реализация:

saveState(ctx, "horoMainScene");

///
async function saveState(ctx, scene) {  
    let { session = {} } = ctx;  
    let { __scenes, ...rest } = session;  
    let userId = ctx.chat.id;  
    return saveUserState(userId, scene, JSON.stringify(rest));
}
///

async function saveUserState(userId, scene, session) {  
    const newUser = new UserStateModel({    
        userId,    
        scene,    
        session: JSON.stringify(session),  
    });  
    await newUser.save();
}

Как восстановить состояние пользователя?

Для этого мы подключаем свой middleware:

async function restoreState(ctx, next) {
  if (
    ctx.update &&
    ctx.update.message &&
    ctx.update.message.text &&
    ctx.update.message.text.startsWith("/")
  ) {
    /// значит это сообщение с командой, а значит данные сцены восстанавливать не нужно, потому что команды отрабатывают глобально
    return next();
  }

  if (!ctx.scene.current) {
  // у пользователя нет текущей сцены. В работе нашего бота такого быть не может, значит нужно восстановить сцену из бд

    let userState = await getState(ctx);
    
    if (userState) {
      const scene = userState.scene; // имя сцены
      let currentScene = ctx.scene.scenes.get(scene);
      if (!currentScene) {
        return next();
      }
      
      let currentHandler = currentScene.handler; // текущий хендлер сцены
      ctx.session = JSON.parse(userState.session);

      await ctx.scene.enter(scene, {}, true); // входим в сцену бесшумно (т е без вызова обработчика enter)
      
      currentHandler(ctx, next); // после восстановления вызываем текущий обработчик апдейта от юзера
      return;
    }
    return next();
  }
  return next();
} 

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

getState() получает стейт пользователя из бд. Из списка сцен (который всегда доступен через `ctx.scene.scenes`)бота получаем текущую сцену со всеми ее параметрами и устанавливаем ее для пользователя. Сделать это надо так, что бы функция enter для данной сцены не выполнялась, потому что пользователь в своем флоу уже находится в ней и будет странным если он опять получит сообщения которые отправляются при заходе в сцену или выполняется какая-нибудь другая обработка. Для этого существует 3й параметр в функции ctx.scene.enter(scene, {}, true) - silen = true.

А после того, как мы восстановили стейт и сцену, вызываем handler текущей сцены, который обработает текущий update от пользователя (нажатие по callback кнопке или текстовый инпут), который не был обработан, т к у пользователя не было сцены (и соответственно не было назначено никаких обработчиков).
Не забудем вызвать next() на остальные ветки проверок, чтобы вызвать следующие middleware, если они есть или будут.

Возможность взаимодействия с глобальными сообщениями бота

Было бы довольно просто работать по обычному флоу с пользователем в формате запрос - ответ. Когда бот ожидает какой нибудь инпут от юзера и выдает ответ, переводя пользователя в какую-то сцену, где есть только несколько вариантов развития событий (переходов в другие сцены). Но задача усложняется тем, что пользователю могут приходить сообщения от бота по расписанию. Например гороскоп - сообщение с кнопкой, позволяющей посмотреть гороскопы на различные тематики и дни. Либо сообщение с предложением продлить подписку. И пользователь должен мочь перейти по этой кнопке, а должен мочь проигнорировать сообщение и продолжить свой флоу, где он может быть например в процессе настроек или использования другого функционала.

Для этого нам нужно определить круг возможных callbackQuery (реакций на кнопки - т е id кнопки, которая была нажата), на которые мы можем реагировать глобально. В данном боте это всего несколько вариантов: otherHoro, renewSubscription, toMainGlobal - это уникальные id кнопок для глобальных сообщений.

Далее, создаем middleware для проверки этих апдейтов от бота. Подключаем его после middleware со всем сценами. Т е если такой апдейт не заинтересовал ни один обработчик из сцен и он попадает в этот middleware, возможно тут будет хендлер для него.

В Telegraf.js есть удобный класс Composer, в котором при помощи метода use можно подключить наши глобальные обработчики как middleware. Получается мидлвары в мидлваре (привет Нолану).

При этом сразу проверим ветку, что если наш апдейт от юзера это не callbackQuery - то пропустим наши обработчики и вызовем следующий middleware через next(). Для этого используем функцию Composer.branch:

const globalCallbackQueryComposer = new Composer();
globalCallbackQueryComposer.use(otherHoroGlobalCallbackHandler);
globalCallbackQueryComposer.use(renewSubscriptionHandler);
globalCallbackQueryComposer.use(toMainGlobalHandler);

let globalCallbackMiddleware = Composer.branch(
  (ctx) => {
    return ctx.callbackQuery;
  },
  globalCallbackQueryComposer,
  async (ctx, next) => {
    await next();
  }
);

Пример реализации одного из глобальных обработчиков

async function otherHoroGlobalCallbackHandler(ctx, next) {
  const answer = ctx.callbackQuery.data;
  if (answer === controls.otherHoro) {
    ctx.session.fromGlobal = true;
    await ctx.scene.enter("horoMainScene");

    answerButtonCb(ctx);
    await next();
    return;
  }
  await next();
}

Если юзера заинтересовало глобальное сообщение, то мы уже переводим флоу общения в это русло, перенаправляя юзера в определенную сцену. Не забываем вызвать next(), для того, чтобы могли отработать следующие middleware, которые могут быть подключены далее.

Возможность для пользователя работать постоянно с reply клавиатурой

Вообще, хотелось бы убирать клавиатуру при выборе определенного меню. НО! Это нужно делать отдельным сообщением (отправляешь сообщение с типом клавиатуры removeKeyboard), потому как невозможно например отправить сообщение с колбек клавиатурой и одновременно удалить reply клавиатуру - такие ограничения у Telegram Bot API. Либо редактировать клавиатуру сообщения, на котором эта клавиатура задавалась (у меня что-то так не завелось). Поэтому я решил не париться (по этому поводу, а париться по другому).

reply клавиатура в ботах

Для этого сделаем мидлвар, который будет отслеживать reply апдейты от пользователя (который по факту - просто текст, поэтому они тоже должны быть уникальными, например ⚜️ Оракул).

const { Composer } = require("telegraf");
const { mainMessageGlobalHandler } = require("../scenes/main");

class GlobalInlineKeyboardHandler {
  constructor(callback) {
    this.callback = callback;

    const globalInlineKeyboardComposer = new Composer();
    globalInlineKeyboardComposer.use(callback);

    this.globalInlineMiddleware = Composer.branch(
      (ctx) => ctx.update && ctx.update.message,
      globalInlineKeyboardComposer,
      (ctx, next) => {
        next();
      }
    );
  }

  middleware() {
    return this.globalInlineMiddleware;
  }
}

let globalInlineMiddleware = new GlobalInlineKeyboardHandler(
  mainMessageGlobalHandler
).middleware();

Сам же обработчик выглядит так

async function mainMessageHandler(ctx, next) {
  if (ctx.update.message.text === buttons.settings) {
    await ctx.scene.enter("settingsMainScene");
    return;
  }
  if (ctx.update.message.text === buttons.horo) {
    await ctx.scene.enter("horoMainScene");
    return;
  }
  if (ctx.update.message.text === buttons.request) {
    await ctx.scene.enter("universeScene");
    return;
  }
    .....
    ....
    ...
}

ezgif-5-25ac425486
Выбираем Запрос ко Вселенной. Потом с помошью reply клавиатуры выбираем Гороскопы.

Сделаю небольшое отступление по поводу вызова next(). Вообще, всю работу с обработкой сообщений в Telegraf.js нужно воспринимать как работу с middleware. Но часто, когда дело доходит до сцен или обычных обработчиков, многие забывают о том, что ваш обработчик - это middleware, у которого вторым агрументом приходит next. Даже в примерах часто на это забивают. И часто кажется, что ты обработал апдтейт от юзера, а дальше хоть трава не расти. В случаях со сложной логикой это может быть чревато долгим поиском багов. Но вместо того чтобы постоянно бдить за тем "а не забыл ли я во всех ветках обработчика вызвать next?", можно сделать какой нибудь класс обертку

class BotScene {
  constructor(name) {
    this.name = name;
    this.scene = new Scenes.BaseScene(name);
  }

  onEnter(...middlewares) {
    this.scene.enter(...createMiddlewres(middlewares));
  }

  onAction(action, ...middlewares) {
    this.scene.action(action, ...createMiddlewres(middlewares));
  }

  on(actionType, ...middlewares) {
    this.scene.on(actionType, ...createMiddlewres(middlewares));
  }

  onCallbackQuery(...middlewares) {
    this.scene.on(callbackQuery(), ...createMiddlewres(middlewares));
  }

  onMessage(...middlewares) {
    this.scene.on(message("text"), ...createMiddlewres(middlewares));
  }

  getScene() {
    return this.scene;
  }
}

Здесь я определил класс, с возможностью добавить обработчики на вход в сцену, обработчики текстового инпута, callbackQuery, а также общий обработчик on, через который можно подрубить коллбек на любой апдейт. Аргументов может быть много, потому что перед основным коллбеком можно вызвать другие, которые добавляют какую-нибудь информацию в сессию или делают какую-то другую обработку перед основным коллбеком. Эти функции обязательно заканчиваются вызовом next(), иначе не добраться до основного обработчика, поэтому тут ошибиться трудно.

Последняя же функция оборачивается в другую, которая явно вызывает next(), на всякий случай в блоке try catch, если обработчик next был вызван в вашей функции (двойной вызов next приведет к исключению)

function nextCallWrapper(cb) {
  return async (ctx, next) => {
    await cb(ctx, next);
    try {
      await next();
    } catch {}
  };
}

function createMiddlewres(middlewares) {
  let lastCallback = middlewares.pop();
  let mainCallback = nextCallWrapper(lastCallback);
  return [...middlewares, mainCallback];
}

Также, нужно учесть, что этот middleware подключается после middleware сцен (порядок важен), и если в текущей сцене есть обработчик тестового ввода, то он первый перехватит сообщение юзера, если это будет нажатие по кнопки reply клавиатуры (нажатие по факту просто отправляет текст в чат). Для этого можно при подписке на текстовое сообщение в сцене добавить обработчик сообщений клавиатуры, и если это сообщение матчится с какой-нибудь из кнопок, то не вызывать метод next, чтобы не прокидывать сообщение в обработчик сцены. И конечно здесь есть нюанс. Например, если у вас есть другие middleware кроме сцен, в которые должны прокидываться апдейты после обработки в сценах. Как быть, если не вызывая метод next, обработка дальше не пойдет. Как вариант - реализовать обработку глобальных сообщений клавиатуры не как middleware перед основной фукнцией сцены, а как обертку (high ordered function) над основной функцией сцены. Т е обертка проверяет матчится ли сообщение с какой-нибудь из кнопок, если да – обработать как нажатие на кнопку и вызвать метод next, если нет – вызвать главную функцию сцены, и тоже вызвать next.

import globalInlineHanler from ......


function withGlobalInlineHandler(cb) {
  return async (ctx, next) => {
    let isKeyboardProcessed = await globalInlineHanler(ctx, next); // должен вернуть  true/false в зависиимости от того, обработан апдейт как нажатие по кнопки или нет (при этом функция сама внутри вызовет next()) 
    if(!isKeyboardProcessed) {
        await cb(ctx);
        await next();
    }
  };
}


/// подключение

someInputScene.onMessage(withGlobalInlineHandler((ctx) => {
    /// ваша обработка текстового инпута
})

А если логика с обработкой остальными middleware не так важна (т е не обязательно, чтобы апдейт всегда прокидывался во все middleware до конца), то можно вызывать вот так

fucntion globalInlineHanler(ctx, next){
	if(ctx.update.message.text === 'Кнопка 1') {
    /// если это глобальная кнопка
    	ctx.scene.enter('Имя сцены 1');
        return;
    }
    ///если нет - прокидываем в основной текстовый обработчик сцены
    next()
} 




import globalInlineHanler from ......

/// подключение

someInputScene.onMessage(globalInlineHanler, (ctx, next) => {
    /// ваша обработка текстового инпута
})
Реализация удобного интерфейса оплаты подписки

Самый удобный способ оплаты подписки, конечно, через TelegramPaymentAPI. Но в текущих реалиях подключить его я не смог - ни один провайдер (я пробовал целых) не предоставляют прием платежей по картам через TelegramPaymentAPI (а счастье было так близко).
Пришлось придумывать что-то свое. Многие боты делают так - генерят ссылку для оплаты через ТинькофPay или Юкассу (вшивая там какие-нибудь данные по юзеру, чтобы можно было отследить кто оплатил), юзер переходит по ссылке, оплачивает, а потом в боте просят юзера нажать кнопку "Я оплатил". Выглядит ужасно, как по мне (но надежно).

Я же сделал немного по другому. Ужасный флоу с кнопкой "Я оплатил" оставил как запасной вариант (т е я показываю ее юзеру), добавив сервер, слушающий реквесты от ЮКассы об успешных платежах. В лк Юкасса, можно настроить эндпоинт на который будут лететь уведомления о платежах. Как только уведомление получено, сразу отправляем юзеру сообщение об успешной оплате. В сообщении кнопка которая переведет его в главную сцену, например.

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

Приведу полный код модуля оплаты

const axios = require("axios");
const { v4 } = require("uuid");
const PaymentServer = require("./payment-callback-server");

const API_URL = "https://api.yookassa.ru/v3/payments";
const PAYMENT_BASE_URL =
  "https://yoomoney.ru/checkout/payments/v2/contract/bankcard?orderId=";

class UkassaPaymentService {
  constructor(providerConfig) {
    this.token = providerConfig.secretKey;
    this.shopId = providerConfig.shopId;
    this.paymentServer = new PaymentServer(5023, "/result-payments");
    this.paymentServer.startServer();
  }

  createAuthHeader() {
    return `Basic ${Buffer.from(`${this.shopId}:${this.token}`).toString(
      "base64"
    )}`;
  }

  async createInvoice(amount, currency, description, userId) {
    let paymentInfo = {
      amount: {
        value: amount,
        currency: currency,
      },
      payment_method_data: {
        type: "bank_card",
      },
      confirmation: {
        type: "redirect",
        return_url: "https://t.me/cute_astro_bot",
      },
      capture: true,
      description,
      metadata: {
        userId,
      },
    };

    try {
      let result = await axios.post(API_URL, paymentInfo, {
        headers: {
          Authorization: this.createAuthHeader(),
          "Idempotence-Key": v4(),
          "Content-Type": "application/json",
        },
      });
      let invoice = result.data;
      return {
        url: `${PAYMENT_BASE_URL}${invoice.id}`,
        invoice,
      };
    } catch (e) {
      console.log("error with invoice cration");
      console.log(e);
      return null;
    }
  }

  subscribeOnSuccessPayment(handler) {
    this.paymentServer.attachPaymentHandler(handler);
  }

  async checkPaymentIsDone(paymentId) {
    const url = `${API_URL}/${paymentId}`;
    try {
      let result = await axios.get(url, {
        headers: {
          Authorization: this.createAuthHeader(),
        },
      });
      return {
        isDone: result.data.status === "succeeded",
        invoice: result.data,
      };
    } catch (e) {
      console.log(e);
      return { isDone: false };
    }
  }
}

module.exports = {
  UkassaPaymentService,
};

subscribeOnSuccessPayment - добавляем единственный обработчик успешного платежа, в который будет приходить инфа о том, кто оплатил подписку. По этому id (chat_id) мы и будем слать сообщение для юзера об успешном продлении (в надежде, что ему не пришлось воспользоваться этой убогой кнопкой).

checkPaymentIsDone - как раз возможность проверки платежа по кнопке.

PaymentServer - обычный http server, который через nginx привязан к домену моего сайта, для которого офомлена Юкасса. Юкассу оформил для продаж через интернет магазин, потому как для ботов оплату по карте они не подключают, а для сайта - подключают. Но там протокол не подходящий для того, чтобы можно было нативно это привязать к TelegramPaymentAPI (я проверял).

function createServer(paymentEndpoint, port, handlers = []) {
  const server = http.createServer((req, res) => {
    // Handle only POST requests
    if (req.method !== "POST") {
      res.writeHead(405, { "Content-Type": "text/plain" });
      res.end("Method Not Allowed");
      return;
    }

    let body = "";
    req.on("data", (chunk) => {
      body += chunk.toString();
    });

    req.on("end", async () => {
      const { pathname, query } = url.parse(req.url);
      const queryParams = querystring.parse(query);
      // Check if the request is for the specified endpoint
      if (
        pathname !== paymentEndpoint ||
        req.headers["content-type"] !== "application/json"
      ) {
        res.writeHead(404, { "Content-Type": "text/plain" });
        res.end("Not Found");
        return;
      }
      try {
        const payment = JSON.parse(body);
        console.log("Received JSON data:", payment);
        handlers.forEach(async (handler) => {
          try {
            await handler(payment);
          } catch {}
        });
        // Send a response
        res.writeHead(200, { "Content-Type": "application/json" });
        res.write(
          JSON.stringify({
            success: true,
          })
        );
        res.end();
      } catch (error) {
        res.writeHead(400, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ success: false }));
      }
    });
  });

Не забудьте проверить, что не абы кто шлет запрос на ваш эндпоинт :)

Возможности приема обращений пользователяей о багах

Можно завести отдельного бота под это дело, и это даже удобнее. Но тогда есть вариант закапаться в жонглировании ботами. Поэтому я реализовал это простым сохранением обращения в БД и уведомлением админа (или админов). У админа же есть свой админский функционал в том же самом боте. Реализовать админский функционал просто, при помощи мидлвары от Telegraf.js

this.bot.use(Composer.acl([admin_id], adminBot));

где adminBot, это уже другая часть бота, которая реализуется по тому же принципу что и обычный бот - свои сцены, свои middlwere, свои команды.

let adminBot = new Composer();


adminBot.command("user", (ctx) => {
  ctx.scene.enter("mainScene");
});

....... остальные обработчики, мидлвары и подписки.

adminBot.on(message("text"), mainGlobalMessageHandler);

module.exports = {
  adminBot,
};

А уже у админа есть возможность посмотреть обращения и ответить конкретному пользователю. Пользователю это придет как обычное сообщение в боте (с возможностью скрыть сообщение по кнопке)

Хотелось бы еще прикрутить сбор метрик в Grafana с помощью Prometheus, но боюсь для моего VPS с 1 GB RAM это немного жирно.

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

function createCallbackMarkup(buttonsArray) {
  let allowedReply = {};
  let keyboard = buttonsArray.map((item) => {
    return item.map((button) => {
      let text;
      let data;
      let hide = false;
      if (Array.isArray(button)) {
        [text, data, hide = false] = button;
      } else {
        ({ text, data, hide } = button);
      }
      allowedReply[data] = true;
      return Markup.button.callback(text, data, hide);
    });
  });
  return {
    keyboard,
    allowedReply,
  };
}
let { keyboard, allowedReply } = createCallbackMarkup([
    [["🌔 Ваше прошлое", controls.past]],
    [["✨ Ваше настоящее", controls.present]],
    [["🔮 Ваше будущее", controls.future]],
    [["Не смотреть", controls.cancel]],
  ]);
  setAllowedReply(ctx, allowedReply);
.
.
.
function setAllowedReply(ctx, reply) {
  ctx.session.allowedReply = reply;
}
function withAllowedCb(handler) {
  return async (ctx, next) => {
    if (
      ctx.session.allowedReply &&
      ctx.callbackQuery &&
      ctx.callbackQuery.data
    ) {
      let action = ctx.callbackQuery.data;
      if (!ctx.session.allowedReply[action]) {
        return next();
      }
    }
    await handler(ctx, next);
  };
}

usersMainScene.onCallbackQuery(
    withAllowedCb(async (ctx, next) => {
      /// гарантировано данные callbackQuery из тех, что разрешены
      clearAlloweReply(ctx)
    })
)
UPD

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

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

В следующей статье расскажу, как это всё деплоится на сервер с помощью Dokku.