Как стать автором
Обновить

Telegram-бот на Java для самых маленьких — от старта до бесплатного размещения на heroku

Время на прочтение15 мин
Количество просмотров160K


В следующих сериях


Это первая статья в моей серии «для самых маленьких» — следующая посвящена Telegram-боту на вебхуках на Spring с блекджеком и Redis и клавиатурами. Будут ещё:)

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


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

Предыстория


Когда моя дочь начала изучать арифметику, я между делом накидал алгоритм генерации простых примеров на сложение и вычитание вида «5 + 7 =», чтобы не придумывать и не гуглить для неё задания.

И тут на глаза попалась новость, что Telegram выпустил новую версию Bot API 5.0. Ботов я раньше не писал, и потому решил попробовать поднять бота как интерфейс для своей поделки. Все примеры, которые мне удалось найти, показались либо совсем простыми (нужные мне функции не были представлены), либо очень сложными для новичка. Также мне не хватало объяснений, почему выбран тот или иной путь. В общем, написано было сразу для умных, а не для меня. Потому я решил описать свой опыт создания простого бота — надеюсь, кому-нибудь это поможет быстрее въехать в тему.

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


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

  • создание бекенда не-инлайн бота на Java 11 с использованием Telegram Bot Api 5.0;
  • обработка команд вида /dosomething;
  • обработка текстовых сообщений, не являющихся командами (т.е. не начинающихся с "/");
  • отправку пользователю текстовых сообщений и файлов;
  • деплой и запуск бота на heroku.

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

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

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

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


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

  • выдавать пользователю справочную текстовую информацию в ответ на команды /start, /help и /settings;
  • обрабатывать и запоминать пользовательские настройки, направленные текстовым сообщением заданного формата. Настроек три — минимальное + максимальное число, используемые в заданиях, и количество страниц выгружаемого файла;
  • оповещать пользователя о несоблюдении им формата сообщения;
  • формировать Word-файл с заданиями на сложение, вычитание или вперемешку в ответ на команды /plus, /minus и /plusminus с использованием дефолтных или установленных пользователем настроек.

Можно потыкать — MentalCalculationBot (должен работать). Выглядит так:



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


  • разобраться с зависимостями;
  • создать класс бота и реализовать обработку текстовых сообщений пользователя, не являющихся командами;
  • создать классы команд;
  • прописать запуск приложения;
  • задеплоить на heroku.

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

Зависимости


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

Вот что вышло в
pom.xml
    <groupId>***</groupId>
    <artifactId>***</artifactId>
    <version>1.0-SNAPSHOT</version>
    <name>***</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>
        <org.projectlombok.version>1.18.16</org.projectlombok.version>
        <apache.poi.version>4.1.2</apache.poi.version>
        <telegram.version>5.0.1</telegram.version>
    </properties>

    <dependencies>
        <!-- Telegram API -->
        <dependency>
            <groupId>org.telegram</groupId>
            <artifactId>telegrambots</artifactId>
            <version>${telegram.version}</version>
        </dependency>
        <dependency>
            <groupId>org.telegram</groupId>
            <artifactId>telegrambotsextensions</artifactId>
            <version>${telegram.version}</version>
        </dependency>
        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${org.projectlombok.version}</version>
            <scope>compile</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>${project.artifactId}</finalName>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <release>${java.version}</release>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                            <version>${org.projectlombok.version}</version>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-dependency-plugin</artifactId>
                <version>3.1.2</version>
                <executions>
                    <execution>
                        <id>copy-dependencies</id>
                        <phase>package</phase>
                        <goals>
                            <goal>copy-dependencies</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.0.0-M5</version>
            </plugin>
        </plugins>
    </build>


Класс бота и обработка текстовых сообщений


Мой класс Bot унаследован от TelegramLongPollingCommandBot, который, в свою очередь, наследуется от более распространённого в примерах TelegramLongPollingBot. Он хорош тем, что в нём уже реализованы приём и обработка команд — то есть сообщений, начинающихся с "/". Можно создавать отдельные классы команд (подробнее о них ниже), инициализировать их в конструкторе бота и уже в них писать логику их обработки.

