Простой Telegram-бот на Java и Spring Boot

Простой Telegram-бот на Java и Spring Boot


Date of publication 15.05.2023



Тема разработки собственного бота для Telegram хоть и не новая, но всегда актуальная и востребованная. Думаю каждый разработчик рано или поздно сталкивается с такой задачей, поэтому сегодня я расскажу как разработать простой Telegram-бот на Java с использованием Spring Boot.

Функционал бота, который я буду разрабатывать в этой статье, очень простой - с помощью него можно будет получить официальные курсы валют с сайта ЦБ РФ (в нашем случае это доллары и евро). После прочтения данной статьи вы сможете написать уже собственный 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 в двух режимах:

  1. Long Polling (Длинные опросы) - необходимо наследоваться от класса org.telegram.telegrambots.bots.TelegramLongPollingBot
  2. 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:

Структура папки 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:

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