Представляю вашему вниманию своё видение данного процесса, т.к. ничего подобного пока не находил. Для примера будет показана обработка сообщений и нажатий на различные кнопки в боте Avandy News (бот основан на программе Avandy News Analysis, которая включена в Реестр российского ПО).
Стек: Java 17, Spring Boot 3.15, Postgresql 16.1
К нам пришли гости и..
Ситуация № 1 (обработка Inline кнопок)
Они хотят написать боту любое слово, чтобы поискать заголовки, содержащие это слово.
Вкратце
Создаём потокобезопасную коллекцию ConcurrentHashMap, где ключ ��то уникальный id пользователя, который даёт сам Telegram (chat_id).
Получаем текст сообщения, которое пришло update.getMessage().getText()
Делаем кейсы для обработки сообщений исходя из логики вашего приложения (у меня такое сообщение, не совпадающее ни с одним кейсом, попадает в дефолтный метод. Там уже есть свои проверки по опыту сообщений пары тысяч пользователей)
Создаём клавиатуру InlineKeyboardMarkup с кнопками
При создании клавиатуры кладём слово для конкретного гостя в коллекцию п.1
В секции update.hasCallbackQuery() получаем имя нажатой кнопки и переходим к её кейсу
Берём слово из коллекции и передаём его в нужный метод для обработки. Всё.
НЕ вкратце