В классе Bot таким образом остаётся только логика обработки текстовых сообщений, не являющихся командами. В моём случае это пользовательские настройки или мусорные сообщения, не соответствующие формату. Для лаконичности логику их обработки тоже стоит вынести в отдельный вспомогательный класс, вызывая его метод из переопределенного метода processNonCommandUpdate(Update update) класса Bot.

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

Получился вот такой
Bot.java
import lombok.Getter;
import org.telegram.telegrambots.extensions.bots.commandbot.TelegramLongPollingCommandBot;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.api.objects.Message;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.api.objects.User;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
import ru.taksebe.telegram.mentalCalculation.telegram.commands.operations.MinusCommand;
import ru.taksebe.telegram.mentalCalculation.telegram.commands.operations.PlusCommand;
import ru.taksebe.telegram.mentalCalculation.telegram.commands.operations.PlusMinusCommand;
import ru.taksebe.telegram.mentalCalculation.telegram.commands.service.HelpCommand;
import ru.taksebe.telegram.mentalCalculation.telegram.commands.service.SettingsCommand;
import ru.taksebe.telegram.mentalCalculation.telegram.commands.service.StartCommand;
import ru.taksebe.telegram.mentalCalculation.telegram.nonCommand.NonCommand;
import ru.taksebe.telegram.mentalCalculation.telegram.nonCommand.Settings;

import java.util.HashMap;
import java.util.Map;

public final class Bot extends TelegramLongPollingCommandBot {
    private final String BOT_NAME;
    private final String BOT_TOKEN;

    //Настройки по умолчанию
    @Getter
    private static final Settings defaultSettings = new Settings(1, 15, 1);

    //Класс для обработки сообщений, не являющихся командой
    private final NonCommand nonCommand;

    /**
     * Настройки файла для разных пользователей. Ключ - уникальный id чата
     */
    @Getter
    private static Map<Long, Settings> userSettings;

    public Bot(String botName, String botToken) {
        super();
        this.BOT_NAME = botName;
        this.BOT_TOKEN = botToken;
        //создаём вспомогательный класс для работы с сообщениями, не являющимися командами
        this.nonCommand = new NonCommand();
        //регистрируем команды
        register(new StartCommand("start", "Старт"));
        register(new PlusCommand("plus", "Сложение"));
        register(new MinusCommand("minus", "Вычитание"));
        register(new PlusMinusCommand("plusminus", "Сложение и вычитание"));
        register(new HelpCommand("help","Помощь"));
        register(new SettingsCommand("settings", "Мои настройки"));
        userSettings = new HashMap<>();
    }

    @Override
    public String getBotToken() {
        return BOT_TOKEN;
    }

    @Override
    public String getBotUsername() {
        return BOT_NAME;
    }

    /**
     * Ответ на запрос, не являющийся командой
     */
    @Override
    public void processNonCommandUpdate(Update update) {
        Message msg = update.getMessage();
        Long chatId = msg.getChatId();
        String userName = getUserName(msg);

        String answer = nonCommand.nonCommandExecute(chatId, userName, msg.getText());
        setAnswer(chatId, userName, answer);
    }

    /**
     * Получение настроек по id чата. Если ранее для этого чата в ходе сеанса работы бота настройки не были установлены, используются настройки по умолчанию
     */
    public static Settings getUserSettings(Long chatId) {
        Map<Long, Settings> userSettings = Bot.getUserSettings();
        Settings settings = userSettings.get(chatId);
        if (settings == null) {
            return defaultSettings;
        }
        return settings;
    }

    /**
     * Формирование имени пользователя
     * @param msg сообщение
     */
    private String getUserName(Message msg) {
        User user = msg.getFrom();
        String userName = user.getUserName();
        return (userName != null) ? userName : String.format("%s %s", user.getLastName(), user.getFirstName());
    }

