Представляю вашему вниманию своё видение данного процесса, т.к. ничего подобного пока не находил. Для примера будет показана обработка сообщений и нажатий на различные кнопки в боте Avandy News (бот основан на программе Avandy News Analysis, которая включена в Реестр российского ПО).

Стек: Java 17, Spring Boot 3.15, Postgresql 16.1

К нам пришли гости и..

Ситуация № 1 (обработка Inline кнопок)

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

Вкратце

  1. Создаём потокобезопасную коллекцию ConcurrentHashMap, где ключ это уникальный id пользователя, который даёт сам Telegram (chat_id).

  2. Получаем текст сообщения, которое пришло update.getMessage().getText()

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

  4. Создаём клавиатуру InlineKeyboardMarkup с кнопками

  5. При создании клавиатуры кладём слово для конкретного гостя в коллекцию п.1

  6. В секции update.hasCallbackQuery() получаем имя нажатой кнопки и переходим к её кейсу

  7. Берём слово из коллекции и передаём его в нужный метод для обработки. Всё.

НЕ вкратце

Ранее было аж 5 кнопок (упрощаем, упрощаем..)

Как это выглядит в коде:

/* 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);
            }
        }
    }
}
  1. Задаём вопрос посредством класса 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));
}
  1. Гости нажимают на кнопку "Искать новости", но не факт, и

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));
  }

}
  1. Далее они наблюдают такой ответ (вид новостей возможен в 5 вариантах). Как видите окончания у Москвы разные, не только как просили, но это уже совсем другая история.

    Беееез теееебяя, без тебя..

Ситуация № 2 (ожидание ввода текста пользователем)

Допустим гость решил сохранить слово для автопоиска.

Он нажимает Добавить и бот переходит в состояние ожидания ввода с клавиатуры для конкретного пользователя (здесь тоже полно проверок, юзер что только не творит).

Вкратце

  1. Нажатие на кнопку

  2. Перевод бота в нужное состояние, путём его добавление в потокобезопасную коллекцию для конкретного гостя

  3. Ожидание отправки сообщения

  4. Обработка сообщения по конкретному гостю и по конкретному состоянию

НЕ совсем вкратце

Нажата кнопка Добавить

В раздел 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 (обработка сообщения с фото)

Гость захотел написать нам свои впечатления от вечера и приложить фото.

Вкратце

  1. Нажатие на кнопку

  2. Перевод бота в нужное состояние, путём его добавление в потокобезопасную коллекцию для конкретного гостя

  3. Ожидание отправки сообщения

  4. Отдельная обработка сообщения с фото

Нет

// После нажатия кнопки "Написать отзыв"
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 переустановить?

  1. @ - можно использовать любой символ или слово, я выбрал этот

  2. без пробела после @ указываем уникальный chat_it гостя (мы его знаем или видим когда приходит отзыв)

  3. ставим пробел и пишем сообщение которое и увидит гость с указанным 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!