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


Это вторая статья в моей серии «для самых маленьких» — предыдущая была посвящена «классическому» 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.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=<версия Java, в нашем случае 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, но нужно понимать, что в этом случае бот будет съедать львиную долю бесплатного тарифа. Я жадный, я так делать не буду.

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


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

Комментарии (5)


  1. aamonster
    18.01.2022 18:34

    Безотносительно к технической части: а вы уверены, что выбранный вами метод заучивания слов (когда ребёнок, помимо правильного варианта, видит ещё три неправильных) хорош?
    Из того, что я знаю про т.н. "врождённую грамотность", это выглядит не лучшей идеей. У тех, кто ещё плохо знает написание слов, глаз "зацепит" и мозг "зафиксирует" и неправильное написание тоже. А уж тем, кто далеко продвинулся - будет просто некомфортно видеть слова с ошибками (почему, по вашему, граммар-наци так бесятся?).


    1. taksebe Автор
      18.01.2022 21:58

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

      Ну и в голове ведь при выборе варианта написания происходит то же самое.


      1. vmkazakoff
        18.01.2022 22:31
        +1

        Не совсем то же самое. Вообще для мозга процесс выбора из вариантов и процесс восстановления в памяти - два совершенно не схожих по механизму действия. Очень условно - в первом случае сигнал должен пробежать по нейронам всех трёх вариантов и где "сопротивление меньше", тот и выбираем. А во втором случае сигнал не знает куда ему пройти точно и вынужден "прокладывать путь". Вот этот процесс (называется реверберация) и нужен для переноса знаний из кратковременной памяти в долговременную.

        Банальный пример: когда в детстве учили стихи и нам подсказывали начало строк. Скорее всего без подсказок рассказать ничего так и не выходило.

        Более холиварно - вот почему ЕГЭ нифига не показывает. Навык "сдавать тестирования" это отдельный навык.

        Так что оптимальная стратегия для запоминания - спросить "помнишь/нет" и потом показать вариант, спросив ещё раз "угадал?". Но это если нет задачи ещё и проверить знания и мы можем опираться на честность.


        1. taksebe Автор
          19.01.2022 09:41

          Туше. Не обладаю знаниями, поэтому предпочту остаться на позиции "всё сложно, способы проверки знаний всякие нужны". Уж точно я не настаиваю, что способ, представленный в моём боте, единственно нужный.


          1. aamonster
            20.01.2022 23:22
            +1

            Из методов, про которые известно, что они неплохи – интервальное повторение (см., к примеру, ankiweb.net). Imho оно вашему боту должно идеально подойти. Кстати, заодно можете там посмотреть на предмет интересных колод :-)

            Imho имеет смысл разделить собственно обучение, когда не показываются неправильные ответы (как сказал @taksebe, тут можно полагаться на честность) и "экзамен", где подойдёт ваш метод. Благо, вам даже не надо модифицировать данные для бота.