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