
В предыдущих сериях
Это вторая статья в моей серии «для самых маленьких» — предыдущая была посвящена «классическому» 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; }
Для сохранения объектов в БД и обращения к ним нам нужны два конвертера, переводящие объект «Слово» в массив байт (да-да, Redis нужно скормить именно его) для сохранения
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.api-url— тут всё просто,https://api.telegram.org/;telegram.webhook-path— адрес вебхука, который должен быть зарегистрирован в Telegram (об этом ниже, в разделе «Запускаем локально»);telegram.bot-nameиtelegram.bot-token— имя и токен бота, полученные от BotFather.
Полный
application.yaml можно посмотреть ниже, в разделе «Запускаем локально».Далее, чтобы эти настройки можно было использовать в коде, создадим небольшой
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.bot-name}") String botName; @Value("${telegram.bot-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.getBotName()); 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, а в статье рассмотрим только две ключевых детали — создание клавиатур и работу с файлами.
Постоянная клавиатура
Чтобы сразу было понятно, вот это она:

Постоянная клавиатура — это основное меню бота. Она создаётся в отдельном классе путём создания отдельных кнопок, затем их рядов и, в завершении, присвоения клавиатуре нужных признаков:
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 надо не забыть обработать текстовые сообщения, отличные от названий кнопок — наш бот в этом случае призывает пользователя воспользоваться клавиатурой.Инлайн-клавиатуры
Это вот такое:

В нашем боте инлайн-клавиатуры используются для выбора пользователем словаря и отображаются в ответ на команды основного меню «Создать файл с заданиями» и «Скачать словарь». Состав кнопок отличается всего на одну позицию — при обработке команды «Скачать словарь» добавляется кнопка «Шаблон». Кроме того, если пользователь загрузил в базу данных свой словарь, при обработке обеих команд в клавиатуру добавляется кнопка «Ваш словарь».
Также, как и постоянная клавиатура, инлайн-версия формируется в отдельном классе:
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.bot-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: 0spring.redis.host: <хост БД Redis, копируем с Heroku>spring.redis.port: <порт БД Redis, копируем с Heroku>spring.redis.password: <пароль БД Redis, копируем с Heroku>spring.redis.ssl: trueПолный
application.yaml можно посмотреть ниже, в разделе «Запускаем локально».Запускаем локально
Нам осталось сделать вебхук и зарегистрировать его в Telegram.
Для получения внешнего адреса при локальном запуске используем утилиту ngrok по вот этой инструкции. Не забываем добавить в
application.yaml настройки telegram.webhook-path (выдаст ngrok) и server.port (передаётся ngrok в качестве параметра)Вот так выглядит итоговый (для локального запуска с использованием БД Redis на Heroku)
application.yaml
telegram: api-url: "https://api.telegram.org/" webhook-path: "https://<...>.ngrok.io" bot-name: "<что-то вроде @NameOfYourBot>" bot-token: "<токен бота - цифры, двоеточие и белиберда:)>" spring: redis: database: 0 host: "<...>.compute-1.amazonaws.com" port: "<как правило, 5 цифр, если деплоим на Heroku>" password: "<очень длинный пароль:)>" ssl: true server: port: "<порт, который передавался в ngrok в качестве параметра, я использовал 5000>"
Регистрируем вебхук в 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=<версия Java, в нашем случае 11>Ещё один специфический для Heroku файл
Procfile в данном случае можно не добавлять, он будет сгенерирован автоматически на основе pom.xml.Сначала нужно обязательно удалить/закомментировать в
application.yaml настройки подключения к БД — она подцепится автоматически, поскольку подключена к проекту на Heroku. Если оставить эти настройки, ничего не заведётся, они нужны только для внешнего подключения к этой БД.Также нужно изменить настройку
webhook-path — это адрес веб-приложения на Heroku. Чтобы его получить, надо нажать на кнопку «Open app» в интерфейсе Heroku, убедиться, что приложение запустилось (должна отображаться надпись «Whitelabel Error Page» — значит, успех) и скопировать содержимое адресной строки.Вот так должен выглядеть готовый к деплою на Heroku
application.yaml
telegram: api-url: "https://api.telegram.org/" webhook-path: "адрес приложения на Heroku- что-то вроде https://<имя приложения>.herokuapp.com/" bot-name: "<что-то вроде @NameOfYourBot>" bot-token: "<токен бота - цифры, двоеточие и белиберда:)>"
Далее:
- в консоли
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, но нужно понимать, что в этом случае бот будет съедать львиную долю бесплатного тарифа. Я жадный, я так делать не буду.
Вместо заключения
Оказывается, и мультик про ПишиЧитая тоже мало кто смотрел.
