Мой опыт создания телеграм-бота на NodeJS/grammY
Арест Павла Дурова стал настолько ярким событием, что мне пришлось повнимательнее присмотреться к этому мессенджеру — чем же таким он значимо отличается от остальных социальных сетей. Так в поле моего зрения попали боты. Так-то я больше по веб-приложениям — ну, тех, что в браузере. Но боты тоже оказались ничего так.
Так как я предпочитаю использовать JavaScript и на фронте, и на бэке, то среда существования для бота была определена сразу же — nodejs. Осталось определиться с библиотекой — Telegraf или grammY? Так как у второй в примере использовался кошерный import
, а у первой — старомодный require
, я выбрал grammY
.
Под катом — пример телеграм-бота в виде nodejs-приложения с использованием библиотеки grammY
, который запускается как в режиме long pooling
, так и в режиме webhook
, созданный с применением моей любимой технологии — внедрения зависимостей через конструктор (TL; DR).
Общая схема взаимодействия
Чуть покопавшись в описаниях того, что такое боты и с чем их едят, пришёл в восторг. Телеграм уверенно идёт по пути создания супер-аппа (по образу китайского WeChat). Сформировав первоначально базу пользователей, Телеграм теперь даёт возможность всем подряд добавить недостающую им функциональность посредством ботов (и мини-аппов).
С точки зрения веб-разработчика можно представить, что телеграм-клиент — это своего рода браузер с усечёнными возможностями по отображению информации. Взаимодействие пользователя с этим »браузером» строится по принципу чата — отправил какую-то информацию в чат, получил какую-то информацию из чата. Вместо широкого набора возможностей Web API обычного браузера Телеграм предлагает свой вариант — Telegram API. При этом у всех »браузеров» (телеграм-клиентов) есть один общий шлюз (телеграм-сервер), через который они могут общаться с внешним миром (ботами), а внешний мир (боты) — с »браузерами» (телеграм-клиентами).
Чуть-чуть про Web Push
Если проводить аналогию с реальными браузерами, то сразу же вспоминается Web Push API. Пользователь разрешает в браузере получение push-уведомлений, после чего браузер регистрирует разрешение и связывается со своим push-сервером, регистрируя endpoint для доставки сообщений. Этот endpoint пользователь отправляет на бэк, где он и сохраняется (сплошная линия на диаграмме внизу). Чтобы бэк мог отправить сообщение пользователю в браузер, бэк должен для начала отправить сообщение на push-сервер, пользуясь сохранённым endpoint’ом. В endpoint’е различным браузерам соответствует различный push-сервер:
Chrome:
fcm.googleapis.com
Safari:
web.push.apple.com
Firefox:
updates.push.services.mozilla.com
Бэкенд стороннего сервиса отправляет сообщение на push-сервер браузера (пунктирная линия), и этот сервер уже перенаправляет уведомление в соответствующий браузер на основании зарегистрированного endpoint (если браузер запущен, разумеется).
Web Push API
По сути, в Телеграме улучшили Web Push API, значительно усложнив формат передаваемых сообщений и дав возможность пользователю »браузера» (телеграм-клиента) не только получать сообщения через шлюз (телеграм-сервер), но и отправлять их. Внешние сервисы, с которыми пользователь может взаимодействовать через шлюз посредством своей клиентской программы в мобильном устройстве (или в компьютере) и которые со своей стороны могут взаимодействовать с пользователем, называются ботами.
Схема подключения бота
Телеграм-бот может быть подключен к шлюзу (телеграм-серверу) в одном из двух режимов:
long pooling: бот работает на любом компьютере (десктоп, ноутбук, сервер) и сам опрашивает шлюз на предмет новых сообщений от пользователей.
webhook: бот работает на веб-сервере и способен принимать сообщения от шлюза по HTTPS-соединению.
Библиотека grammY
поддерживает оба режима. Long pooling
удобен для разработки и для проектов с низкой загрузкой, webhook
— для высоконагруженных проектов.
Регистрация бота
Про регистрацию написано много (раз, два, три). Всё сводится к тому, что нужно через бот @BotFather
получить токен для подключения к API (шлюзу). Что-то типа такого:
2338700115:AAGKevlLXYhEnaYLВSyTjqcRkVQeUl8kiRo
Если токен действующий, то при его подстановке в этот адрес вместо {TOKEN}
:
https://api.telegram.org/bot{TOKEN}/getMe
Телеграм возвращает информацию о боте:
{
"ok": true,
"result": {
"id": 2338700115,
"is_bot": true,
"first_name": "...",
"username": "..._bot",
"can_join_groups": true,
"can_read_all_group_messages": false,
"supports_inline_queries": false,
"can_connect_to_business": false,
"has_main_web_app": false
}
}
Токен используется при создании бота в grammY
:
import {Bot} from 'grammy';
const bot = new Bot(token, opts);
Я не буду в этом посте описывать, как создавать nodejs-приложение, подключать npm-пакеты и т.п. — остановлюсь на принципиальных моментах, связанных с ботами.
Добавление команд
Командой для бота в Телеграме считается строка, начинающаяся с /
. Есть три команды, наличие которых ожидается для каждого бота:
/start
: начало взаимодействия пользователя с ботом./help
: запрос пользователя на получение справки о работе с ботом./settings
: (если применимо) настройка бота пользователем.
Команды можно интерактивно добавлять на телеграм-клиенте через @BotFather
, но более правильным, на мой взгляд, является добавление команд через бота при его запуске:
const cmdRu = [
{command: '...', description: '...'},
];
await bot.api.setMyCommands(cmdRu, {language_code: 'ru'});
В этом случае можно варьировать описание команд в зависимости от предпочтений пользователя (например, выбранного пользователем языка общения).
Добавление обработчиков
После создания бота и добавления к нему списка команд к боту добавляются обработчики, реагирующие на эти самые команды, и обработчики, реагирующие на другие события, не являющися командами (новое сообщение, реакция на предыдущее сообщение, редактирование сообщения, отправка файла и т.п.):
const bot = new Bot(token, opts1);
const commands = [
{command: '...', description: '...'},
];
await bot.api.setMyCommands(commands, opts2);
// add the command handlers
bot.command('help', (ctx) => {});
// add the event handlers
bot.on('message:file', (ctx) => {});
Список команд мы определяем сами, а вот список »других событий» (»фильтров» в терминологии grammY
) формируется более извилистым путём. Тем не менее, суть обработчиков (»middleware» в терминах grammY
) в обоих случаях примерно одинакова — получить на вход контекст запроса (ctx
), отреагировать на поступившую информацию, сформировать ответ и отправить его пользователю:
const middleware = function (ctx) {
const msgIn = ctx.message;
// ...
const msgOut = '...';
ctx.reply(msgOut, opts).catch((e) => {});
};
Этой информации уже достаточно для того, чтобы сделать простого бота, реагирующего на текстовые команды, отправляемые через телеграм-клиента.
Запуск бота в режиме long pooling
Тут всё просто:
const bot = new Bot(token, opts1);
await bot.api.setMyCommands(commands, opts2);
bot.command('...', (ctx) => {});
bot.on('...', (ctx) => {});
// start the bot in the long pooling mode
bot.start(opts3).catch((e) => {});
Всё, бот работает прямо с вашего ноутбука/десктопа/сервера, опрашивает телеграм-шлюз на предмет сообщений, поступивших для бота, обрабатывает их и отправляет обратно. Можно в таком же виде запустить бот на каком-нибудь VPS или выкатить на какую другую площадку.
Коротко о режиме webhook
Режим «webhook» — это запуск бота »по-взрослому». В этом режиме бот при старте связывается с телеграм-шлюзом и сообщает ему свой адрес, на который шлюз будет присылать боту сообщения от пользователей по мере их появления. Что-то типа:
https://grammy.demo.tg.wiredgeese.com/bot/
Сообщения присылаются в виде HTTP POST запросов:
POST /bot/ HTTP/1.1
Host: grammy.demo.tg.wiredgeese.com
Content-Type: application/json
...
Сразу понятно, что бот в этом режиме должен представлять из себя HTTPS-сервер.
Сама библиотека grammY
не является таким сервером, но предоставляет адаптеры для подключения бота к популярным веб-серверам в nodejs. Вот пример подключения бота к express
:
import {Bot, webhookCallback} from 'grammy';
const app = express();
const bot = new Bot(token);
app.use(webhookCallback(bot, 'express'));
В webhook-режиме запускается непосредственно веб-сервер, который перенаправляет webhook’у HTTP-запросы, приходящие от телеграм-шлюза. Webhook извлекает входные данные из запроса при помощи адаптера, передаёт их боту на обработку, принимает результат от бота и возвращает результат обработки обратно в телеграм-шлюз.
Архитектура приложения
Библиотека grammY
создана для адаптации телеграм-шлюза к nodejs-приложениям в очень широком спектре применения, но её функционал всё равно нуждается в дополнительной доработке согласно конкретным бизнес-требованиям. Вот, что мне нужно в общем случае (для любого бота):
возможность запуска/останова бота как в режиме
long pooling
, так и в режимеwebhook
, локально или на виртуальном сервере;считывание конфигурации бота из внешнего источника (файл или переменные окружения);
добавление списка доступных команд и обработчиков для них при запуске бота;
регистрация адреса бота на телеграм-шлюзе при работе в режиме
webhook
;запуск бота в режиме отдельного веб-сервера (с поддержкой HTTPS) или в режиме сервера приложений, спрятанного за прокси-сервером (nginx/apache).
Компоненты разработки
Я сторонник архитектуры »модульный монолит», соответственно, весь типовой код, который отвечает за общение приложения с телеграм-шлюзом, и его зависимости логично вынести в отдельный модуль (npm-пакет, типа @flancer32/teq-telegram-bot
), а в бот-приложениях просто подключать этот модуль (вместе со всеми зависимостями) и имплементировать уже только бизнес-логику работы бота (обработку команд).
В бот-приложении, в файле package.json
это описывается так:
{
"dependencies": {
"@flancer32/teq-telegram-bot": "github:flancer32/teq-telegram-bot",
...
}
}
Этот пакет, в свою очередь, должен тянуть все остальные зависимости, обеспечивающие работу бота, включая grammY
.
npm-пакеты
В своих приложениях для связывания программных модулей я использую инверсию управления (IoC), а конкретно — внедрение зависимостей в конструкторе объектов. Реализация этого подхода — в моём собственном пакете @teqfw/di.
Для запуска nodejs-приложения из командной строки я использую пакет commander, который, в свою очередь, обёрнут в пакет @teqfw/core. В core-пакет таже ещё реализована настройка правил разрешения зависимостей в коде и загрузка конфигурации node-приложения из JSON-файла.
Я предпочитаю в своих приложениях использовать по максимуму node-модули, поэтому для всех трёх имплементаций веб-сервера в Node (HTTP, HTTP/2, HTTPS) сделал свою обёртку @teqfw/web, вместо того, чтобы использовать сторонние обёртки (express, fastify, koa, …)
Таким образом дерево зависимостей npm-пакетов в моём типовом бот-приложении (bot-app
) можно отобразить так:
Дерево npm-пакетов
зелёное:
grammy
и его зависимости;синее: веб-сервер, IoC и CLI;
жёлтое: npm-пакет, содержащий общую логику работы чат-бота (настройка бота и запуск бота в обоих режимах);
красное: бот-приложение, содержащее собственно сам бизнес-функционал бота.
Можно диаграмму дерева зависимостей представить в таком виде, скрыв все зависимости общего пакета:
Усечённое дерево зависимостей
Таким образом, достаточно прописать в зависимостях бот-приложения общий пакет, а всё остальное подтянется автоматом.
Общий npm-пакет
Пакет @flancer32/teq-telegram-bot
реализует функционал, общий для всех ботов:
Загрузка конфигурации бота (токен) из внешних источников (JSON-файл).
Запуск node-приложения
в виде бота в режиме
long pooling
.в режиме
webhook
в виде веб-сервера (http & http/2 — как application-сервер за прокси сервером, https — как самостоятельный сервер).
Общие для всех ботов действия (инициализация списка команд при старте бота, регистрация webhook’а и т.п.).
Определяет точки расширения, в которых приложения могут добавлять собственную логику.
Варианты использования бот-библиотеки
Консольные команды
В общем пакете реализованы и подключены две консольные команды, которые обрабатываются commander
'ом:
Запуск и останов бота в режиме webhook
осуществляется средствами пакета @teqfw/web
:
Веб-запросы
В общем npm-пакете осуществляет только подключение обработчика веб-запросов (Telegram_Bot_Back_Web_Handler_Bot) для всех путей, начинающихся на https://.../telegram-bot
.
Именно на этот адрес будет отправлять все запросы телеграм-шлюз и этот адрес регистрируется общей библиотекой на шлюзе при старте бот-приложения в webhook
-режиме.
Конфигурация
Каждый плагин (npm-пакет) в моём модульном монолите может иметь свои собственные настройки (конфигурацию). Для своих приложений я сохраняю настройки в JSON-формате в файле ./etc/local.json
. Шаблон настроек я обычно держу под контролем версий в файле ./etc/init.json
.
В нашей общей библиотеке пока что есть только один конфигурационный параметр — токен для подключения к телеграм-шлюзу:
{
"@flancer32/teq-telegram-bot": {
"apiKeyTelegram": "..."
}
}
Для отражения структуры конфигурационных параметров в коде используется объект Telegram_Bot_Back_Plugin_Dto_Config_Local.
Общие действия
На данный момент, помимо старта/останова, следующие действия являются общими для всех ботов:
инициализация библиотеки
grammY
токеном, считанным из конфигурации приложения.инициализация списка команд бота через телеграм-шлюз при старте приложения.
добавление обработчиков на события (команды бота и другие события).
создание webhook-адаптера для интеграции с плагином
@teqfw/web
.регистрация в телеграм-шлюзе endpoint’а бота при его старте в
webhook
-режиме.
Общие действия выполняются в объекте Telegram_Bot_Back_Mod_Bot.
API
Я уже описывал ранее, каким образом можно использовать интерфейсы в чистом JavaScript. В общем npm-пакете определяется интерфейс объекта, который должен быть имплементирован в бот-приложении — Telegram_Bot_Back_Api_Setup:
/**
* @interface
*/
class Telegram_Bot_Back_Api_Setup {
async commands(bot) {}
handlers(bot) {}
}
Общий пакет не знает, какие конкретно команды будут в бот-приложении и какие обработчики событий, но он ожидает от контейнера объектов такую зависимость, которая даст возможность модели Telegram_Bot_Back_Mod_Bot
проинициализировать при старте приложения и список команд, и обработчики событий.
Внедрение имплементации вместо интерфейса
Бот-приложение
Так как базовый функционал для работы с телеграм-шлюзом у нас расположен во внешних библиотеках (grammY
, @teqfw/di
, @tefw/core
, @tefw/web
), то в коде бот-приложения нам остаётся лишь добавить собственно бизнес-логику самого бота и связующий код, который позволит контейнеру объектов корректно создать и внедрить нужные зависимости.
Для этого в минимуме нужно 5 файлов:
./package.json
: дескритор npm-пакета../teqfw.json
: дескриптор teq-приложения.имплементация интерфейса
Telegram_Bot_Back_Api_Setup
: основной файл бот-приложения в котором к боту привязывается кастомная бизнес-логика../cfg/local.json
: локальная конфигурация бот-приложения (содержит токен)../bin/tequila.mjs
: стартер приложения.
package.json
Архитектура »модульный монолит» подразумевает, что приложение, хоть и модульное, но собирается воедино. Для nodejs/npm приложений главным файлом является package.json
. Для нашего приложения интересным является конфигурация выполняемых npm-команд и зависимости:
// ./package.json
{
"scripts": {
"help": "node ./bin/tequila.mjs -h",
"start": "node ./bin/tequila.mjs tg-bot-start",
"stop": "node ./bin/tequila.mjs tg-bot-stop",
"web-start": "node ./bin/tequila.mjs web-server-start",
"web-stop": "node ./bin/tequila.mjs web-server-stop"
},
"dependencies": {
"@flancer32/teq-telegram-bot": "github:flancer32/teq-telegram-bot"
},
}
teqfw.json
Файл ./teqfw.json
позволяет нашему npm-пакету, соответствующему бот-приложению, использовать возможности Контейнера Объектов @teqfw/di
:
{
"@teqfw/di": {
"autoload": {
"ns": "Demo",
"path": "./src"
},
"replaces": {
"Telegram_Bot_Back_Api_Setup": {
"back": "Demo_Back_Di_Replace_Telegram_Bot_Back_Api_Setup"
}
}
}
}
Инструкции предписывают Контейнеру искать модули с префиксом Demo
в каталоге ./src/
, а для внедрения объекта с интерфейсом Telegram_Bot_Back_Api_Setup
использовать es6-модуль Demo_Back_Di_Replace_Telegram_Bot_Back_Api_Setup
.
Такое длинное для имплементации имя обусловлено моими субъективными предпочтениями в органзиации структуры каталогов в своих приложениях. Вполне можно было бы обойтись и таким именем: Demo_Bot_Setup
.
Имплементация интерфейса
В моём примере я вынес обработчики событий в отдельные es6-модули и оставил в имплементации только добавление команд и обработчиков к боту:
/**
* @implements {Telegram_Bot_Back_Api_Setup}
*/
export default class Demo_Back_Di_Replace_Telegram_Bot_Back_Api_Setup {
constructor(
{
Demo_Back_Defaults$: DEF,
TeqFw_Core_Shared_Api_Logger$$: logger,
Demo_Back_Bot_Cmd_Help$: cmdHelp,
Demo_Back_Bot_Cmd_Settings$: cmdSettings,
Demo_Back_Bot_Cmd_Start$: cmdStart,
Demo_Back_Bot_Filter_Message$: filterMessage,
}
) {
// VARS
const CMD = DEF.CMD;
// INSTANCE METHODS
this.commands = async function (bot) {
// добавляет команды и их описание на русском и английском языках
};
this.handlers = function (bot) {
bot.command(CMD.HELP, cmdHelp);
bot.command(CMD.SETTINGS, cmdSettings);
bot.command(CMD.START, cmdStart);
bot.on('message', filterMessage);
return bot;
};
}
}
Обработчики событий
Все обработчики команд у меня находятся в пространстве Demo_Back_Bot_Cmd
, а обработчики прочих событий (фильтры) — в пространстве Demo_Back_Bot_Filter
. Код типового обработчика:
export default class Demo_Back_Bot_Cmd_Start {
constructor() {
return async (ctx) => {
const from = ctx.message.from;
const msgDef = 'Start';
const msgRu = 'Начало';
const msg = (from.language_code === 'ru') ? msgRu : msgDef;
// https://core.telegram.org/bots/api#sendmessage
await ctx.reply(msg);
};
}
}
Смысл обработки сводится к получению из контекста запроса необходимой информации, формированию на её основе управляющего воздействия и к созданию ответа с результатом.
Как правило, обработчик содержит гораздо больше кода, чем приведено в примере, поэтому рационально выносить его код в отдельный файл (или даже в группу файлов).
Головной файл
В npm-пакете бот-приложения также должен находиться и файл, представляющий из себя nodejs-приложение для запуска бота. Вот код этого файла:
#!/usr/bin/env node
'use strict';
import {dirname, join} from 'node:path';
import {fileURLToPath} from 'node:url';
import teq from '@teqfw/core';
const url = new URL(import.meta.url);
const script = fileURLToPath(url);
const bin = dirname(script);
const path = join(bin, '..');
teq({path}).catch((e) => console.error(e));
Код одинаков для всех приложений и, полагаю, может быть внесён в @teqfw/core
, но пока что лично я размещаю его в файле ./bin/tequila.mjs
.
Локальная конфигурация приложения
Приложение на базе Tequila Framework ищет локальную конфигурацию в файле ./cfg/local.json
. В нашем случае в этом файле должны лежать настройки подключения к телеграм-шлюзу и настройки работы веб-сервера:
{
"@flancer32/teq-telegram-bot": {
"apiKeyTelegram": "..."
},
"@teqfw/web": {
"server": {
"secure": {
"cert": "path/to/the/cert",
"key": "path/to/the/key"
},
"port": 8483
},
"urlBase": "virtual.server.com"
}
}
В принципе, можно считывать конфигурацию и из переменных окружения, но мне удобнее вот так.
Запуск бота с самоподписным сертификатом
Про запуск бота в режиме webhook
есть замечательный материал на английском языке — Marvin’s Marvellous Guide. В этом пункте я просто опишу команды, которые позволяют запустить на виртуальном сервере бот-приложение в режиме веб-сервера (webhook
).
Создание сертификата
Подробное описание процесса создания — здесь.
$ mkdir etc
$ cd ./etc
$ openssl req -newkey rsa:2048 -sha256 -nodes -keyout bot.key \
-x509 -days 3650 -out bot.pem \
-subj "/C=LV/ST=Riga/L=Bolderay/O=Test Bot/CN=grammy.demo.tg.teqfw.com"
В результате будет создано два файла в каталоге ./etc/:
./etc/bot.key
./etc/bot.pem
Конфигурация веб-сервера
В локальной конфигурации приложения (файл ./cfg/local.json) нужно прописать пути к ключу и сертификату, а также доменное имя для бота и порт, который слушает бот:
"@teqfw/web": {
"server": {
"secure": {
"cert": "etc/bot.pem",
"key": "etc/bot.key"
},
"port": 8443
},
"urlBase": "grammy.demo.tg.teqfw.com:8443"
}
Запуск бота в режиме webhook
$ npm run web-start
...
...: Web server is started on port 8443 in HTTPS mode (without web sockets).
...
$ npm run web-stop
Посмотреть состояние бота в этом режиме:
https://api.telegram.org/bot{TOKEN}/getWebhookInfo
{
"ok": true,
"result": {
"url": "https://grammy.demo.tg.teqfw.com:8443/telegram-bot",
"has_custom_certificate": true,
"pending_update_count": 0,
"last_error_date": 1725019662,
"last_error_message": "Connection refused",
"max_connections": 40,
"ip_address": "167.86.94.59"
}
}
Пример работы бота
Подключиться к боту в телеграм-клиенте можно здесь — flancer64_demo_grammy_bot.
Работа бота на ru-локали
Заключение
Спасибо всем, кто промотал статью до этого места — мне самому бывает влом читать длинные портянки текста и просто интересно, чем это всё закончится. Тем же, кто дочитал до заключения, пусть и вполглаза — мой искренний респект!
После ознакомления с основами ботостроения в Телеграм я пришёл к выводу, что Web 3.0 вполне себе можно построить не на браузерах, а на вот таких вот клиентах с упрощённым интерфейсом взаимодействия с пользователем (текстовые сообщения, возможно, с голосовым набором, плюс пересылка файлов) и широкой сетью ботов, взаимодействующих друг с другом.
P.S.
КДПВ создана Dall-E через браузерное приложение (исходники).