    /**
     * Отправка ответа
     * @param chatId id чата
     * @param userName имя пользователя
     * @param text текст ответа
     */
    private void setAnswer(Long chatId, String userName, String text) {
        SendMessage answer = new SendMessage();
        answer.setText(text);
        answer.setChatId(chatId.toString());
        try {
            execute(answer);
        } catch (TelegramApiException e) {
            //логируем сбой Telegram Bot API, используя userName
        }
    }
}


Класс обработки текстовых сообщений
NonCommand.java
import ru.taksebe.telegram.mentalCalculation.exceptions.IllegalSettingsException;
import ru.taksebe.telegram.mentalCalculation.telegram.Bot;

/**
 * Обработка сообщения, не являющегося командой (т.е. обычного текста не начинающегося с "/")
 */
public class NonCommand {

    public String nonCommandExecute(Long chatId, String userName, String text) {
        Settings settings;
        String answer;
        try {
            //создаём настройки из сообщения пользователя
            settings = createSettings(text);
            //добавляем настройки в мапу, чтобы потом их использовать для этого пользователя при генерации файла
            saveUserSettings(chatId, settings);
            answer = "Настройки обновлены. Вы всегда можете их посмотреть с помощью /settings";
            //логируем событие, используя userName
        } catch (IllegalSettingsException e) {
            answer = e.getMessage() +
                    "\n\n Настройки не были изменены. Вы всегда можете их посмотреть с помощью /settings";
            //логируем событие, используя userName
        } catch (Exception e) {
            answer = "Простите, я не понимаю Вас. Возможно, Вам поможет /help";
            //логируем событие, используя userName
        }
        return answer;
    }

    /**
     * Создание настроек из полученного пользователем сообщения
     * @param text текст сообщения
     * @throws IllegalArgumentException пробрасывается, если сообщение пользователя не соответствует формату
     */
    private Settings createSettings(String text) throws IllegalArgumentException {
        //отсекаем файлы, стикеры, гифки и прочий мусор
        if (text == null) {
            throw new IllegalArgumentException("Сообщение не является текстом");
        }
        //создаём из сообщения пользователя 3 числа-настройки (min, max, listCount) либо пробрасываем исключение о несоответствии сообщения требуемому формату
        return new Settings(min, max, listCount);
    }

    /**
     * Добавление настроек пользователя в мапу, чтобы потом их использовать для этого пользователя при генерации файла
     * Если настройки совпадают с дефолтными, они не сохраняются, чтобы впустую не раздувать мапу
     * @param chatId id чата
     * @param settings настройки
     */
    private void saveUserSettings(Long chatId, Settings settings) {
        if (!settings.equals(Settings.getDefaultSettings())) {
            Bot.getUserSettings().put(chatId, settings);
        } else {
            Bot.getUserSettings().remove(chatId);
        }
    }
}


Классы команд


Все классы команд наследуются от BotCommand.

Команды в моём боте делятся на 2 группы:

  • Сервисные — возвращают справочную информацию;
  • Основные — формируют файл с заданиями.

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

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

Абстрактный суперкласс Сервисных команд
ServiceCommand.java
import org.telegram.telegrambots.extensions.bots.commandbot.commands.BotCommand;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.bots.AbsSender;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;

/**
 * Суперкласс для сервисных команд
 */
abstract class ServiceCommand extends BotCommand {

    ServiceCommand(String identifier, String description) {
        super(identifier, description);
    }

    /**
     * Отправка ответа пользователю
     */
    void sendAnswer(AbsSender absSender, Long chatId, String commandName, String userName, String text) {
        SendMessage message = new SendMessage();
        //включаем поддержку режима разметки, чтобы управлять отображением текста и добавлять эмодзи
        message.enableMarkdown(true);
        message.setChatId(chatId.toString());
        message.setText(text);
        try {
            absSender.execute(message);
        } catch (TelegramApiException e) {
            //логируем сбой Telegram Bot API, используя commandName и userName
        }
    }
}


