Пишем Telegram бота на вебхуках

от
Java    heroku, telegram, bot, webhook, вебхуки

Эта статья является дополнением к предыдущей «Пишем бота для Telegram и хостим его на heroku». Здесь я покажу, как сделать бота с поддержкой вебхуков на Heroku и с самоподписанным сертификатом на своём сервере. Расскажу о новых возможностях библиотеки tgbots-module, а также покажу пример встраивания двух ботов в один проект.

Содержание  - tgbots-module
  - 1. Базовая реализация бота
  - 2. Добавляем систему команд
  - 3. Команда на регулярных выражениях
  - 4. Группа команд
  - 5. config.yaml и профили
  - 6a. Запуск на Heroku
  - 6b. Запуск на своём сервере с самоподписанным сертификатом
  - 7. Второй модуль бота

tgbots-module
tgbots-module основана на библиотеке TelegramBots и имеет ряд улучшений:

  • Возможность вести разработку ботов в отдельных проектах, а затем собирать всех в одном исполняемом файле. Это позволяет работать над каждым ботом отдельно, заводить для них отдельные репозитории и т.д.
    TelegramBots поддерживает регистрацию нескольких обработчиков для ботов в одном проекте, но для отключения бота приходится пересобирать проект. В tgbots-module для включения/отключения бота можно просто добавить или удалить строчку в конфиге, а затем перезапустить приложение.

  • Возможность переключать способ получения обновлений с long polling на webhook и обратно без пересборки проекта. В TelegramBots для этого нужно менять базовый класс (TelegramLongPollingBot и TelegramWebHookBot), а затем пересобирать проект.

  • Система команд с поддержкой ролей. Есть простые текстовые команды (/start, /help), команды с аргументами (/say hello), команды на регулярных выражениях (/donate (\d{1,3})) и команды для CallbackQuery и InlineQuery.

  • Различные улучшения в API и полезные утилиты. Загрузчик yaml-конфигов, классы для локализации ботов, препроцессинг методов API. Есть возможность вызывать методы Telegram API из класса Methods. В таком случае появляются дополнительные полезные методы, которых нет в TelegramBots:
  1. Methods.sendMessage(chatId, text)
  2.         .enableHtml()
  3.         .inReplyTo(message)
  4.         .callAsync(this);
Можно вызывать синхронный .call(this), а можно асинхронный .callAsync(this).


1. Базовая реализация бота
Чтобы было проще понять, на каком этапе что реализуется, я разбил создание двухмодульного бота на мелкие шаги. Начнём с создания простого бота. Можно смотреть коммит.

Создаём модуль бота и его обработчик:
  1. public class TestBot implements BotModule {
  2.     public static void main(String[] args) {
  3.         Runner.run(List.of(new TestBot()));
  4.     }
  5.  
  6.     @Override
  7.     public @NotNull BotHandler botHandler(@NotNull Config config) {
  8.         return new TestBotHandler();
  9.     }
  10. }
  1. public class TestBotHandler extends BotHandler {
  2.     private final TestBotConfig botConfig;
  3.  
  4.     public TestBotHandler() {
  5.         final var configLoader = new YamlConfigLoaderService();
  6.         botConfig = configLoader.loadResource("/testbot.yaml", TestBotConfig.class);
  7.     }
  8.  
  9.     @Override
  10.     protected BotApiMethod<?> onUpdate(@NotNull Update update) {
  11.         final var msg = update.getMessage();
  12.         if (msg != null && msg.hasText()) {
  13.             System.out.println(msg.getChatId());
  14.             Methods.sendMessage(msg.getChatId(), msg.getText().toUpperCase(Locale.ROOT))
  15.                     .callAsync(this);
  16.         }
  17.         return null;
  18.     }
  19.  
  20.     @Override
  21.     public String getBotUsername() {
  22.         return botConfig.getName();
  23.     }
  24.  
  25.     @Override
  26.     public String getBotToken() {
  27.         return botConfig.getToken();
  28.     }
  29. }

Токен и юзернейм бота будут браться из yaml конфига testbot.yaml:
  1. name: yoursuperbot
  2. token: env(TOKEN)
И помещаться в объект:
  1. @JsonIgnoreProperties(ignoreUnknown = true)
  2. public class TestBotConfig {
  3.     private String name;
  4.     private String token;
  5.     // getters, setters
  6. }

В конфиге строчка env(TOKEN) говорит о том, что значение следует взять из переменной окружения. Это в дальнейшем будет полезно при работе с Heroku. Чтобы поместить токен в переменную окружения, можно настроить Run configurations в идее:
shot-20210324T155609.png

Содержимое метода onUpdate должно быть уже вам знакомо по библиотеке TelegramBots: если в update приходит сообщение боту, в котором есть текст, то выводит id юзера в консоль и отправляем этот же текст в верхнем регистре юзеру.

Запускаем проект, пишем боту и получаем ответ:
shot-20210324T160107.png


2. Добавляем систему команд
Добавляем систему команд с простой текстовой командой /start:
  1. private final CommandRegistry<For> commands;
  2. // ..
  3. final var authority = new SimpleAuthority(this, botConfig.getAdminId());
  4. commands = new CommandRegistry<>(this, authority);
  5. commands.register(new SimpleCommand("/start", ctx -> {
  6.     ctx.reply("Hi, " + ctx.user().getFirstName())
  7.             .callAsync(ctx.sender);
  8. }));

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

Чтобы реестр команд понимал кто есть кто, нужно реализовать интерфейс Authority или воспользоваться встроенным SimpleAuthority, которому нужно передать userId главного админа бота. Как обычно, чтобы не хардкодить, добавляем значение в конфиг:
  1. name: yoursuperbot
  2. token: env(TOKEN)
  3. adminId: 1234
  1. public class TestBotConfig {
  2.     private String name;
  3.     private String token;
  4.     private Long adminId;
  5.     // getters, setters
  6. }

Наконец, нужно добавить строку в onUpdate:
  1. @Override
  2. protected BotApiMethod<?> onUpdate(@NotNull Update update) {
  3.     if (commands.handleUpdate(update)) {
  4.         return null;
  5.     }
  6.     // ...
  7. }

Коммит со всеми изменениями на этом шаге.


3. Команда на регулярных выражениях
Обработчики простых команд удобно регистрировать лямбдой, а вот команды побольше лучше вынести в отдельный класс. Рассмотрим пример команды, которая распознаёт ссылку на YouTube:
  1. public class YouTubeThumbnail implements RegexCommand {
  2.     @Override
  3.     public Pattern pattern() {
  4.         return Pattern.compile("https?://(?:www\\.)?youtu(?:\\.be/|be.com/watch\\?v=)([^#&?\\s]+)");
  5.     }
  6.  
  7.     @SuppressWarnings("unchecked")
  8.     @Override
  9.     public EnumSet<For> authority() {
  10.         return For.all();
  11.     }
  12.  
  13.     @Override
  14.     public void accept(@NotNull RegexMessageContext ctx) {
  15.         var url = "https://img.youtube.com/vi/" + ctx.group(1) + "/hqdefault.jpg";
  16.         ctx.replyWithPhoto()
  17.                 .setFile(new InputFile(url))
  18.                 .setCaption(url)
  19.                 .callAsync(ctx.sender);
  20.     }
  21. }

Для RegexCommand указываем регулярное выражение, роли и сам обработчик.

Чтобы зарегистрировать команду, нужно добавить одну строчку:
  1. commands.register(new YouTubeThumbnail());

Коммит для этого шага.


4. Группа команд
Ещё одной полезной возможностью является вынесение команд в группу.
Например, есть простая текстовая команда /game, которая создаёт игру и отправляет сообщение с inline-клавиатурой. Также есть команда для обработки CallbackQuery этой игры. Почему бы не задать эти команды в одном классе?

  1. public class GuessNumberGame implements CommandBundle<For> {
  2.     @Override
  3.     public void register(@NotNull CommandRegistry<For> commands) {
  4.         commands.register(new SimpleCommand("/game", this::startGame));
  5.         commands.register(new SimpleCallbackQueryCommand("guess", this::checkGuess));
  6.     }
  7.  
  8.     private void startGame(MessageContext ctx) {
  9.         final var userId = ctx.chatId();
  10.         // ...
  11.     }
  12.  
  13.     private void checkGuess(CallbackQueryContext ctx) {
  14.         final var userId = Long.parseLong(ctx.argument(0));
  15.         final var guess = Integer.parseInt(ctx.argument(1));
  16.         // ..
  17.     }
  18. }

callback data для inline-кнопки выглядит так:
  1. guess:12345 7
Где, guess это команда, распознаваемая системой команд, а всё остальное — аргументы. Они разделяются пробелом и их можно достать по порядковому номеру через ctx.argument(..)

Регистрируем набор команд:
  1. commands.registerBundle(new GuessNumberGame());

Коммит с изменениями.


5. config.yaml и профили
В config.yaml задаются настройки вебхуков, а также перечисляются модули ботов:
  1. log-level: INFO
  2. webhook:
  3.   enabled: false
  4.   externalUrl: env(URL)
  5. modules:
  6.  - com.example.bot.TestBot
Именно здесь можно переключать long polling и webhook, а также отключать ботов.
Конфиг можно размещать как файл, либо как ресурс внутри приложения (удобно, если бот запускается на Heroku).
При этом, конфиг не является обязательным. По умолчанию используется long polling, а модулем становится тот, который указан в main:
  1. Runner.run(List.of(new TestBot()));

Также есть поддержка профилей. Профиль по умолчанию использует config.yaml, но если задать профиль test или prod, то использоваться будут config-test.yaml или config-prod.yaml соответственно.
Чтобы указать профиль, необходимо передать его первым аргументом в Runner. Я делаю это из командной строки:
  1. public static void main(String[] args) {
  2.     final var profile = args.length >= 1 ? args[0] : "";
  3.     Runner.run(profile, List.of(new TestBot()));
  4. }
  1. java -jar bots.jar test
  2. java -jar bots.jar prod

Всё это позволяет запускать одного бота на лонг поллинге при разработке, и всех ботов на вебхуках уже в продакшене.

Коммит для этого шага.


6a. Запуск на Heroku
Добавляем Procfile:
  1. web: java -jar build/libs/tgbotsmodule-webhook-bot-1.0-SNAPSHOT-all.jar
Так как используется Java 11, указываем это в system.properties:
  1. java.runtime.version=11

Также добавляем gradle таск stage в build.gradle:
  1. task stage(dependsOn: ['clean', 'shadowJar'])
Иначе Heroku не сможет собрать приложение.

Не забываем добавить файл config.yaml:
  1. log-level: INFO
  2. webhook:
  3.   enabled: true
  4.   port: env(PORT:8443)
  5.   externalUrl: env(URL)
  6.   internalUrl: http://0.0.0.0:$port
  7. modules:
  8.  - com.example.bot.TestBot
Heroku запускает веб-приложение каждый раз на разном порту, заранее узнать и записать значение порта нельзя. Но поскольку номер порта помещается в переменную окружения PORT, можно указать env(PORT) в конфиге.
То же самое проделываем с URL, чтобы не коммитить новый адрес при смене приложения в Heroku.

Создаём приложение в Heroku, добавляем в Config Vars токен от бота и адрес приложения:
shot-20210324T191516.png
Деплоим бота из гитхаба или из Heroku Git и радуемся работающему на вебхуках боту.

В отличие от лонг поллинга, который держит приложение всегда активным и, тем самым отъедая бесплатную квоту, бот на вебхуках может засыпать при длительном бездействии, что экономит дино-часы. Пробуждение составляет порядка 10-15 секунд, это вполне терпимо для обычного бота, но совершенно не подходит для инлайн ботов, имейте в виду.


6b. Запуск на своём сервере с самоподписанным сертификатом
Имеем: Debian или Ubuntu сервер, на котором порты 80 и 443 заняты основным сайтом, но хочется на нём запустить бота на вебхуках на порту 8443, который, благо, поддерживается телеграмом.

Допустим, IP-адрес сервера 123.45.67.89, находится он в Москве.

1. Создаём сертификат при помощи openssl
certgen.sh
  1. JKS=keystore.jks
  2. CERT=public_cert.pem
  3.  
  4. openssl req -newkey rsa:2048 -sha256 -nodes \
  5.     -keyout private.key -x509 \
  6.     -days 365 \
  7.     -out $CERT \
  8.     -subj "/C=RU/ST=Moscow/L=Moscow/O=Organization/CN=123.45.67.89"
  9.  
  10. openssl pkcs12 -export \
  11.     -in $CERT \
  12.     -inkey private.key \
  13.     -certfile $CERT \
  14.     -out keystore.p12
  15.  
  16. keytool -importkeystore \
  17.     -srckeystore keystore.p12 \
  18.     -srcstoretype pkcs12 \
  19.     -sigalg SHA1withRSA \
  20.     -destkeystore $JKS \
  21.     -deststoretype pkcs12
  22.  
  23. rm keystore.p12 private.key
