Разработка 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 апдейты от пользователя (который по факту - просто текст, поэтому они тоже должны быть уникальными, например ⚜️ Оракул).
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;
}
.....
....
...
}
Выбираем Запрос ко Вселенной. Потом с помошью 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.