Класс Сервисной команды на примере
StartCommand.java
import org.telegram.telegrambots.meta.api.objects.Chat;
import org.telegram.telegrambots.meta.api.objects.User;
import org.telegram.telegrambots.meta.bots.AbsSender;

/**
 * Команда "Старт"
 */
public class StartCommand extends ServiceCommand {

    public StartCommand(String identifier, String description) {
        super(identifier, description);
    }

    @Override
    public void execute(AbsSender absSender, User user, Chat chat, String[] strings) {
        //формируем имя пользователя - поскольку userName может быть не заполнено, для этого случая используем имя и фамилию пользователя
        String userName = (user.getUserName() != null) ? user.getUserName() :
                String.format("%s %s", user.getLastName(), user.getFirstName());
        //обращаемся к методу суперкласса для отправки пользователю ответа
        sendAnswer(absSender, chat.getId(), this.getCommandIdentifier(), userName,
                "Давайте начнём! Если Вам нужна помощь, нажмите /help");
    }
}


В суперклассе Основных команд, помимо аналогичного метода отправки ответов, содержится формирование Word-документа.
OperationCommand.java
import org.telegram.telegrambots.extensions.bots.commandbot.commands.BotCommand;
import org.telegram.telegrambots.meta.api.methods.send.SendDocument;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.api.objects.InputFile;
import org.telegram.telegrambots.meta.bots.AbsSender;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
import ru.taksebe.telegram.mentalCalculation.calculation.Calculator;
import ru.taksebe.telegram.mentalCalculation.calculation.PlusMinusService;
import ru.taksebe.telegram.mentalCalculation.enums.OperationEnum;
import ru.taksebe.telegram.mentalCalculation.fileProcessor.WordFileProcessorImpl;
import ru.taksebe.telegram.mentalCalculation.telegram.Settings;

import java.io.FileInputStream;
import java.io.IOException;
import java.util.List;

/**
 * Суперкласс для команд создания заданий с различными операциями
 */
abstract class OperationCommand extends BotCommand {
    private PlusMinusService service;

    OperationCommand(String identifier, String description) {
        super(identifier, description);
        this.service = new PlusMinusService(new WordFileProcessorImpl(), new Calculator());
    }

    /**
     * Отправка ответа пользователю
     */
    void sendAnswer(AbsSender absSender, Long chatId, List<OperationEnum> operations, String description, String commandName, String userName) {
        try {
            absSender.execute(createDocument(chatId, operations, description));
        } catch (IOException | IllegalArgumentException e) {
            sendError(absSender, chatId, commandName, userName);
            e.printStackTrace();
        } catch (TelegramApiException e) {
            //логируем сбой Telegram Bot API, используя commandName и userName
        }
    }

    /**
     * Создание документа для отправки пользователю
     * @param chatId id чата
     * @param operations список типов операций (сложение и/или вычитание)
     * @param fileName имя, которое нужно присвоить файлу
     */
    private SendDocument createDocument(Long chatId, List<OperationEnum> operations, String fileName) throws IOException {
        FileInputStream stream = service.getPlusMinusFile(operations, Bot.getUserSettings(chatId));
        SendDocument document = new SendDocument();
        document.setChatId(chatId.toString());
        document.setDocument(new InputFile(stream, String.format("%s.docx", fileName)));
        return document;
    }

    /**
     * Отправка пользователю сообщения об ошибке
     */
    private void sendError(AbsSender absSender, Long chatId, String commandName, String userName) {
        try {
            absSender.execute(new SendMessage(chatId.toString(), "Похоже, я сломался. Попробуйте позже"));
        } catch (TelegramApiException e) {
            //логируем сбой Telegram Bot API, используя commandName и userName
        }
    }
}


Класс Основной команды на примере
PlusMinusCommand.java
import org.telegram.telegrambots.meta.api.objects.Chat;
import org.telegram.telegrambots.meta.api.objects.User;
import org.telegram.telegrambots.meta.bots.AbsSender;
import ru.taksebe.telegram.mentalCalculation.enums.OperationEnum;