Как это выглядит в коде:
/* 1 шаг. Для того, чтобы слова пользователей, которые пишут боту одновременно, не пересекались - делаем потокобезопасную коллекцию где ключ Long это уникальный chat_id, который любезно предоставляет Telegram */ private final Map<Long, String> oneWordFromChat = new ConcurrentHashMap<>(); @Override public void onUpdateReceived(Update update) { // 2 шаг. Гость отправил сообщение и мы его перехватили здесь if (update.hasMessage() && update.getMessage().hasText()) { String messageText = update.getMessage().getText(); // = "Москва" long chatId = update.getMessage().getChatId(); // уникальный id гостя // Принудительный выход для психов (поверьте - часто прилетает) List<String> flugegeheimen = List.of("стоп", "/стоп", "stop", "/stop"); // toLowerCase :) if (flugegeheimen.contains(messageText.toLowerCase())) { userStates.remove(chatId); // Сбрасываем состояние бота (об этом позже) nextKeyboard(chatId, "Действие отменено"); // Отправляем сообщение гостю } else if (messageText.startsWith("")) { } else if (messageText.equals("")) { } else { /* Основные команды */ switch (messageText) { case "/settings" -> getSettings(chatId); case "/info" -> infoKeyboard(chatId); // 3 шаг. Мы попали сюда, т.к. сообщение "Москва" // ни с одним кейсом не совпало default -> undefinedKeyboard(chatId, messageText); } } } }
Задаём вопрос посредством класса SendMessage, InlineKeyboardMarkup - 2 шт., потокобезопасной коллекции и красивого смайла для сглаживания ситуации после столь прямого вопроса.
// 4 шаг. Показываем клавиатуру InlineKeyboardMarkup с двумя кнопками private void undefinedKeyboard(long chatId, String word) { // 5 шаг. Кладём слово для конкретного гостя в ранее созданную коллекцию oneWordFromChat.put(chatId, word); // word = "Москва" // Делаем двухслойный торт из кнопок Map<String, String> floor1 = new HashMap<>(); Map<String, String> floor2 = new HashMap<>(); // Понятно озвучиваем ингридиенты для себя и гостей floor1.put("ADD_KEYWORD_FROM_CHAT", "Сохранить для автопоиска"); floor2.put("SEARCH_BY_WORD", "Искать новости"); // код maker на GitHub sendMessage(chatId, "Что с этим сделать? " + "\uD83E\uDDD0", InlineKeyboards.maker(floor1, floor2)); }
Гости нажимают на кнопку "Искать новости", но не факт, и
if (update.hasMessage() && update.getMessage().hasText()) { // здесь сейчас пусто, поэтому идём дальше } else if (update.hasCallbackQuery()) { // 6 шаг. А вот здесь уже ловим нажатую кнопку String callbackData = update.getCallbackQuery().getData(); // = "SEARCH_BY_WORD" switch (callbackData) { case "JOHN_COFFEY_MAM" -> drinkCoffee(chatId, "See The Green mile"); // зашли сюда case "SEARCH_BY_WORD" -> // 7 шаг. Передаём слово из коллекции в метод поиска wordSearch(chatId, oneWordFromChat.get(chatId)); } }
Далее они наблюдают такой ответ (вид новостей возможен в 5 вариантах). Как видите окончания у Москвы разные, не только как просили, но это уже совсем другая история.

Беееез теееебяя, без тебя..
Ситуация № 2 (ожидание ввода текста пользователем)
Допустим гость решил сохранить слово для автопоиска.
Он нажимает Добавить и бот переходит в состояние ожидания ввода с клавиатуры для конкретного пользователя (здесь тоже полно проверок, юзер что только не творит).
Вкратце
Нажатие на кнопку
Перевод бота в нужное состояние, путём его добавление в потокобезопасную коллекцию для конкретного гостя
Ожидание отправки сообщения
Обработка сообщения по конкретному гостю и по конкретному состоянию
НЕ совсем вкратце

В раздел hasCallbackQuery летит callbackData равный ADD_KEYWORD (так мы назвали эту кнопку внутри бота) и наш бот ожидает отправки сообщения, т.е. его состояние для конкретного пользователя включается на ADD_KEYWORDS
// Состояния бота public enum UserState { SEND_FEEDBACK, //.. ADD_KEYWORDS }
/* Класс бота */ // 1. Коллекция состояний для каждого пользователя где Long = chatId private final Map<Long, UserState> userStates = new ConcurrentHashMap<>(); if (update.hasMessage() && update.getMessage().hasText()) { // здесь сейчас пусто } else if (update.hasCallbackQuery()) { // 2. Нажав на кнопку мы попадаем в этот кейс, т.к. "Добавить" = "ADD_KEYWORD" case "ADD_KEYWORD" -> setBotState(chatId, UserState.ADD_KEYWORDS, addInListText); // Сброс состояния бота для конкретного пользователя если он нажмёт "Отмена" case "CANCEL" -> userStates.remove(chatId); } // 3. Установка состояния бота private void setBotState(Long chatId, UserState state, String text) { userStates.put(chatId, state); // 4. Здесь бот и ожидает ввода текста. Тут же можем отменить ввод. cancelKeyboard(chatId, text); } // Кнопка отмены ввода слов private void cancelKeyboard(long chatId, String text) { Map<String, String> buttons = new LinkedHashMap<>(); buttons.put("CANCEL", cancelButtonText); sendMessage(chatId, text, InlineKeyboards.maker(buttons)); } // Клавиатура раздела с ключевыми словами для поиска private void keywordsListKeyboard(long chatId, String text) { Map<String, String> buttons1 = new LinkedHashMap<>(); Map<String, String> buttons2 = new LinkedHashMap<>(); buttons1.put("DELETE_KEYWORD", delText); buttons1.put("ADD_KEYWORD", addText); buttons2.put("SET_PERIOD", intervalText); buttons2.put("FIND_BY_KEYWORDS", searchText); sendMessage(chatId, text, InlineKeyboards.maker(buttons1, buttons2)); } }
После ввода текста, когда бот в состоянии ADD_KEYWORDS, мы ловим введённое сообщение уже в разделе update.hasMessage() где и происходит его обработка
// Обработка сообщения if (update.hasMessage() && update.getMessage().hasText()) { String messageText = update.getMessage().getText(); long chatId = update.getMessage().getChatId(); // 5. Берём состояние бота для конкретного пользователя UserState userState = userStates.get(chatId); // Состояние бота сейчас - это добавление слов для автопоиска if (UserState.ADD_KEYWORDS.equals(userState)) { // Обрабатываем String keywords = messageText.trim().toLowerCase(); addKeywords(chatId, keywords); } // Сброс состояния userStates.remove(chatId); } else if (update.hasCallbackQuery()) { //.. }
Ситуация № 3 (обработка сообщения с фото)
Гость захотел написать нам свои впечатления от вечера и приложить фото.
Вкратце
Нажатие на кнопку
Перевод бота в нужное состояние, путём его добавление в потокобезопасную коллекцию для конкретного гостя
Ожидание отправки сообщения
Отдельная обработка сообщения с фото
Нет

// После нажатия кнопки "Написать отзыв" if (update.hasMessage() && update.getMessage().hasText()) {} else if (update.hasCallbackQuery()) { // Устанавливаем статус бота в режим SEND_FEEDBACK для конкретного гостя case "FEEDBACK" -> setBotState(chatId, UserState.SEND_FEEDBACK); }
// Обработка сообщения с фото @Override public void onUpdateReceived(Update update) { if (update.hasMessage() && update.getMessage().hasPhoto()) { long chatId = update.getMessage().getChatId(); // Получаем состояние бота UserState userState = userStates.get(chatId); // update содержит скриншот if (UserState.SEND_FEEDBACK.equals(userState)) { // Отправка сообщения разработчику с приложением скриншота sendFeedbackWithPhoto(update); // Сброс состояния бота userStates.remove(chatId); // Уведомляем, что отправка успешна sendMessage(chatId, "Доставлено ✔️"); } } else if (update.hasMessage() && update.getMessage().hasText()) {} else if (update.hasCallbackQuery()) {} } // Отправка отзыва повару с приложением скриншота (если можно проще, то скажите как) private void sendFeedbackWithPhoto(Update update) { long chatIdFrom = update.getMessage().getChatId(); String userName = userRepository.findNameByChatId(chatIdFrom); String caption = update.getMessage().getCaption(); if (caption == null) caption = "не удосужился.."; List<PhotoSize> photos = update.getMessage().getPhoto(); SendPhoto sendPhoto = new SendPhoto(); sendPhoto.setChatId(OWNER_ID); sendPhoto.setCaption("Message from " + userName + ", " + chatIdFrom + ":\n" + caption); List<PhotoSize> photo = update.getMessage().getPhoto(); if (photo == null) return; try { String fileId = Objects.requireNonNull(photos.stream().max(Comparator.comparing(PhotoSize::getFileSize)) .orElse(null)).getFileId(); URL url = new URL("https://api.telegram.org/bot" + TOKEN + "/getFile?file_id=" + fileId); BufferedReader in = new BufferedReader(new InputStreamReader(url.openStream())); String res = in.readLine(); String filePath = new JSONObject(res).getJSONObject("result").getString("file_path"); String urlPhoto = "https://api.telegram.org/file/bot" + TOKEN + "/" + filePath; URL url2 = new URL(urlPhoto); BufferedImage img = ImageIO.read(url2); ByteArrayOutputStream os = new ByteArrayOutputStream(); ImageIO.write(img, "jpg", os); InputStream is = new ByteArrayInputStream(os.toByteArray()); sendPhoto.setPhoto(new InputFile(is, caption)); execute(sendPhoto); } catch (IOException | TelegramApiException e) { log.error(e.getMessage()); } }
Результат

Ситуация № 4 (отправка сообщения конкретному пользователю от владельца бота)
Если лично разработчик хочет отправить сообщение в чат конкретному гостю, то можно поступить так.
Пишем Эмили в чат сообщение вида:
@123457 А что надо было сделать? Windows переустановить?
@ - можно использовать любой символ или слово, я выбрал этот
без пробела после @ указываем уникальный chat_it гостя (мы его знаем или видим когда приходит отзыв)
ставим пробел и пишем сообщение которое и увидит гость с указанным chat_it
// Перехватываем простое сообщение без фото, без нажатия Inline кнопок if (update.hasMessage() && update.getMessage().hasText()) { long chatId = update.getMessage().getChatId(); // Проверямем, что сообщение отправляет именно владелец бота if (messageText.startsWith("@") && config.getBotOwner() == chatId) { // берём chat_id от собаки до пробела ("что значит открой собаку?") long chatToSend = Long.parseLong(messageText .substring(1, messageText.indexOf(" "))); // а здесь берём всё после пробела String textToSend = messageText.substring(messageText.indexOf(" ")); sendMessage(chatToSend, textToSend); } }

Ситуация № 5 (Menu и Reply Keyboard)
Обработка команд меню происходит в секции обработки сообщений без фото и без нажатых кнопок.
Жмём на команду /settings, переходим к соответствующему кейсу
if (update.hasMessage() && update.getMessage().hasText()) { switch (messageText) { case "/settings" -> getSettings(chatId); case "/keywords", "/list_key" -> showKeywordsList(chatId); case "/info" -> infoKeyboard(chatId); } }

Для быстрого вызова часто используемого или просто-напросто основного функционала - используется ReplyKeyboardMarkup

// Инициализации клавиатуры getReplyKeyboard(chatId, textToSend); // Создание клавиатуры private void getReplyKeyboard(long chatId, String textToSend) { KeyboardRow row = new KeyboardRow(); row.add("Все новости"); row.add("Top 20"); row.add("По словам"); sendMessage(chatId, textToSend, ReplyKeyboards.replyKeyboardMaker(row)); }
// Класс ReplyKeyboards import org.springframework.stereotype.Component; import org.telegram.telegrambots.meta.api.objects.replykeyboard.ReplyKeyboardMarkup; import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.KeyboardRow; import java.util.ArrayList; import java.util.List; public class ReplyKeyboards { public static ReplyKeyboardMarkup replyKeyboardMaker(KeyboardRow row) { ReplyKeyboardMarkup keyboardMarkup = new ReplyKeyboardMarkup(); keyboardMarkup.setResizeKeyboard(true); List<KeyboardRow> keyboardRows = new ArrayList<>(); keyboardRows.add(row); keyboardMarkup.setKeyboard(keyboardRows); return keyboardMarkup; } }
После нажатия на одну из таких кнопок будет обработка простого сообщения с текстом, а не как после нажатия на кнопки Inline Keyboards
if (update.hasMessage() && update.getMessage().hasText()) { if (messageText.equals("По словам")) { findNewsByKeywordsManual(chatId); } else if (messageText.equals("Top 20")) { showTop(chatId); } else if (messageText.equals("Все новости")) { fullSearch(chatId); } else { switch (messageText) { case "/settings" -> getSettings(chatId); case "/keywords", "/list_key" -> showKeywordsList(chatId); case "/info" -> infoKeyboard(chatId); } } }
Подытожим
Бот у меня первый, экспериментальный, так сказать. Делаю долго, учусь. Когда буду делать нового бота, конечно, он будет лучше по качеству, хотя я и здесь постарался на славу.
Если есть какие-то предложения по улучшению кода или ещё какие полезные ситуации, пишите в комментариях, попробую применить. Или что может лучше использовать технологию webHook вместо longPolling.
Спасибо Павлу Дурову за BotFather!
