Функционал бота, который я буду разрабатывать в этой статье, очень простой - с помощью него можно будет получить официальные курсы валют с сайта ЦБ РФ (в нашем случае это доллары и евро). После прочтения данной статьи вы сможете написать уже собственный Telegram-бот с гораздо более сложным функционалом.
Так же данный материал есть в видео-формате на моём YouTube-канале:
Основа Telegram-бота
Для начала понадобится BotFather, чтобы зарегистрировать новый бот в Telegram: https://t.me/BotFather
Выполняем команду /newbot и вводим имя будущего бота, после чего BotFather сгенерирует уникальный токен, который далее потребуется для организации взаимодействия приложения с созданным Telegram-ботом:
Далее открываем Spring Initializr и генерируем новое Spring Boot приложение:
На начальном этапе я не стал включать в проект какие-либо зависимости - всё необходимое буду добавлять постепенно, чтобы было более наглядно.
Скачиваем и открываем архив со сгенерированными исходниками, после чего добавляем первую зависимость - библиотеку TelegramBots в файл build.gradle:
implementation group: 'org.telegram', name: 'telegrambots', version: '6.5.0'
Эта библиотека серьёзно упрощает взаимодействие с Telegram API и предоставляет удобные интерфейсы, которые я и буду использовать для написания бота.
TelegramBots позволяет взаимодействовать с Telegram API в двух режимах:
- Long Polling (Длинные опросы) - необходимо наследоваться от класса org.telegram.telegrambots.bots.TelegramLongPollingBot
- Webhook (Вебхук) - необходимо наследоваться от класса org.telegram.telegrambots.bots.TelegramWebhookBot
Я предпочитаю использовать первый вариант, поэтому в своём примере буду наследоваться от абстрактного класса TelegramLongPollingBot. Но прежде чем идти дальше, я хочу остановиться на исходниках библиотеки, а конкретно на конструкторах абстрактного класса TelegramLongPollingBot. На момент написания статьи реализация TelegramLongPollingBot выглядит следующим образом (версия библиотеки 6.5.0):
public abstract class TelegramLongPollingBot extends DefaultAbsSender implements LongPollingBot {
/**
* If this is used getBotToken has to be overridden in order to return the bot token!
* @deprecated Overwriting the getBotToken() method is deprecated. Use the constructor instead
*/
@Deprecated()
public TelegramLongPollingBot() {
this(new DefaultBotOptions());
}
/**
* If this is used getBotToken has to be overridden in order to return the bot token!
* @deprecated Overwriting the getBotToken() method is deprecated. Use the constructor instead
*/
@Deprecated()
public TelegramLongPollingBot(DefaultBotOptions options) {
super(options);
}
public TelegramLongPollingBot(String botToken) {
this(new DefaultBotOptions(), botToken);
}
public TelegramLongPollingBot(DefaultBotOptions options, String botToken) {
super(options, botToken);
}
. . .
}
Обратите особое внимание что первые два конструктора помечены как @Deprecated(), а это значит что их уже не рекомендуется использовать. Это важный момент, так как в более ранних статьях по написанию бота для Telegram очень часто используется конструктор без параметров. Так как теперь у данного конструктора имеется аннотация @Deprecated(), то я его не буду использовать и вместо него возьму конструктор, у которого в сигнатуре всего один параметр - botToken:
public TelegramLongPollingBot(String botToken) {
this(new DefaultBotOptions(), botToken);
}
После того, как разобрались с конструкторами, можно начинать разработку. Для начала я создам в проекте 2 новых пакета - bot и configuration. Вот так на начальном этапе у меня выглядит структура папки src:
В пакет bot я добавил новый класс ExchangeRatesBot, который как раз и наследуется от абстрактного класса TelegramLongPollingBot:
package ru.akutepov.exchangeratesbot.bot;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.bots.TelegramLongPollingBot;
import org.telegram.telegrambots.meta.api.objects.Update;
@Component
public class ExchangeRatesBot extends TelegramLongPollingBot {
public ExchangeRatesBot(@Value("${bot.token}") String botToken) {
super(botToken);
}
@Override
public void onUpdateReceived(Update update) {
}
@Override
public String getBotUsername() {
return "kutepov_exchange_rates_bot";
}
}
Тут пока всё предельно просто, разберём основные моменты:
- в конструктор передаётся переменная botToken, которая будет заполняться из параметра bot.token (чуть позже я пропишу в resources/application.properties). Конечно же сам класс ExchangeRatesBot помечен аннотацией @Component, для того чтобы был создан соответствующий бин.
- метод onUpdateReceived(Update update) вызывается всякий раз, когда пользователь отправляет в бот сообщение. В этом методе я и буду обрабатывать поступающие от пользователя команды.
- метод getBotUsername() должен возвращать название бота, которое тоже можно поместить в проперти. Но лично я в этом смысла не вижу, так как название бота не является конфиденциальной информацией в отличие от токена и его вполне можно и захардкодить. Если пихать в проперти-файл всё подряд, то можно получить антипаттерн Мягкое кодирование (Soft code), о нём я рассказывал в одном из своих видео: https://youtu.be/os7VVNsX5pI
И следующим логичным шагом будет добавление параметра bot.token в файл resources/application.properties:
Завершающим шагом подготовки "скелета" Telegram-бота будет добавление класса ExchangeRatesBotConfiguration в пакет configuration:
package ru.akutepov.exchangeratesbot.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.telegram.telegrambots.meta.TelegramBotsApi;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
import org.telegram.telegrambots.updatesreceivers.DefaultBotSession;
import ru.akutepov.exchangeratesbot.bot.ExchangeRatesBot;
@Configuration
public class ExchangeRatesBotConfiguration {
@Bean
public TelegramBotsApi telegramBotsApi(ExchangeRatesBot exchangeRatesBot) throws TelegramApiException {
var api = new TelegramBotsApi(DefaultBotSession.class);
api.registerBot(exchangeRatesBot);
return api;
}
}
Тут я создаю новый бин TelegramBotsApi и регистрирую в нём класс бота - ExchangeRatesBot. После этого можно собрать приложение и запустить, чтобы убедиться что ничего не падает.
Получение данных для бота
Прежде чем обрабатывать команды пользователя, необходимо разработать механизм получения данных, которые планируется отправлять пользователю. В моём случае это курсы доллара и евро, которые можно взять с официального сайта ЦБ РФ: http://www.cbr.ru/scripts/XML_daily.asp
Данная ссылка возвращает XML, который содержит информацию о курсах валют, установленных на текущий день. Telegram-бот должен будет уметь запрашивать данный XML с сервиса ЦБ РФ и парсить его. Для реализации такой возможности я решил использовать простые библиотеки okhttp и XPath.
И если XPath доступен из коробки, то зависимость для okhttp придётся добавить в файл build.gradle:
implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.11.0'
После этого можно приступить к разработке клиента, который будет ходить в ЦБ РФ за данными.
Сперва нужно сконфигурировать бин OkHttpClient в классе ExchangeRatesBotConfiguration, чтобы иметь возможности выполнять http-запросы с помощью библиотеки okhttp:
@Bean
public OkHttpClient okHttpClient() {
return new OkHttpClient();
}
Затем прописать в resources/application.properties новый параметр:
cbr.currency.rates.xml.url=http://www.cbr.ru/scripts/XML_daily.asp
Кроме этого, для удобства я решил создать checked-исключение, поэтому добавил в проект пакет exception и класс ServiceException в него:
package ru.akutepov.exchangeratesbot.exception;
public class ServiceException extends Exception {
public ServiceException(String message) {
super(message);
}
public ServiceException(String message, Throwable cause) {
super(message, cause);
}
}
Небольшое отступление: ситуация с checked-исключенями на самом деле неоднозначная. С одной стороны они гарантируют что исключения будут обработаны, с другой стороны негативно влияют на читаемость кода, так как логика обработки checked-исключений расползается по всему приложению. Например Sonar Qube в дефолтных настройках будет ругаться на участки кода, которые выбрасывают checked-исключения (https://rules.sonarsource.com/java/RSPEC-1162). Моё мнение такое - в отдельных ситуациях checked-исключения бывают очень полезны и прекрасно встраиваются в бизнес-логику, однако если ими злоупотреблять, то действительно можно получить очень переусложнённый код.
И наконец я добавил новый пакет client и создал в нём класс CbrClient, в котором написал следующий код:
package ru.akutepov.exchangeratesbot.client;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import ru.akutepov.exchangeratesbot.exception.ServiceException;
import java.io.IOException;
import java.util.Optional;
@Component
public class CbrClient {
@Autowired
private OkHttpClient client;
@Value("${cbr.currency.rates.xml.url}")
private String cbrCurrencyRatesXmlUrl;
public Optional<String> getCurrencyRatesXML() throws ServiceException {
var request = new Request.Builder()
.url(cbrCurrencyRatesXmlUrl)
.build();
try (var response = client.newCall(request).execute()) {
var body = response.body();
return body == null ? Optional.empty() : Optional.of(body.string());
} catch (IOException e) {
throw new ServiceException("Ошибка получения курсов валют от ЦБ РФ", e);
}
}
}
Класс CbrClient будет далее использоваться как отдельный бин, поэтому не забываем про аннотацию @Component, чтобы Spring смог его найти. Для того чтобы выполнить запрос к сервису ЦБ РФ, требуется url, который я достаю из пропертей с помощью аннотации @Value и записываю в переменную cbrCurrencyRatesXmlUrl и бин OkHttpClient, который получаю с помощью аннотации @Autowired.
В методе getCurrencyRatesXML() я формирую запрос в виде объекта класса Request и далее его передаю в метод newCall(request) и вызываю метод execute() для выполнения http-запроса с помощью библиотеки okhttp. Метод execute() возвращает объект класса Response, который имплиментирует интерфейс AutoClosable, поэтому я использовал конструкцию try с параметрами.
У класса Response имеется метод body(), который возвращает тело сообщения. В данном случае сервис ЦБ РФ вернёт XML с курсами валют, вот его я и вытаскиваю из тела в виде строки, если тело не пустое:
<ValCurs Date="13.05.2023" name="Foreign Currency Market">
. . .
<Valute ID="R01235">
<NumCode>840</NumCode>
<CharCode>USD</CharCode>
<Nominal>1</Nominal>
<Name>Доллар США</Name>
<Value>77,2041</Value>
</Valute>
<Valute ID="R01239">
<NumCode>978</NumCode>
<CharCode>EUR</CharCode>
<Nominal>1</Nominal>
<Name>Евро</Name>
<Value>84,2500</Value>
</Valute>
. . .
</ValCurs>
Клиент для API ЦБ РФ готов и теперь нужно создать сервисный слой, в котором из XML будут вытаскиваться необходимые данные. В приложении я создал пакет service, в него положил интерфейс ExchangeRatesService и пакет impl, в котором хранится имплементация интерфейса - ExchangeRatesServiceImpl:
В интерфейсе ExchangeRatesService всего 2 метода:
package ru.akutepov.exchangeratesbot.service;
import ru.akutepov.exchangeratesbot.exception.ServiceException;
public interface ExchangeRatesService {
String getUSDExchangeRate() throws ServiceException;
String getEURExchangeRate() throws ServiceException;
}
Как вы можете догадаться, getUSDExchangeRate() будет возвращать курс доллара, а getEURExchangeRate() - курс евро. Поскольку курсы валют потребуются для формирования текстового сообщения, то я не заморачиваюсь и возвращаю их сразу в виде строки. А так работа с деньгами в приложениях это отдельная интересная тема :)
Реализация сервисного класса ExchangeRatesServiceImpl, который имплиментирует интерфейс ExchangeRatesService, выглядит так:
package ru.akutepov.exchangeratesbot.service.impl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.w3c.dom.Document;
import org.xml.sax.InputSource;
import ru.akutepov.exchangeratesbot.client.CbrClient;
import ru.akutepov.exchangeratesbot.exception.ServiceException;
import ru.akutepov.exchangeratesbot.service.ExchangeRatesService;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import java.io.StringReader;
@Service
public class ExchangeRatesServiceImpl implements ExchangeRatesService {
private static final String USD_XPATH = "/ValCurs//Valute[@ID='R01235']/Value";
private static final String EUR_XPATH = "/ValCurs//Valute[@ID='R01239']/Value";
@Autowired
private CbrClient client;
@Override
public String getUSDExchangeRate() throws ServiceException {
var xmlOptional = client.getCurrencyRatesXML();
String xml = xmlOptional.orElseThrow(
() -> new ServiceException("Не удалось получить XML")
);
return extractCurrencyValueFromXML(xml, USD_XPATH);
}
@Override
public String getEURExchangeRate() throws ServiceException {
var xmlOptional = client.getCurrencyRatesXML();
String xml = xmlOptional.orElseThrow(
() -> new ServiceException("Не удалось получить XML")
);
return extractCurrencyValueFromXML(xml, EUR_XPATH);
}
private static String extractCurrencyValueFromXML(String xml, String xpathExpression)
throws ServiceException {
var source = new InputSource(new StringReader(xml));
try {
var xpath = XPathFactory.newInstance().newXPath();
var document = (Document) xpath.evaluate("/", source, XPathConstants.NODE);
return xpath.evaluate(xpathExpression, document);
} catch (XPathExpressionException e) {
throw new ServiceException("Не удалось распарсить XML", e);
}
}
}
Вначале я вынес в константы выражения для XPath:
private static final String USD_XPATH = "/ValCurs//Valute[@ID='R01235']/Value";
private static final String EUR_XPATH = "/ValCurs//Valute[@ID='R01239']/Value";
По сути это правила, по которым выполняется поиск нужных данных в XML.
Далее через @Autowired я подтягиваю CbrClient с помощью которого я буду отправлять запросы к API ЦБ РФ.
Теперь перейдём в конец и рассмотрим самый интересный метод - extractCurrencyValueFromXML(String xml, String xpathExpression). Он получает на вход строку с XML и выражение для XPath, которое представляет из себя правило, по которому в XML будет осуществлён поиск нужного значения (в нашем случае это курс валюты).
Методы getUSDExchangeRate() и getEURExchangeRate() сначала получают через клиентский бин актуальный XML, вызывая метод getCurrencyRatesXML(), а затем через метод extractCurrencyValueFromXML вытаскивают курс нужной валюты, передавая в него соответствующее выражение для XPath.
Важно! При каждом вызове метода getUSDExchangeRate() или getEURExchangeRate() выполняется запрос к API ЦБ РФ и в ответ приходит один и тот же XML, данные в котором изменятся только на следующий день. В реальном проекте такой подход не приемлем и для таких случаев следует использовать кэширование. Это увеличит быстродействие приложения и снизит нагрузку на внешние сервисы.
Обработка команд
Так как возможность получения необходимых данных в приложении реализована, то можно приступить к обработке пользовательских команд. Для этого вернёмся в класс ExchangeRatesBot и допишем его. Кода будет много, но поэтапно всё разберём:
package ru.akutepov.exchangeratesbot.bot;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.bots.TelegramLongPollingBot;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
import ru.akutepov.exchangeratesbot.exception.ServiceException;
import ru.akutepov.exchangeratesbot.service.ExchangeRatesService;
import java.time.LocalDate;
@Component
public class ExchangeRatesBot extends TelegramLongPollingBot {
private static final Logger LOG = LoggerFactory.getLogger(ExchangeRatesBot.class);
private static final String START = "/start";
private static final String USD = "/usd";
private static final String EUR = "/eur";
private static final String HELP = "/help";
@Autowired
private ExchangeRatesService exchangeRatesService;
public ExchangeRatesBot(@Value("${bot.token}") String botToken) {
super(botToken);
}
@Override
public void onUpdateReceived(Update update) {
if (!update.hasMessage() || !update.getMessage().hasText()) {
return;
}
String message = update.getMessage().getText();
Long chatId = update.getMessage().getChatId();
switch (message) {
case START -> {
String userName = update.getMessage().getChat().getUserName();
startCommand(chatId, userName);
}
case USD -> usdCommand(chatId);
case EUR -> eurCommand(chatId);
case HELP -> helpCommand(chatId);
default -> unknownCommand(chatId);
}
}
@Override
public String getBotUsername() {
return "kutepov_exchange_rates_bot";
}
private void startCommand(Long chatId, String userName) {
var text = """
Добро пожаловать в бот, %s!
Здесь Вы сможете узнать официальные курсы валют на сегодня, установленные ЦБ РФ.
Для этого воспользуйтесь командами:
/usd - курс доллара
/eur - курс евро
Дополнительные команды:
/help - получение справки
""";
var formattedText = String.format(text, userName);
sendMessage(chatId, formattedText);
}
private void usdCommand(Long chatId) {
String formattedText;
try {
var usd = exchangeRatesService.getUSDExchangeRate();
var text = "Курс доллара на %s составляет %s рублей";
formattedText = String.format(text, LocalDate.now(), usd);
} catch (ServiceException e) {
LOG.error("Ошибка получения курса доллара", e);
formattedText = "Не удалось получить текущий курс доллара. Попробуйте позже.";
}
sendMessage(chatId, formattedText);
}
private void eurCommand(Long chatId) {
String formattedText;
try {
var usd = exchangeRatesService.getEURExchangeRate();
var text = "Курс евро на %s составляет %s рублей";
formattedText = String.format(text, LocalDate.now(), usd);
} catch (ServiceException e) {
LOG.error("Ошибка получения курса евро", e);
formattedText = "Не удалось получить текущий курс евро. Попробуйте позже.";
}
sendMessage(chatId, formattedText);
}
private void helpCommand(Long chatId) {
var text = """
Справочная информация по боту
Для получения текущих курсов валют воспользуйтесь командами:
/usd - курс доллара
/eur - курс евро
""";
sendMessage(chatId, text);
}
private void unknownCommand(Long chatId) {
var text = "Не удалось распознать команду!";
sendMessage(chatId, text);
}
private void sendMessage(Long chatId, String text) {
var chatIdStr = String.valueOf(chatId);
var sendMessage = new SendMessage(chatIdStr, text);
try {
execute(sendMessage);
} catch (TelegramApiException e) {
LOG.error("Ошибка отправки сообщения", e);
}
}
}
В самом начале я добавил логгер, вынес все команды в константы и подтянул ExchangeRatesService для того чтобы была возможность получать актуальные курсы валют:
private static final Logger LOG = LoggerFactory.getLogger(ExchangeRatesBot.class);
private static final String START = "/start";
private static final String USD = "/usd";
private static final String EUR = "/eur";
private static final String HELP = "/help";
@Autowired
private ExchangeRatesService exchangeRatesService;
Всего будет 4 команды, которые можно будет отправить в бот:
- /start - начало работы с ботом, первичная инструкция пользователю
- /usd - получение курса доллара
- /eur - получение курса евро
- /help - справка
Далее я в самом конце добавил метод sendMessage(Long chatId, String text), который будет отправлять пользователю сообщение:
private void sendMessage(Long chatId, String text) {
var chatIdStr = String.valueOf(chatId);
var sendMessage = new SendMessage(chatIdStr, text);
try {
execute(sendMessage);
} catch (TelegramApiException e) {
LOG.error("Ошибка отправки сообщения", e);
}
}
В метод передаются 2 параметра:
- chatId - идентификатор чата с пользователем, в который необходимо отправить сообщение
- text - текст сообщения
Теперь добавляем методы-обработчики для каждой команды. Если разрабатывать серьёзный Telegram-бот, то скорее всего придётся создавать отдельные классы-обработчики для каждой команды, чтобы соответствовать Single-Responsibility Principle (SRP) из SOLID. Но так как проект учебный и обработчики у нас не сложные, то пойдём по более простому пути.
Метод-обработчик команды /start:
private void startCommand(Long chatId, String userName) {
var text = """
Добро пожаловать в бот, %s!
Здесь Вы сможете узнать официальные курсы валют на сегодня, установленные ЦБ РФ.
Для этого воспользуйтесь командами:
/usd - курс доллара
/eur - курс евро
Дополнительные команды:
/help - получение справки
""";
var formattedText = String.format(text, userName);
sendMessage(chatId, formattedText);
}
В данном методе просто формируется сообщение пользователю и отправляется с помощью метода sendMessage(Long chatId, String text). В сигнатуре метода обработчика startCommand 2 параметра:
- chatId - идентификатор чата с пользователем, в который необходимо отправить сообщение
- userName - уникальное имя пользователя в Telegram (ник).
Метод-обработчик команды /usd:
private void usdCommand(Long chatId) {
String formattedText;
try {
var usd = exchangeRatesService.getUSDExchangeRate();
var text = "Курс доллара на %s составляет %s рублей";
formattedText = String.format(text, LocalDate.now(), usd);
} catch (ServiceException e) {
LOG.error("Ошибка получения курса доллара", e);
formattedText = "Не удалось получить текущий курс доллара. Попробуйте позже.";
}
sendMessage(chatId, formattedText);
}
В этом методе пробуем получить актуальный курс доллара и отправить пользователю сообщение. В случае ошибки - пишем её в лог и честно признаёмся пользователю что что-то пошло не так. В сигнатуре метода только параметр chatId.
Метод-обработчик команды /eur:
private void eurCommand(Long chatId) {
String formattedText;
try {
var usd = exchangeRatesService.getEURExchangeRate();
var text = "Курс евро на %s составляет %s рублей";
formattedText = String.format(text, LocalDate.now(), usd);
} catch (ServiceException e) {
LOG.error("Ошибка получения курса евро", e);
formattedText = "Не удалось получить текущий курс евро. Попробуйте позже.";
}
sendMessage(chatId, formattedText);
}
Метод аналогичен предыдущему, только получает актуальный курс евро, а не доллара.
Метод-обработчик команды /help:
private void helpCommand(Long chatId) {
var text = """
Справочная информация по боту
Для получения текущих курсов валют воспользуйтесь командами:
/usd - курс доллара
/eur - курс евро
""";
sendMessage(chatId, text);
}
Просто формируем сообщение со справочной информацией о командах бота и отправляем это сообщение пользователю.
Ну и наивно полагать что пользователи не будут слать всякую фигню в бот. Поэтому сделаем обработчик и на этот случай:
private void unknownCommand(Long chatId) {
var text = "Не удалось распознать команду!";
sendMessage(chatId, text);
}
Методы обработчики готовы, теперь осталось доработать метод onUpdateReceived:
@Override
public void onUpdateReceived(Update update) {
if (!update.hasMessage() || !update.getMessage().hasText()) {
return;
}
String message = update.getMessage().getText();
Long chatId = update.getMessage().getChatId();
switch (message) {
case START -> {
String userName = update.getMessage().getChat().getUserName();
startCommand(chatId, userName);
}
case USD -> usdCommand(chatId);
case EUR -> eurCommand(chatId);
case HELP -> helpCommand(chatId);
default -> unknownCommand(chatId);
}
}
Как вы видите, данный метод получает команды от пользователя и решает какому обработчику какую команду передать с помощью оператора выбора switch case. Команды приходят в бот в виде текстового сообщения:
String message = update.getMessage().getText();
Идентификатор чата с пользователем можно получить следующим образом:
Long chatId = update.getMessage().getChatId();
А узнать как зовут пользователя, можно так:
update.getMessage().getChat().getUserName(); // ник
update.getMessage().getChat().getFirstName(); // имя
update.getMessage().getChat().getLastName(); // фамилия
Вот и всё, Telegram-бот готов! Осталось только запустить приложение и радоваться жизни:
Как всегда исходники доступны на GitHub: https://github.com/AlexeyKutepov/exchange-rates-bot
Так же приглашаю всех желающих подписаться на мой Telegram-канал, в котором я делюсь большим количеством полезной информации о разработке программного обеспечения (в том числе и своими новыми статьями): https://t.me/akutepov