/**
 * Команда получение файла с заданиями на сложение и вычитание
 */
public class PlusMinusCommand extends OperationCommand {

    public PlusMinusCommand(String identifier, String description) {
        super(identifier, description);
    }

    @Override
    public void execute(AbsSender absSender, User user, Chat chat, String[] strings) {
        //формируем имя пользователя - поскольку userName может быть не заполнено, для этого случая используем имя и фамилию пользователя
        String userName = (user.getUserName() != null) ? user.getUserName() :
                String.format("%s %s", user.getLastName(), user.getFirstName());
        //обращаемся к методу суперкласса для формирования файла на сложение и вычитание (за это отвечает метод getPlusMinus() перечисления OperationEnum) и отправки его пользователю
        sendAnswer(absSender, chat.getId(), OperationEnum.getPlusMinus(), this.getDescription(), this.getCommandIdentifier(), userName);
    }
}


Приложение


В методе main инициализируется TelegramBotsApi, в котором и регистрируется Bot.

TelegramBotsApi в качестве параметра принимает Class<? extends BotSession>. Если нет никаких заморочек с прокси, можно использовать DefaultBotSession.class.

Чтобы получать имя и токен бота как переменные окружения, необходимо использовать System.getenv().

Получаем вот такой
MentalCalculationApplication.java
import org.telegram.telegrambots.meta.TelegramBotsApi;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
import org.telegram.telegrambots.updatesreceivers.DefaultBotSession;
import ru.taksebe.telegram.mentalCalculation.telegram.Bot;

import java.util.Map;

public class MentalCalculationApplication {
    private static final Map<String, String> getenv = System.getenv();

    public static void main(String[] args) {
        try {
            TelegramBotsApi botsApi = new TelegramBotsApi(DefaultBotSession.class);
            botsApi.registerBot(new Bot(getenv.get("BOT_NAME"), getenv.get("BOT_TOKEN")));
        } catch (TelegramApiException e) {
            e.printStackTrace();
        }
    }
}


Деплой на heroku


Для начала нужно создать в корне проекта файл Procfile и написать в него одну строку:
worker: java -Xmx300m -Xss512k -XX:CICompilerCount=2 -Dfile.encoding=UTF-8 -cp ./target/classes:./target/dependency/* <путь до приложения, в моём случае ru.taksebe.telegram.mentalCalculation.MentalCalculationApplication>
, где worker — это тип процесса.

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

Далее порядок такой:

  1. Регистрируемся на heroku и идём в консоль;
  2. mvn clean install;
  3. heroku login — после выполнения потребуется нажать любую клавишу и залогиниться в открывшемся окне браузера;
  4. heroku create <имя приложения> — создаём приложение на heroku;
  5. git push heroku master — пушим в репозиторий heroku;
  6. heroku config:set BOT_NAME=<имя бота> — добавляем имя бота в переменные окружения;
  7. heroku config:set BOT_TOKEN=<токен бота> — добавляем токен бота в переменные окружения;
  8. heroku config:get BOT_NAME (аналогично BOT_TOKEN) — убеждаемся, что переменные окружения установлены верно;
  9. heroku ps:scale worker=1 — устанавливаем количество контейнеров (dynos) для типа процесса worker (ранее мы выбрали этот тип в Procfile), при этом происходит рестарт приложения;
  10. В интерфейсе управления приложением в личном кабинете на heroku переходим к логам (прячутся под кнопкой «More» в правом верхнем углу) и убеждаемся, что приложение запущено;
  11. Тестируем бота через Telegram.

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

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


Heroku периодически перезапускает приложение, и тогда введённые пользователем настройки удаляются. Можно добавить к проекту БД (например, Heroku Redis, как в другом моём боте), чтобы этого избежать.

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

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


Как выяснилось, не только лишь все видели чудесный советский мультик про козлёнка, который учился считать до 10.
Теги:
Хабы:
Всего голосов 11: ↑11 и ↓0+11
Комментарии17

Публикации

Истории

Работа

Java разработчик
347 вакансий

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань