![](https://habrastorage.org/webt/v9/mj/xn/v9mjxnpeteb00fprvqucr_lmxng.png)
В предыдущих сериях
Это вторая статья в моей серии «для самых маленьких» — предыдущая была посвящена «классическому» Telegram-боту, наследуемому от
TelegramLongPollingBot
.Для кого написано
Если вы ни разу не писали Telegram-ботов на Java с использованием вебхуков и только начинаете разбираться — эта статья для вас. В ней подробно и с пояснениями описано создание реального бота, автоматизирующего одну конкретную функцию. Можно использовать статью как мануал для создания скелета своего бота, а потом подключить его к своей бизнес-логике.
Я пытаюсь писать как для себя, а не сразу для умных — надеюсь, кому-нибудь это поможет быстрее въехать в тему.
Предыстория
Учить словарные слова — занятие довольно скучное, а если делать это в лоб, ещё и малоэффективное, поэтому я решил разработать для дочери задания такого вида:
![](https://habrastorage.org/webt/on/sa/jg/onsajg8bdnbkymxj0nflhpv92q4.png)
Ребёнок сначала выбирает правильный вариант, а затем закрепляет его написанием. По ощущениям, запоминание идёт неплохо. Дело оставалось за малым — написать бота и составить словари неправильных написаний.
Что в статье есть, чего нет
В статье есть про:
- создание бекенда Telegram-бота на вебхуках на Java 11 с использованием Spring;
- использование базы данных Redis;
- отправку пользователю текстовых сообщений и файлов;
- подключение постоянных и временных клавиатур;
- локальный запуск бота для дебага;
- деплой и запуск бота на Heroku, включая подключение к проекту Heroku Redis.
В статье нет про:
- использование функций ботов, не перечисленных выше;
- работу с Apache POI — создание Word и Excel файлов;
- общение с BotFather (создание бота, получение его токена и формирование списка команд подробно и понятно описано во многих источниках, вот первый попавшийся мануал;
- создание и загрузку в БД словарей по умолчанию.
Из примеров кода в статье эти функции исключены, чтобы упростить восприятие. Исходный код лежит на GitHub. Если у вас вдруг есть вопросы, пишите в личку, с удовольствием проконсультирую.
Бизнес-функции бота
Бот позволяет:
- создавать Word-файлы с заданиями из имеющихся словарей (стандартных или пользовательского);
- скачивать имеющиеся словари в Excel-файлы (для корректировки и последующей загрузки в качестве пользовательского словаря);
- загружать пользовательский словарь;
- выводить справку.
Можно потыкать — WriteReadRightBot. Выглядит так:
![](https://habrastorage.org/webt/gq/cf/kz/gqcfkz_7ftblicgnqlsiarvdjjs.png)
Порядок разработки
- разобраться с зависимостями;
- сконфигурировать БД;
- создать бота;
- реализовать обработку сообщений, включая работу с клавиатурами;
- раскурить приём и отправку файлов;
- завести локально;
- задеплоить на 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, а в статье рассмотрим только две ключевых детали — создание клавиатур и работу с файлами.
Постоянная клавиатура
Чтобы сразу было понятно, вот это она:
![image](https://habrastorage.org/webt/je/w7/as/jew7asn3nmukw5wmipslur7sn0w.png)
Постоянная клавиатура — это основное меню бота. Она создаётся в отдельном классе путём создания отдельных кнопок, затем их рядов и, в завершении, присвоения клавиатуре нужных признаков:
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](https://habrastorage.org/webt/na/gj/t-/nagjt-eqitopfz1z8cpvek3v8us.png)
В нашем боте инлайн-клавиатуры используются для выбора пользователем словаря и отображаются в ответ на команды основного меню «Создать файл с заданиями» и «Скачать словарь». Состав кнопок отличается всего на одну позицию — при обработке команды «Скачать словарь» добавляется кнопка «Шаблон». Кроме того, если пользователь загрузил в базу данных свой словарь, при обработке обеих команд в клавиатуру добавляется кнопка «Ваш словарь».
Также, как и постоянная клавиатура, инлайн-версия формируется в отдельном классе:
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: 0
spring.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, но нужно понимать, что в этом случае бот будет съедать львиную долю бесплатного тарифа. Я жадный, я так делать не буду.
Вместо заключения
Оказывается, и мультик про ПишиЧитая тоже мало кто смотрел.