Telegram-бот на вебхуках — Java+Spring, Redis, клавиатуры и деплой на Heroku — снова для самых маленьких

от автора

В предыдущих сериях

Это вторая статья в моей серии «для самых маленьких» — предыдущая была посвящена «классическому» Telegram-боту, наследуемому от TelegramLongPollingBot.

Для кого написано

Если вы ни разу не писали Telegram-ботов на Java с использованием вебхуков и только начинаете разбираться — эта статья для вас. В ней подробно и с пояснениями описано создание реального бота, автоматизирующего одну конкретную функцию. Можно использовать статью как мануал для создания скелета своего бота, а потом подключить его к своей бизнес-логике.

Я пытаюсь писать как для себя, а не сразу для умных — надеюсь, кому-нибудь это поможет быстрее въехать в тему.

Предыстория

Учить словарные слова — занятие довольно скучное, а если делать это в лоб, ещё и малоэффективное, поэтому я решил разработать для дочери задания такого вида:

Ребёнок сначала выбирает правильный вариант, а затем закрепляет его написанием. По ощущениям, запоминание идёт неплохо. Дело оставалось за малым — написать бота и составить словари неправильных написаний.

Что в статье есть, чего нет

В статье есть про:

  • создание бекенда Telegram-бота на вебхуках на Java 11 с использованием Spring;
  • использование базы данных Redis;
  • отправку пользователю текстовых сообщений и файлов;
  • подключение постоянных и временных клавиатур;
  • локальный запуск бота для дебага;
  • деплой и запуск бота на Heroku, включая подключение к проекту Heroku Redis.

В статье нет про:

  • использование функций ботов, не перечисленных выше;
  • работу с Apache POI — создание Word и Excel файлов;
  • общение с BotFather (создание бота, получение его токена и формирование списка команд подробно и понятно описано во многих источниках, вот первый попавшийся мануал;
  • создание и загрузку в БД словарей по умолчанию.

Из примеров кода в статье эти функции исключены, чтобы упростить восприятие. Исходный код лежит на GitHub. Если у вас вдруг есть вопросы, пишите в личку, с удовольствием проконсультирую.

Бизнес-функции бота

Бот позволяет:

  • создавать Word-файлы с заданиями из имеющихся словарей (стандартных или пользовательского);
  • скачивать имеющиеся словари в Excel-файлы (для корректировки и последующей загрузки в качестве пользовательского словаря);
  • загружать пользовательский словарь;
  • выводить справку.

Можно потыкать — WriteReadRightBot. Выглядит так:

Порядок разработки

  • разобраться с зависимостями;
  • сконфигурировать БД;
  • создать бота;
  • реализовать обработку сообщений, включая работу с клавиатурами;
  • раскурить приём и отправку файлов;
  • завести локально;
  • задеплоить на heroku.

Ниже подробно расписан каждый пункт.

Зависимости

Для управления зависимостями используем Apache Maven. Нужные зависимости — собственно Telegram Spring Boot, Redis и Lombok, использовавшийся для упрощения кода (заменяет стандартные java-методы аннотациями).

Вот что вышло в

pom.xml

    <parent>         <groupId>org.springframework.boot</groupId>         <artifactId>spring-boot-starter-parent</artifactId>         <version>2.2.0.RELEASE</version>         <relativePath/>     </parent>     <modelVersion>4.0.0</modelVersion>      <groupId>ru.taksebe.telegram</groupId>     <artifactId>write-read</artifactId>     <version>1.0-SNAPSHOT</version>     <name>write-read</name>     <description>Пиши-читай</description>     <packaging>jar</packaging>      <properties>         <java.version>11</java.version>         <maven.compiler.source>${java.version}</maven.compiler.source>         <maven.compiler.target>${java.version}</maven.compiler.target>         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>         <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>     </properties>      <dependencies>         <dependency>             <groupId>org.springframework.boot</groupId>             <artifactId>spring-boot-starter-web</artifactId>         </dependency>         <dependency>             <groupId>org.springframework.boot</groupId>             <artifactId>spring-boot-starter-data-redis</artifactId>         </dependency>         <dependency>             <groupId>org.springframework.data</groupId>             <artifactId>spring-data-redis</artifactId>             <version>2.2.0.RELEASE</version>         </dependency>         <dependency>             <groupId>redis.clients</groupId>             <artifactId>jedis</artifactId>             <version>3.7.0</version>         </dependency>         <dependency>             <groupId>org.telegram</groupId>             <artifactId>telegrambots-spring-boot-starter</artifactId>             <version>5.3.0</version>         </dependency>         <dependency>             <groupId>org.projectlombok</groupId>             <artifactId>lombok</artifactId>             <version>1.18.20</version>             <scope>compile</scope>         </dependency>     </dependencies>      <build>         <plugins>             <plugin>                 <groupId>org.springframework.boot</groupId>                 <artifactId>spring-boot-maven-plugin</artifactId>                 <executions>                     <execution>                         <goals>                             <goal>build-info</goal>                         </goals>                         <configuration>                             <additionalProperties>                                 <encoding.source>${project.build.sourceEncoding}</encoding.source>                                 <encoding.reporting>${project.reporting.outputEncoding}</encoding.reporting>                                 <java.source>${maven.compiler.source}</java.source>                                 <java.target>${maven.compiler.target}</java.target>                             </additionalProperties>                         </configuration>                     </execution>                 </executions>             </plugin>         </plugins>     </build> 

Конфигурируем Redis

Создадим модель — классы-сущности, которые должны храниться в БД. В каждом из них должны быть ключ и значение — очень похоже на привычную Map<K,V>. В нашем случае сущности всего две — словарное слово

Word.java

import lombok.*; import lombok.experimental.FieldDefaults; import org.springframework.data.annotation.Id; import org.springframework.data.redis.core.RedisHash;  import java.util.Set;  @FieldDefaults(level = AccessLevel.PRIVATE) @Getter @Setter @AllArgsConstructor @NoArgsConstructor @RedisHash("word") public class Word {      @Id     String word;      /**      * Ошибочные варианты написания      */     Set<String> mistakes;     //тут переопределены equals() и hashCode() } 

… и словарь

Dictionary.java

import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.experimental.FieldDefaults; import org.springframework.data.annotation.Id; import org.springframework.data.redis.core.RedisHash;  import java.util.List;  @FieldDefaults(makeFinal = true, level = AccessLevel.PRIVATE) @Getter @RedisHash("dictionary") @Builder public class Dictionary {      @Id     String id;      List<Word> wordList; } 

Для сохранения сущностей в БД и обращения к ним нам нужны два конвертера, переводящие объект «Слово» в массив байт для сохранения в БД

WordToBytesConverter.java

import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.core.convert.converter.Converter; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import ru.taksebe.telegram.writeRead.model.Word;  import javax.annotation.Nullable;  public class WordToBytesConverter implements Converter<Word, byte[]> {     private final Jackson2JsonRedisSerializer<Word> serializer;      public WordToBytesConverter() {         serializer = new Jackson2JsonRedisSerializer<>(Word.class);         serializer.setObjectMapper(new ObjectMapper());     }      @Override     public byte[] convert(@Nullable Word value) {         return serializer.serialize(value);     } } 

… и обратно для получения объектов из БД

BytesToWordConverter.java

import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.core.convert.converter.Converter; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import ru.taksebe.telegram.writeRead.model.Word;  import javax.annotation.Nullable;  public class BytesToWordConverter implements Converter<byte[], Word> {     private final Jackson2JsonRedisSerializer<Word> serializer;      public BytesToWordConverter() {         serializer = new Jackson2JsonRedisSerializer<>(Word.class);         serializer.setObjectMapper(new ObjectMapper());     }      @Override     public Word convert(@Nullable byte[] value) {         return serializer.deserialize(value);     } } 

С использованием конвертеров создадим файл конфигурации

RedisConfiguration.java

import org.springframework.boot.autoconfigure.data.redis.LettuceClientConfigurationBuilderCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.convert.RedisCustomConversions; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; import ru.taksebe.telegram.writeRead.converters.BytesToWordConverter; import ru.taksebe.telegram.writeRead.converters.WordToBytesConverter;  import java.util.Arrays;  @Configuration @EnableRedisRepositories public class RedisConfiguration {      @Bean     public LettuceClientConfigurationBuilderCustomizer lettuceClientConfigurationBuilderCustomizer() {         return clientConfigurationBuilder -> {             if (clientConfigurationBuilder.build().isUseSsl()) {                 clientConfigurationBuilder.useSsl().disablePeerVerification();             }         };     }      @Bean     public RedisTemplate<?, ?> redisTemplate(RedisConnectionFactory redisConnectionFactory) {         RedisTemplate<byte[], byte[]> template = new RedisTemplate<>();         template.setConnectionFactory(redisConnectionFactory);         template.setKeySerializer(new StringRedisSerializer());         template.setValueSerializer(new GenericJackson2JsonRedisSerializer());         return template;     }      @Bean     public RedisCustomConversions redisCustomConversions() {         return new RedisCustomConversions(Arrays.asList(new WordToBytesConverter(),new BytesToWordConverter()));     } } 

Наконец, нужно создать репозиторий. Привыкшим к Postgre (как я) будет особенно приятно узнать, что работу с Redis поддерживает набивший оскомину CrudRepositoty<T, ID>. Поскольку мы используем только его стандартные методы, оставляем репозиторий без своих методов:

DictionaryRepository.java

import org.springframework.data.repository.CrudRepository; import org.springframework.stereotype.Repository; import ru.taksebe.telegram.writeRead.model.Dictionary;  @Repository public interface DictionaryRepository extends CrudRepository<Dictionary, String> { } 

К классу Word напрямую я не обращаюсь, поэтому для него репозиторий не нужен.

Создаём бота

Начнём с добавления в application.yaml (или application.properties, если так привычнее) трёх настроек:

  • telegram.webhookPath — адрес вебхука, который должен быть зарегистрирован в Telegram (об этом ниже, в разделе «Запускаем локально»);
  • telegram.botUsername и telegram.botToken — имя и токен бота, полученные от BotFather.

Далее, чтобы эти настройки можно было использовать в коде, создадим небольшой

TelegramConfig.java

import lombok.AccessLevel; import lombok.Getter; import lombok.experimental.FieldDefaults; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component;  @Component @Getter @FieldDefaults(level = AccessLevel.PRIVATE) public class TelegramConfig {     @Value("${telegram.webhook-path}")     String webHookPath;     @Value("${telegram.user}")     String userName;     @Value("${telegram.token}")     String botToken; } 

Теперь создадим класс бота и унаследуем его от SpringWebhookBot

WriteReadBot.java

import lombok.AccessLevel; import lombok.Getter; import lombok.Setter; import lombok.experimental.FieldDefaults; import org.telegram.telegrambots.meta.api.methods.BotApiMethod; import org.telegram.telegrambots.meta.api.methods.send.SendMessage; import org.telegram.telegrambots.meta.api.methods.updates.SetWebhook; import org.telegram.telegrambots.meta.api.objects.CallbackQuery; import org.telegram.telegrambots.meta.api.objects.Message; import org.telegram.telegrambots.meta.api.objects.Update; import org.telegram.telegrambots.starter.SpringWebhookBot; import ru.taksebe.telegram.writeRead.constants.bot.BotMessageEnum; import ru.taksebe.telegram.writeRead.telegram.handlers.CallbackQueryHandler; import ru.taksebe.telegram.writeRead.telegram.handlers.MessageHandler;  import java.io.IOException;  @Getter @Setter @FieldDefaults(level = AccessLevel.PRIVATE) public class WriteReadBot extends SpringWebhookBot {     String botPath;     String botUsername;     String botToken;      MessageHandler messageHandler;     CallbackQueryHandler callbackQueryHandler;      public WriteReadBot(SetWebhook setWebhook, MessageHandler messageHandler,CallbackQueryHandler callbackQueryHandler) {         super(setWebhook);         this.messageHandler = messageHandler;         this.callbackQueryHandler = callbackQueryHandler;     }      @Override     public BotApiMethod<?> onWebhookUpdateReceived(Update update) {         try {             return handleUpdate(update);         } catch (IllegalArgumentException e) {             return new SendMessage(update.getMessage().getChatId().toString(),                     BotMessageEnum.EXCEPTION_ILLEGAL_MESSAGE.getMessage());         } catch (Exception e) {             return new SendMessage(update.getMessage().getChatId().toString(),                     BotMessageEnum.EXCEPTION_WHAT_THE_FUCK.getMessage());         }     }      private BotApiMethod<?> handleUpdate(Update update) throws IOException {         if (update.hasCallbackQuery()) {             CallbackQuery callbackQuery = update.getCallbackQuery();             return callbackQueryHandler.processCallbackQuery(callbackQuery);         } else {             Message message = update.getMessage();             if (message != null) {                 return messageHandler.answerMessage(update.getMessage());             }         }         return null;     } } 

MessageHandler и CallbackQueryHandler — обработчики (соответственно) сообщений и нажатий на кнопки инлайн-клавиатур (подробнее ниже, в разделе «Обрабатываем сигналы»).

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

Для создания бина бота нам нужна ещё одна конфигурация —

SpringConfig.java

import lombok.AllArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.telegram.telegrambots.meta.api.methods.updates.SetWebhook; import ru.taksebe.telegram.writeRead.telegram.WriteReadBot; import ru.taksebe.telegram.writeRead.telegram.handlers.CallbackQueryHandler; import ru.taksebe.telegram.writeRead.telegram.handlers.MessageHandler;  @Configuration @AllArgsConstructor public class SpringConfig {     private final TelegramConfig telegramConfig;      @Bean     public SetWebhook setWebhookInstance() {         return SetWebhook.builder().url(telegramConfig.getWebHookPath()).build();     }      @Bean     public WriteReadBot springWebhookBot(SetWebhook setWebhook,                                          MessageHandler messageHandler,                                          CallbackQueryHandler callbackQueryHandler) {         WriteReadBot bot = new WriteReadBot(setWebhook, messageHandler, callbackQueryHandler);          bot.setBotPath(telegramConfig.getWebHookPath());         bot.setBotUsername(telegramConfig.getUserName());         bot.setBotToken(telegramConfig.getBotToken());          return bot;     } } 

Поскольку наш бот — это веб-приложение, для доступа к нему нам нужен контроллер —

WebhookController.java

import lombok.AllArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import org.telegram.telegrambots.meta.api.methods.BotApiMethod; import org.telegram.telegrambots.meta.api.objects.Update;  @RestController @AllArgsConstructor public class WebhookController {     private final WriteReadBot writeReadBot;      @PostMapping("/")     public BotApiMethod<?> onUpdateReceived(@RequestBody Update update) {         return writeReadBot.onWebhookUpdateReceived(update);     } } 

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

WriteReadApplication.java

import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;  @SpringBootApplication public class WriteReadApplication {      public static void main(String[] args) {         SpringApplication.run(WriteReadApplication.class, args);     } } 

Бот готов, осталось научить его общаться с пользователем.

Обрабатываем сигналы

Как уже говорилось, наш бот получает от пользователя сигналы двух типов — сообщения и нажатия на кнопки инлайн-клавиатур. Эти сигналы обрабатываются в классах MessageHandler и CallbackQueryHandler, а маршрутизация между ними осуществляется в классе бота WriteReadBot (его код чуть выше, в разделе «Создаём бота»).

Классы-обработчики служат связующим звеном между ботом и бизнес-логикой решаемой задачи — в зависимости от конкретного полученного значения происходит обращение к нужного методу внешнего сервиса. Код этих классов можно посмотреть в этом пакете на Github, а в статье рассмотрим только две ключевых детали — создание клавиатур и работу с файлами.

Постоянная клавиатура

Чтобы сразу было понятно, вот это она:

image

Постоянная клавиатура — это основное меню бота. Она создаётся в отдельном классе путём создания отдельных кнопок, затем их рядов и, в завершении, присвоения клавиатуре нужных признаков —

ReplyKeyboardMaker.java

import org.springframework.stereotype.Component; import org.telegram.telegrambots.meta.api.objects.replykeyboard.ReplyKeyboardMarkup; import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardButton; import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardRow; import ru.taksebe.telegram.writeRead.constants.bot.ButtonNameEnum;  import java.util.ArrayList; import java.util.List;  @Component public class ReplyKeyboardMaker {      public ReplyKeyboardMarkup getMainMenuKeyboard() {         KeyboardRow row1 = new KeyboardRow();         row1.add(new KeyboardButton(ButtonNameEnum.GET_TASKS_BUTTON.getButtonName()));         row1.add(new KeyboardButton(ButtonNameEnum.GET_DICTIONARY_BUTTON.getButtonName()));          KeyboardRow row2 = new KeyboardRow();         row2.add(new KeyboardButton(ButtonNameEnum.UPLOAD_DICTIONARY_BUTTON.getButtonName()));         row2.add(new KeyboardButton(ButtonNameEnum.HELP_BUTTON.getButtonName()));          List<KeyboardRow> keyboard = new ArrayList<>();         keyboard.add(row1);         keyboard.add(row2);          final ReplyKeyboardMarkup replyKeyboardMarkup = new ReplyKeyboardMarkup();         replyKeyboardMarkup.setKeyboard(keyboard);         replyKeyboardMarkup.setSelective(true);         replyKeyboardMarkup.setResizeKeyboard(true);         replyKeyboardMarkup.setOneTimeKeyboard(false);          return replyKeyboardMarkup;     } } 

Для удобства названия кнопок можно вынести в отдельный ButtonNameEnum (на GitHub), но это необязательно — можно прописать их текстом прямо в классе.

Инициализируется клавиатура в рамках обработки команды /start (то есть при первом обращении пользователя к боту) в классе-обработчике сообщений MessageHandler. Необходимо

добавить постоянную клавиатуру в ответное сообщение

SendMessage sendMessage = new SendMessage(<id чата>, <текст ответа>); sendMessage.setReplyMarkup(replyKeyboardMaker.getMainMenuKeyboard()); return sendMessage; 

Кроме того, в классе MessageHandler надо не забыть обработать текстовые сообщения, отличные от названий кнопок — наш бот в этом случае призывает пользователя воспользоваться клавиатурой.

Инлайн-клавиатуры

Это вот такое:

image

В нашем боте инлайн-клавиатуры используются для выбора пользователем словаря и отображаются в ответ на команды основного меню «Создать файл с заданиями» и «Скачать словарь». Состав кнопок отличается всего на одну позицию — при обработке команды «Скачать словарь» добавляется кнопка «Шаблон». Кроме того, если пользователь загрузил в базу данных свой словарь, при обработке обеих команд в клавиатуру добавляется кнопка «Ваш словарь».

Также, как и постоянная клавиатура, инлайн-версия формируется в отдельном классе

InlineKeyboardMaker.java

import org.springframework.stereotype.Component; import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup; import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton; import ru.taksebe.telegram.writeRead.constants.bot.CallbackDataPartsEnum; import ru.taksebe.telegram.writeRead.constants.resources.DictionaryResourcePathEnum;  import java.util.ArrayList; import java.util.List;  @Component public class InlineKeyboardMaker {      public InlineKeyboardMarkup getInlineMessageButtonsWithTemplate(String prefix, boolean isUserDictionaryNeed) {         InlineKeyboardMarkup inlineKeyboardMarkup = getInlineMessageButtons(prefix, isUserDictionaryNeed);         inlineKeyboardMarkup.getKeyboard().add(getButton(                 "Шаблон",                 prefix + CallbackDataPartsEnum.TEMPLATE.name()         ));         return inlineKeyboardMarkup;     }      public InlineKeyboardMarkup getInlineMessageButtons(String prefix, boolean isUserDictionaryNeed) {         List<List<InlineKeyboardButton>> rowList = new ArrayList<>();          for (DictionaryResourcePathEnum dictionary : DictionaryResourcePathEnum.values()) {             rowList.add(getButton(                     dictionary.getButtonName(),                     prefix + dictionary.name()             ));         }          if (!rowList.isEmpty()) {             rowList.add(getButton(                     "Все классы",                     prefix + CallbackDataPartsEnum.ALL_GRADES.name()             ));         }          if (isUserDictionaryNeed) {             rowList.add(getButton(                     "Ваш словарь",                     prefix + CallbackDataPartsEnum.USER_DICTIONARY.name()             ));         }          InlineKeyboardMarkup inlineKeyboardMarkup = new InlineKeyboardMarkup();         inlineKeyboardMarkup.setKeyboard(rowList);         return inlineKeyboardMarkup;     }      private List<InlineKeyboardButton> getButton(String buttonName, String buttonCallBackData) {         InlineKeyboardButton button = new InlineKeyboardButton();         button.setText(buttonName);         button.setCallbackData(buttonCallBackData);          List<InlineKeyboardButton> keyboardButtonsRow = new ArrayList<>();         keyboardButtonsRow.add(button);         return keyboardButtonsRow;     } } 

В отличие от кнопок постоянных клавиатур, инлайн-кнопкам можно добавлять не только название (которое видит пользователь), но и ответное значение, которое будет отправлено при нажатие на неё.

Инициализация инлайн-клавиатуры происходит

в целом аналогично постоянной

SendMessage sendMessage = new SendMessage(<id чата>, <текст ответа>); sendMessage.setReplyMarkup(inlineKeyboardMaker.getInlineMessageButtons(<аргументы, связанные с бизнес-логикой>)); return sendMessage; 

Отправка и получение файлов

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

Отправка и загрузка файлов происходит в отдельном классе, в котором реализовано REST-взаимодействие с сервисами Telegram —

TelegramApiClient.java

import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.ByteArrayResource; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.StreamUtils; import org.springframework.web.client.RestTemplate; import org.telegram.telegrambots.meta.api.objects.ApiResponse; import ru.taksebe.telegram.writeRead.exceptions.TelegramFileNotFoundException; import ru.taksebe.telegram.writeRead.exceptions.TelegramFileUploadException;  import java.io.File; import java.io.FileOutputStream; import java.text.MessageFormat; import java.util.Objects;  @Service public class TelegramApiClient {     private final String URL;     private final String botToken;      private final RestTemplate restTemplate;      public TelegramApiClient(@Value("${telegram.api-url}") String URL,                              @Value("${telegram.token}") String botToken) {         this.URL = URL;         this.botToken = botToken;         this.restTemplate = new RestTemplate();     }      public void uploadFile(String chatId, ByteArrayResource value) {         LinkedMultiValueMap<String, Object> map = new LinkedMultiValueMap<>();         map.add("document", value);          HttpHeaders headers = new HttpHeaders();         headers.setContentType(MediaType.MULTIPART_FORM_DATA);          HttpEntity<LinkedMultiValueMap<String, Object>> requestEntity = new HttpEntity<>(map, headers);          try {             restTemplate.exchange(                     MessageFormat.format("{0}bot{1}/sendDocument?chat_id={2}", URL, botToken, chatId),                     HttpMethod.POST,                     requestEntity,                     String.class);         } catch (Exception e) {             throw new TelegramFileUploadException();         }     }      public File getDocumentFile(String fileId) {         try {             return restTemplate.execute(                     Objects.requireNonNull(getDocumentTelegramFileUrl(fileId)),                     HttpMethod.GET,                     null,                     clientHttpResponse -> {                         File ret = File.createTempFile("download", "tmp");                         StreamUtils.copy(clientHttpResponse.getBody(), new FileOutputStream(ret));                         return ret;                     });         } catch (Exception e) {             throw new TelegramFileNotFoundException();         }     }      private String getDocumentTelegramFileUrl(String fileId) {         try {             ResponseEntity<ApiResponse<org.telegram.telegrambots.meta.api.objects.File>> response = restTemplate.exchange(                     MessageFormat.format("{0}bot{1}/getFile?file_id={2}", URL, botToken, fileId),                     HttpMethod.GET,                     null,                     new ParameterizedTypeReference<ApiResponse<org.telegram.telegrambots.meta.api.objects.File>>() {                     }             );             return Objects.requireNonNull(response.getBody()).getResult().getFileUrl(this.botToken);         } catch (Exception e) {             throw new TelegramFileNotFoundException();         }     } } 

Методы этого класса используются в классах-обработчиках MessageHandler и CallbackQueryHandler.

Для отправки файла пользователю необходимо перевести его в объект класса ByteArrayResource и отправить POST-запрос на адрес вида:
https://api.telegram.org/bot<токен бота>/sendDocument?chat_id=<id чата>

При загрузке пользовательского файла в составе объекта Document приходит идентификатор файла. Чтобы скачать файл, необходимо отправить GET-запрос на адрес вида:
https://api.telegram.org/bot<токен бота>/getFile?file_id=<id файла>

Следует обратить внимание, что скачивается объект File из пакета org.telegram.telegrambots.meta.api.objects, и для последующего использования мы переводим его в привычный java.io.File.

Подключаем Heroku Redis для локального запуска

Идём на Heroku и выполняем алгоритм:

  • Зарегистрироваться (если нет аккаунта)
  • Создать проект — нажать «New»/«Create new app» в правой части экрана
  • Перейти на вкладку «Resources»
  • В разделе «Add-ons» ввести в поисковую строку «Heroku Redis», выбрать её в результатах поиска
  • Подтвердить подключение БД к проекту
  • В правом верхнем углу нажать на иконку в виде квадрата из синих точек, выбрать пункт Data, в открывшемся списке баз нажать на только что созданную. Первые несколько минут после подключения может тормозить и показывать ошибку
  • Перейти на вкладку «Settings»
  • Нажать на кнопку «View credentials» в правой части экрана
  • Вуаля, перед Вами настройки подключения к БД

Надо помнить, что эти настройки Heroku периодически меняет, поэтому иногда нужно будет заново копировать их в Ваш проект

Заполняем application.yaml:
spring.redis.database: 0
spring.redis.host: <хост БД Redis, копируем с Heroku>
spring.redis.port: <порт БД Redis, копируем с Heroku>
spring.redis.password: <пароль БД Redis, копируем с Heroku>
spring.redis.ssl: true

Запускаем локально

Нам осталось сделать вебхук и зарегистрировать его в Telegram.

Для получения внешнего адреса при локальном запуске используем утилиту ngrok по вот этой инструкции. Не забываем добавить в application.yaml настройки telegram.webhook-path (выдаст ngrok) и server.port (передаётся ngrok в качестве параметра)

Регистрируем вебхук в Telegram, формируя в строке браузера запрос вида:
https://api.telegram.org/bot<токен бота>/setWebhook?url=<URL, полученный от ngrok>

… видим ответ:
{"ok":true,"result":true,"description":"Webhook was set"}

… и запускаем приложение в своей IDE!

Деплоим на Heroku

Если используется версия Java, отличная от 8, необходимо в корне проекта создать файл system.properties, содержащий одну строку:
java.runtime.version=11

Ещё один специфический для Heroku файл Procfile в данном случае можно не добавлять, он будет сгенерирован автоматически на основе pom.xml.

Сначала нужно обязательно удалить/закомментировать в application.yaml настройки подключения к БД — она подцепится автоматически, поскольку подключена к проекту на Heroku. Если оставить эти настройки, ничего не заведётся, они нужны только для внешнего подключения к этой БД.

Далее:

  • в консоли heroku create <имя приложения> (либо heroku git:remote -a <название проекта>, если приложение на Heroku уже было создано ранее)
  • в интерфейсе Heroku создать в проекте БД Heroku Redis (если ранее это не было сделано — алгоритм выше, в разделе «Запускаем локально»)
  • в консоли mvn clean install
  • в консоли git push heroku master
  • в консоли heroku ps:scale web=1 — установить количество используемых контейнеров (dynos) для типа процесса web
  • открыть приложение — нажать на кнопку «Open app» в интерфейсе Heroku, убедиться, что оно запустилось (должна отображаться надпись «Whitelabel Error Page» — значит, успех)
  • зарегистрировать вебхук в Telegram (алгоритм выше, в разделе «Запускаем локально»), используя URL из адресной строки предыдущего пункта

Теперь можно проверять бота непосредственно в Telegram!

При необходимости в интерфейсе Heroku на вкладке «Deploy» можно переключить деплой на GitHub-репозиторий (по запросу или автоматически).

Что можно доделать

Как известно, Heroku гасит веб-приложения, которые не используются какое-то время, поэтому на первое сообщение бот может отвечать порядка 8-10 секунд — он ждёт, когда приложение развернётся с нуля. Это позволяет на бесплатном тарифе хостить много редко используемых веб-приложений — в тарифе учитывается только время аптайма.

Чтобы заставить приложение работать постоянно, можно добавить в проект пинг по расписанию условного Google, но нужно понимать, что в этом случае бот будет съедать львиную долю бесплатного тарифа. Я жадный, я так делать не буду.

Вместо заключения

Оказывается, и мультик про ПишиЧитая тоже мало кто смотрел.


ссылка на оригинал статьи https://habr.com/ru/post/646017/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *