Телеграм-бот на Java: поэтапный ввод с UserState и меню из inline-кнопок

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

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

Поэтапный ввод и UserState

Попробуем реализовать заполнение небольшой анкеты. Например, нас интересует имя и возраст пользователя. Информация эта задана классом UserData.

 public class UserData {
        private final String name;
        private int age;
 
       // здесь будут геттеры и сеттеры
} 

Чтобы заполнять эти данные по очереди, а не одним сообщением, бот должен знать, на каком этапе заполнения он находится. Он должен понимать, в какой момент пользователь пытается послать команду для какого-то нового действия, а в какой момент он пишет своё имя и возраст. Для этого нам нужен enum UserState.

 enum UserState {
        WAITING_FOR_NAME,
        WAITING_FOR_AGE
    }

Названия статусов соответствуют этапу ввода данных.

Также нам нужно хранить данные о всех этих людях. Делать это будет в мапе: Map<Long, List<UserData>>, где каждому чату по ключу chatId будет соответствовать список анкет UserData.

Ну и чтобы не класть в эту мапу анкету до того, как все нужные данные заполнены, создадим ещё мапу для хранения текущего «черновика» Map<Long, UserData>, где у каждого чата может быть по одной не заполненной ещё до конца анкете.

Начинать ввод будем по команде /start, потом будем ожидать имя, а потом возраст. Тогда бот будет выглядеть следующим образом:

public class MyBot extends TelegramLongPollingBot {

    private final Map<Long, UserState> userStates = new HashMap<>();
    private final Map<Long, List<UserData>> userDataMap = new HashMap<>();
    private final Map<Long, UserData> currentDraft = new HashMap<>();

    @Override
    public String getBotUsername() {
        return "MyBot"; 
    }

    @Override
    public String getBotToken() {
        return System.getenv("BOT_TOKEN");
    }

    @Override
    public void onUpdateReceived(Update update) {
        if (update.hasMessage() && update.getMessage().hasText()) {
            String message = update.getMessage().getText();
            long chatId = update.getMessage().getChatId();

            if (message.equalsIgnoreCase("/start")) {
                userStates.put(chatId, UserState.WAITING_FOR_NAME);
                currentDraft.put(chatId, new UserData()); 
                sendText(chatId, "Привет! Как тебя зовут?");
                return;
            }

            UserState state = userStates.get(chatId);

            if (state == null) {
                sendText(chatId, "Напиши /start, чтобы начать заполнение анкеты.");
                return;
            }

            switch (state) {
                case WAITING_FOR_NAME -> {
                    UserData draft = currentDraft.get(chatId);
                    draft.setName(message.trim());
                    userStates.put(chatId, UserState.WAITING_FOR_AGE);
                    sendText(chatId, "Сколько тебе лет?");
                }

                case WAITING_FOR_AGE -> {
                    try {
                        int age = Integer.parseInt(message.trim());
                        UserData current = currentDraft.get(chatId);
                        current.setAge(age);

                        userDataMap
                                .computeIfAbsent(chatId, k -> new ArrayList<>())
                                .add(current);

                        currentDraft.remove(chatId);
                        userStates.remove(chatId);

                        sendText(chatId, String.format("Готово! Тебя зовут %s, тебе %d лет. Хочешь заполнить ещё анкету — напиши /start.",
                                current.getName(), current.getAge()));
                    } catch (NumberFormatException e) {
                        sendText(chatId, "Пожалуйста, введи возраст числом.");
                    }
                }
            }
        }

        private void sendText(long chatId, String text) {
            SendMessage msg = new SendMessage();
            msg.setChatId(String.valueOf(chatId));
            msg.setText(text);
            try {
                execute(msg);
            } catch (TelegramApiException e) {
                e.printStackTrace();
            }
        }
    }
}

Здесь опять первым делом проверяем, есть ли вообще в сообщении текст: update.hasMessage() && update.getMessage().hasText(). Если пользователь скинул гифку или стикер, мы её обрабатывать не будем.

В случае, если нам прислали команду /start, начинаем заполнение новой анкеты. Следующим сообщением мы будем ожидать имя, о чём сообщаем пользователю в собщении.с помощью метода sendText(). И устанавливаем статус для этого chatId на WAITING_FOR_NAME.

В блоке switch проверяем все возможные статусы. При WAITING_FOR_NAME сохраняем текст в поле name и меняем статус на следующий – WAITING_FOR_AGE. Если задан этот статус, то парсим возраст. После этого пользователь считается заполненным – можем сохранить его в главную мапу, очистить текущий «черновик» и установить статус в null. Это значит, что наш «пайплайн» завершён, и мы теперь не ждём никакую информацию, и следующей пользователь должен ввести команду. У нас команда лишь одна /start, но вполне можно добавить дополнительные, например, для удаления данных о человеке или для вывода всех людей из мапы.

Конечно, в идеале данные нужно хранить в базе, а не в памяти. В этом примере при каждом перезапуске приложения на railway (или где вы его будете размещать) данные потеряются. Но для учебного проекта решение подходит.

Работа с базой в боте идентична с другими приложениями. Создаём базу (у меня это MySQL), подключаемся к ней и выполняем SQL-запросы (или, если хочется, с Hibernate HQL-запросы). Создаём DAO-класс для выполнения таких запросов, создаём модель (класс, который отвечает структуре таблицы в базе) и вместо чтения или записи в мапу, как в примерах здесь, пишем и читаем из базы.

Создание меню с inline-кнопками в телеграм-боте

В телеграме есть два вида кнопок: inline-кпопки и reply-кнопки.

Reply-кнопки выводятся внизу под полем ввода текста, и по нажатию на них в чат отправляется текст. Часто они просто иллюстрируют доступные команды и помогают исключить ручной ввод пользователем. За такое меню отвечает класс ReplyKeyboardMarkup. И так как они отправляют в ответ текст, то обрабатывать их в боте тоже нужно, как обычные текстовые сообщение – это мы уже умеем.

Поэтому меня больше интересует другой тип – InlineKeyboardMarkup. Такиеinline-кнопки появляются под сообщением бота, и по их нажатию никакого текста от пользователя в чат не отправляется, а бот мгновенно реагирует на происходящее.

В нашем примере особо не развернуться, но предлагаю добавить выбор возраста в виде меню. Допустим, нас не интересует точный возраст, а только интервал, например 18-24, 25-29, 30-34, 35-40, 40+. Таким образом в классе UserData будем хранить только числа 18, 25, 30, 35, 40 и давать пользователю выбрать подходящий интервал.

Меню можно генерировать на лету, но у нас оно всего одно и не меняется, поэтому сохраним его в поле класса бота:

private final InlineKeyboardMarkup ageKeyboardMarkup = createAgeKeyboard(); 

private InlineKeyboardMarkup createAgeKeyboard() {
        List<List<InlineKeyboardButton>> rows = new ArrayList<>();

        for (String label : List.of("18", "25", "30", "35", "40+")) {
            InlineKeyboardButton button = new InlineKeyboardButton();
            button.setText(label);
            button.setCallbackData("AGE_" + label);
            rows.add(Collections.singletonList(button));
        }

        InlineKeyboardMarkup markup = new InlineKeyboardMarkup();
        markup.setKeyboard(rows);
        return markup;
    }

Кнопки в таком меню выводятся рядами, согласно списку List<List<InlineKeyboardButton>> rows. В нашем примере все выводятся в столбец – по одной кнопке в строчке, поэтому мы помещаем лишь одну кнопку в каждый список. Для каждой кнопки InlineKeyboardButton задаём текст, который будем выводиться на ней и коллбэк – какая команда отправляется в наш метод.

Альтернативой будет использовать билдер, например:

    for (String label : List.of("18", "25", "30", "35", "40+")) {
        InlineKeyboardButton button = InlineKeyboardButton.builder()
                .text(label)
                .callbackData("AGE_" + label)
                .build();

        rows.add(Collections.singletonList(button));
    }

Теперь посмотрим на onUpdateReceived(). В этом методе теперь нужно реагировать не только на текст. При нажатии на кнопку метод update.hasCallbackQuery() вернёт true. И в этом объекте CallbackQuery получаем data – как раз команду, которую мы присвоили каждой кнопке, чтобы узнать, что же конкретно пользователь нажал.

        if (update.hasCallbackQuery()) {
            String data = update.getCallbackQuery().getData();
            long chatId = update.getCallbackQuery().getMessage().getChatId();

            if (data.startsWith("AGE_")) {
                String ageStr = data.substring(4);
                try {
                    int age = ageStr.equals("40+") ? 40 : Integer.parseInt(ageStr);
                    UserData draft = currentDraft.get(chatId);
                    draft.setAge(age);

                    userDataMap
                        .computeIfAbsent(chatId, k -> new ArrayList<>())
                        .add(draft);

                    currentDraft.remove(chatId);
                    userStates.remove(chatId);

                    sendText(chatId, String.format("Готово! Тебя зовут %s, тебе %d лет. Спасибо!",
                            draft.getName(), draft.getAge()));
                } catch (NumberFormatException e) {
                    sendText(chatId, "Произошла ошибка с выбором возраста.");
                }
            }
        }

И нужно не забыть вообще вывести наше меню. После ввода возраста, кроме отправки только текстовой команды также выведем меню, для этого у message зададим ReplyMarkup.

case WAITING_FOR_NAME - > {
                    UserData draft = currentDraft.get(chatId);
                    draft.setName(message.trim());
                    userStates.put(chatId, UserState.WAITING_FOR_AGE);
                    SendMessage message = new SendMessage();
                    message.setChatId(String.valueOf(chatId));
                    message.setText("Сколько тебе лет?");
                    message.setReplyMarkup(ageKeyboardMarkup);
}

Теперь бот реагирует на команду /start, предлагая начать ввод данных с имени, а после этого будет предлагать выбор возраста по кнопкам. При нажатии на кнопку он сохраняет информацию о пользователе в мапу.

Если логика сложнее и действий больше, то и меню может быть не одно. Тогда и после if (update.hasCallbackQuery()) будет намного больше вариантов. Например, вы можете добавить отдельное меню для основных действий: удаление анкеты, добавление новой, редактирование или просмотр всех анкет и выводить его после завершения ввода вместо того, чтобы просить пользователя снова ввести /start.

Удаление сообщений из чата

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

Удаление всего сообщения из чата (DeleteMessage)

Этот способ полностью удаляет сообщение (и текст и меню с кнопками под ним) и полезен, когда нужно полностью очистить устаревший интерфейс, предотвратить повторное взаимодействие или просто удалить лишний шум из чата.

DeleteMessage delete = new DeleteMessage();
delete.setChatId(chatId);
delete.setMessageId(messageId);
execute(delete);

Для удаления сообщения создаём объект DeleteMessage и задаём там chatId и messageId, и после этого вызываем передаём этот объект в метод execute.

Получить айди сообщения можно следующим образом, если нажата кнопка:

int messageId = update.getCallbackQuery().getMessage().getMessageId();

Если вы хотите удалить обычное сообщение, то CallbackQuery в нём не будет, и сообщение получаем так:

int messageId = update.getMessage().getMessageId();

Удаление только кнопок из сообщения в чате (EditMessageReplyMarkup)

Для деактивации устаревших кнопок можно использовать и такой способ. В этом случае текст остаётся, и это полезно, если в нём есть какая-то важная информация, а не просто предложение пользователю выполнить новое действие.

EditMessageReplyMarkup edit = new EditMessageReplyMarkup();
edit.setChatId(chatId);
edit.setMessageId(messageId);
edit.setReplyMarkup(null);  
execute(edit);

Здесь создаём объект типа EditMessageReplyMarkup, и устанавливаем новый марк-ап. Так как мы хотим удалить все кнопки, ставим null, но в принципе можно передать туда другое меню.

Изменение текста и кнопок для сообщения в чате

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

EditMessageText edit = new EditMessageText();
edit.setChatId(chatId);
edit.setMessageId(messageId);
edit.setText("Новое сообщение");
edit.setReplyMarkup(new InlineKeyboardMarkup()); 
execute(edit);

Используем тот же объект EditMessageText, только задаём новый текст методом setText().


В этой статье я показал, как создавать inline-меню, которое прикрепляется к сообщению, как проводить поэтапный ввод и очищать чат от лишних кнопок и сообщений.

Оставьте комментарий