Изменяем IP адрес и информацию о локации сервера на этой строчке:
  1. -subj "/C=RU/ST=Moscow/L=Moscow/O=Organization/CN=123.45.67.89"

Запускаем скрипт, придумываем и вводим несколько раз пароль от сертификата, получаем в итоге два файла: keystore.jks и public_cert.pem.

2. Настраиваем вебхуки в config.yaml
  1. webhook:
  2.   enabled: true
  3.   port: 8443
  4.   externalUrl: https://123.45.67.89:$port
  5.   internalUrl: http://0.0.0.0:$port
  6.   keystorePath: cert/keystore.jks
  7.   keystorePassword: env(KEYSTORE_PASSWORD)
  8.   certificatePublicKeyPath: cert/public_cert.pem

3. Запускаем приложение со всеми необходимыми переменными окружения
  1. KEYSTORE_PASSWORD=mysupersecretpasswordis123456 TOKEN=12345:ABCDEFG && java -jar tgbotsmodule-webhook-bot-all.jar


7. Второй модуль бота
Если вы планируете запускать бота на собственном сервере, а не на Heroku, тогда можно создать Gradle Multiproject в котором подпроектами будут отдельные модули бота:
  1. bots/
  2.   gradle/
  3.   testbot/
  4.     .git/
  5.     src/
  6.     build.gradle
  7.   secondbot/
  8.     .git/
  9.     src/
  10.     build.gradle
  11.   build.gradle
  12.   settings.gradle
При этом остаётся возможность держать отдельные модули в отдельных репозиториях, запускать их по отдельности при отладке и собирать все модули в один jar при сборке.

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

Создаём отдельный проект со вторым ботом (можно сразу смотреть весь репозиторий tgbotsmodule-bot2).

В build.gradle должны присутствовать как минимум эти три плагина:
  1. plugins {
  2.     id 'java-library'
  3.     id 'application'
  4.     id 'maven'
  5. }
Первый позволит точнее указывать зависимости (api, implementation), второй позводит запускать приложение для отладки, а третий подготовит нужные файлы для релиза.

Так как используется Java 11, создаём файл jitpack.yml:
  1. jdk:
  2.   - openjdk11
  3. install:
  4.   - ./gradlew clean build install

Создаём конфиг для второго бота secondbot.yaml:
  1. username: secondbot
  2. token: env(BOT2_TOKEN)
Не забываем о возможном конфликте переменных окружения, токен для второго бота должен задаваться в другой переменной, нежели токен для первого.

config.yaml у второго ботмодуля в этот раз уже не понадобится, он будет у главного приложения.

В главном приложении в build.gradle добавляем новый репозиторий maven и зависимость:
  1. repositories {
  2.     mavenCentral()
  3.     maven { url "https://jitpack.io" }
  4. }
  5.  
  6. dependencies {
  7.     // ...
  8.     implementation 'com.github.annimon-tutorials:tgbotsmodule-bot2:e0a072b0f3'
  9. }
Правильно подключить зависимость поможет главная страница https://jitpack.io/
Вставляем адрес репозитория и нажимаем Look up, а напротив нужного коммита кнопку Get it:
20210325_121540.png
Ниже появятся инструкции для подключения зависимости в проект.

В config.yaml главного приложения добавляем ещё один модуль:
  1. log-level: INFO
  2. webhook:
  3.   enabled: true
  4.   port: env(PORT:8443)
  5.   externalUrl: env(URL)
  6.   internalUrl: http://0.0.0.0:$port
  7. modules:
  8.  - com.example.bot.TestBot
  9.  - com.example.secondbot.SecondBot

Остаётся только запушить изменения на гитхаб, добавить в Heroku новую переменную окружения с токеном для второго бота и сделать Deploy.

Коммит с изменениями на этом шаге.


После успешного деплоя, оба бота будут работать на вебхуках в одном проекте.
Посмотреть в действии можно тут:
Первый бот: @tgbotsmodulebot (исходники)
Второй бот: @tgbotsmodule2bot (исходники)
  • +5
  • views 9513