Мой опыт создания телеграм-бота на 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

По сути, в Телеграме улучшили 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-пакетов

Дерево 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-локали

Работа бота на ru-локали

Заключение

Спасибо всем, кто промотал статью до этого места — мне самому бывает влом читать длинные портянки текста и просто интересно, чем это всё закончится. Тем же, кто дочитал до заключения, пусть и вполглаза — мой искренний респект!

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

P.S.

КДПВ создана Dall-E через браузерное приложение (исходники).

© Habrahabr.ru