Продолжаем разрабатывать базовый функционал для бота в телеграм. В предыдущих частях обсуждался момент, что работу бота по получению сообщений, обработке и отправке нужно разделять. Давайте попробуем с помощью базовых инструментов Java Core сделать нашего бота многопоточным и асинхронным. Придумаем ему такое задание, которое занимает очень много времени на обработку. Рассмотрим как работают команды в телеграмме и как их нужно обрабатывать.

Это продолжение первой части статьи по программированию ботов для телеграмм на Java
TelegramBot инструкция по созданию базового функционала для бота. (Часть 1)
Кому интересно дальше, милости прошу под кат…

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

Как обычно с главного:
Весь готовый код по этой статье вы можете найти в ветке Part2-Handlers в гит-репозитории.
Код полностью рабочий, достаточно склонироваться, изменить данные для авторизации бота(имя и токен) и запустить метод main в классе App.class.

Обратите внимание, данный класс при старте бота отправляет уведомление админу бота о том, что бот стартовал. ID админа бота также указывается в классе App.class и если вы его не измените — ваш бот будет пытаться слать сообщения мне :)

А дальше по пунктам разберем те изменения, которые появились после выхода первой части.

Обработка команд


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

  1. Обычный текст, обычные сообщения.
    В таком виде бот получается сообщения, когда ему пишут в личку. И еще, если в настройках бота режим приватности в группах боту отключают — он начинает видеть полностью все сообщения. Есть же эта настройка включена, то при добавлении в группу бот видит только адресованные ему команды. Как они выглядят — смотрите второй пункт
  2. Специально оформленные команды
    Такие команды всегда начинаются с косой черты: /
    После следует сама команда. Текст команды должен быть без пробелов. Пример:
    /start
    С этой команды любой пользователь всегда начинает общение с вашим ботом. Поэтому по правилам хорошего тона реакцию на эту команду нужно прописывать обязательно.

    Все команды, с которыми умеет работать ваш бот, желательно добавить в список умений в настройках вашего бота. Делается это все в телеграмме с @BotFather.

    Вызвав команду /myBots выберите вашего бота и дальше кнопка «Edit Bot»
    Вы получите такое окно, где будут показаны все параметры бота и дальше можно настроить весь его интерфейс и указать с какими командами ваш бот умеет работать.



    Задаются они вот в таком формате:



    И после этого при начале ввода команды вашему боту — он будет показывать помощь со списком перечисленных команд:



    И есть еще один нюанс. В группе может присутствовать несколько ботов и если у них есть общие команды(а общие команды обязательно будут, те же start и help реализованы у большей части ботов), то к самой команде обязательно будет добавлена часть, сообщающая к какому боту эта команда относится. И выглядеть команда полностью будет вот так:
    /start@test_habr_bot

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

Создадим package, который у нас будет содержать классы, ответственные за обработку команд.
package com.example.telegrambot.command

В классе Command мы перечислим все команды, которые должен уметь понимать наш бот.

public enum Command {
    NONE, NOTFORME,

    NOTIFY,
    START, HELP, ID
}

Как вы видели ранее, я у @BotFather указал, что бот у меня должен уметь понимать 4 команды. Это будут стандартные start и help. Одну добавим полезную — id. И еще одну, notify, про которую я расскажу чуть позже. И две команды NONE и NOTFORME, которые будут говорить нам, что текст сообщение либо вообще не является командой, либо это команда не для нашего бота.

Еще добавим один вспомогательный класс ParsedCommand


import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ParsedCommand {
    Command command = Command.NONE;
    String text="";
}

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

И напишем отдельный класс, который будет нам парсить команды. Класс Parser


import javafx.util.Pair;
import org.apache.log4j.Logger;

public class Parser {
    private static final Logger log = Logger.getLogger(Parser.class);
    private final String PREFIX_FOR_COMMAND = "/";
    private final String DELIMITER_COMMAND_BOTNAME = "@";
    private String botName;

    public Parser(String botName) {
        this.botName = botName;
    }

    public ParsedCommand getParsedCommand(String text) {
      String trimText = "";
        if (text != null) trimText = text.trim();
        ParsedCommand result = new ParsedCommand(Command.NONE, trimText);

        if ("".equals(trimText)) return result;
        Pair<String, String> commandAndText = getDelimitedCommandFromText(trimText);
        if (isCommand(commandAndText.getKey())) {
            if (isCommandForMe(commandAndText.getKey())) {
                String commandForParse = cutCommandFromFullText(commandAndText.getKey());
                Command commandFromText = getCommandFromText(commandForParse);
                result.setText(commandAndText.getValue());
                result.setCommand(commandFromText);
            } else {
                result.setCommand(Command.NOTFORME);
                result.setText(commandAndText.getValue());
            }

        }
        return result;
    }

    private String cutCommandFromFullText(String text) {
        return text.contains(DELIMITER_COMMAND_BOTNAME) ?
                text.substring(1, text.indexOf(DELIMITER_COMMAND_BOTNAME)) :
                text.substring(1);
    }

    private Command getCommandFromText(String text) {
        String upperCaseText = text.toUpperCase().trim();
        Command command = Command.NONE;
        try {
            command = Command.valueOf(upperCaseText);
        } catch (IllegalArgumentException e) {
            log.debug("Can't parse command: " + text);
        }
        return command;
    }

    private Pair<String, String> getDelimitedCommandFromText(String trimText) {
        Pair<String, String> commandText;

        if (trimText.contains(" ")) {
            int indexOfSpace = trimText.indexOf(" ");
            commandText = new Pair<>(trimText.substring(0, indexOfSpace), trimText.substring(indexOfSpace + 1));
        } else commandText = new Pair<>(trimText, "");
        return commandText;
    }

    private boolean isCommandForMe(String command) {
        if (command.contains(DELIMITER_COMMAND_BOTNAME)) {
            String botNameForEqual = command.substring(command.indexOf(DELIMITER_COMMAND_BOTNAME) + 1);
            return botName.equals(botNameForEqual);
        }
        return true;
    }

    private boolean isCommand(String text) {
        return text.startsWith(PREFIX_FOR_COMMAND);
    }
}

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

Ну и дальше мы просто вызываем публичный метод

public ParsedCommand getParsedCommand(String text)

Которому в аргументах передаем текст сообщения, а он нам в ответ должен вернуть команду и текст сообщения идущий после команды.

Как работает парсер вы можете увидеть в тестовом классе.

Мухи отдельно, котлеты отдельно


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

Изменения в этом классе очень незначительные. Мы добавили две очереди:


    public final Queue<Object> sendQueue = new ConcurrentLinkedQueue<>();
    public final Queue<Object> receiveQueue = new ConcurrentLinkedQueue<>();

и немного переписали код функции public void onUpdateReceived(Update update)


    @Override
    public void onUpdateReceived(Update update) {
        log.debug("Receive new Update. updateID: " + update.getUpdateId());
        receiveQueue.add(update);
    }

Почему так? Опять таки я перепробовал разные варианты. И основная проблема многопоточности — это работа с общими данными. И мне больше всего понравилось как с этим справляется реализация многопоточных очередей ConcurrentLinkedQueue<>()
И как вы видите, в обоих очередях у нас будут хранится типы данных Object. Это еще одна закладка на будущее. Таким образом мы не привязываемся к типам полученных сообщений. Во входящую очередь сможем складывать не только объекты типа Update а и какие-то другие, нужные нам.

Тоже самое и с очередью на отправку. Так как мы можем отправлять самые разные типы сообщений и у них нет общего родителя — мы также используем общий тип данных — Object.
Если в таком виде запустить бота, то он будет работать, но не будет делать ничего. Все полученные сообщения он будет фиксировать в логе и складывать в очередь.
Следовательно нам нужен какой-то поток, который займется тем, что будет забирать из очереди принятые сообщения и совершать над ними какие-то действия и складывать в очередь sendQueue результаты своей работы.

Создадим отдельный package: service и в нем у нас будет всего 2 класса:

MessageReciever — обработчик полученных сообщений
MessageSender — обработчик очереди сообщений, которые нужно отправить пользователю.

Их работу мы рассмотрим чуть ниже, а пока пропишем их использование в нашем стартовом классе App

После того, как наш бот приконнектился — мы стартует наши обработчики в отдельных потоках:


        MessageReciever messageReciever = new MessageReciever(test_habr_bot);
        MessageSender messageSender = new MessageSender(test_habr_bot);

        test_habr_bot.botConnect();

        Thread receiver = new Thread(messageReciever);
        receiver.setDaemon(true);
        receiver.setName("MsgReciever");
        receiver.setPriority(PRIORITY_FOR_RECEIVER);
        receiver.start();

        Thread sender = new Thread(messageSender);
        sender.setDaemon(true);
        sender.setName("MsgSender");
        sender.setPriority(PRIORITY_FOR_SENDER);
        sender.start();

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

Как бы не хотелось разобраться в первую очередь с обработчиком входящих сообщений — давайте разберем работу класса MessageSender.

Разберем по пунктам, что он умеет и что делает:

  • Естественно это наследование интерфейса для многопоточности:
    implements Runnable
    и реализация функции run

    @Override
    public void run()

    Тут мы запускам бесконечный цикл, который занят только тем, что он проверяет очередь на отправку и вызывает команду send

    private void send(Object object)

    если в очереди что-то появляется.
  • В конструкторе класса мы передаем объект класса Bot, т.к. из него мы будем брать объекты для отправки сообщений и с его же помощью будем их отправлять.
  • Метод send определяет тип сообщения для отправки и применяет к нему соответствую команду.

Ну и теперь разберем работу класса MessageReciever

Он, как и MessageSender, должен быть многопоточным, в конструкторе получать объект класса Bot, в котором он в бесконечном цикле будет брать принятые сообщения, обрабатывать их и складывать ему же в очередь для отправки результаты своей работы.

Тут мы используем созданный ранее парсер команд. И тут же мы добавим возможность использовать различные типы обработчиков для наших команд и некоторые из них сделаем многопоточными.

Цикл работы очень простой:


    @Override
    public void run() {
        log.info("[STARTED] MsgReciever.  Bot class: " + bot);
        while (true) {
            for (Object object = bot.receiveQueue.poll(); object != null; object = bot.receiveQueue.poll()) {
                log.debug("New object for analyze in queue " + object.toString());
                analyze(object);
            }
            try {
                Thread.sleep(WAIT_FOR_NEW_MESSAGE_DELAY);
            } catch (InterruptedException e) {
                log.error("Catch interrupt. Exit", e);
                return;
            }
        }
    }

Проверяем очередь. Если что-то есть — запускаем анализатор:

private void analyze(Object object)

Если ничего нет — ждем.

Анализатор проверяет тип объекта. Если он умеет с ним работать — запускает следующий анализатор. Если не умеет — ругается :)

Почему так? Опять таки это закладка на будущее и, надеюсь, я её раскрою в следующих частях этого цикла статей. Такая реализация позволит нам потом формировать свои какие-то задания для бота, делать списки рассылок, дневные задания. Для этого ресивер должен уметь обрабатывать не только объекты типа Update а и что-то наше. Но об этом потом :)

Рассмотрим подробнее анализатор для типа Update:


    private void analyzeForUpdateType(Update update) {
        Long chatId = update.getMessage().getChatId();
        String inputText = update.getMessage().getText();

        ParsedCommand parsedCommand = parser.getParsedCommand(inputText);
        AbstractHandler handlerForCommand = getHandlerForCommand(parsedCommand.getCommand());

        String operationResult = handlerForCommand.operate(chatId.toString(), parsedCommand, update);

        if (!"".equals(operationResult)) {
            SendMessage message = new SendMessage();
            message.setChatId(chatId);
            message.setText(operationResult);
            bot.sendQueue.add(message);
        }
    }

Он определяет ID чата. Получает текст сообщения. С помощью парсера определяет, является ли сообщение командой и определяет каким хендлером данную команду нужно обрабатывать. Запускает обработку команды и если обработка команды вернула какой-то непустой текст — формирует сообщение для отправки пользователю и складывает его в очередь.

И тут у вас должен возникнуть вопрос: «Что еще за хендлер?». Речи про него раньше не шло и в коде он не упоминался. Все верно. Сейчас мы этот функционал и разберем.

Для этого создадим отдельный package, который и будет хранить все наши хендлеры. Назовем его handler
Создадим абстрактный класс AbstractHandler


import com.example.telegrambot.bot.Bot;
import com.example.telegrambot.command.ParsedCommand;
import org.telegram.telegrambots.api.objects.Update;

public abstract class AbstractHandler {
    Bot bot;

    AbstractHandler(Bot bot) {
        this.bot = bot;
    }

    public abstract String operate(String chatId, ParsedCommand parsedCommand, Update update);
}

У него будет базовый конструктор, в котором мы передаем с каким именно объектом Bot ему нужно будет взаимодествовать. И объявлена абстрактная функция operate реализацию которой мы должны будем прописать в наследниках нашего класса.

И сразу же реализуем самый простой хендлер, который не будет делать ничего и использовать мы его будем тогда, когда не сможем понять что за тип команды нам передали и от бота никакой реакции не требуется.

DefaultHandler.java


import com.example.telegrambot.bot.Bot;
import com.example.telegrambot.command.ParsedCommand;
import org.apache.log4j.Logger;
import org.telegram.telegrambots.api.objects.Update;

public class DefaultHandler extends AbstractHandler {
    private static final Logger log = Logger.getLogger(DefaultHandler.class);

    public DefaultHandler(Bot bot) {
        super(bot);
    }

    @Override
    public String operate(String chatId, ParsedCommand parsedCommand, Update update) {
        return "";
    }
}

Как мы его применим и где мы получим результаты его работы — разберем чуть позже.

Следующий на очереди — SystemHandler
Он у нас займется обработкой базовых команд, таких как start, help и мы поручим ему еще и выполнение команды id

Основа его выглядит так:


import com.example.telegrambot.bot.Bot;
import com.example.telegrambot.command.Command;
import com.example.telegrambot.command.ParsedCommand;
import org.apache.log4j.Logger;
import org.telegram.telegrambots.api.methods.send.SendMessage;
import org.telegram.telegrambots.api.objects.Update;

public class SystemHandler extends AbstractHandler {
    private static final Logger log = Logger.getLogger(SystemHandler.class);
    private final String END_LINE = "\n";

    public SystemHandler(Bot bot) {
        super(bot);
    }

    @Override
    public String operate(String chatId, ParsedCommand parsedCommand, Update update) {
        Command command = parsedCommand.getCommand();

        switch (command) {
            case START:
                bot.sendQueue.add(getMessageStart(chatId));
                break;
            case HELP:
                bot.sendQueue.add(getMessageHelp(chatId));
                break;
            case ID:
                return "Your telegramID: " + update.getMessage().getFrom().getId();
        }
        return "";
    }

Как реализовано формирование ответа на команду start и help вы можете посмотреть в коде :)
Формируем текстовые сообщения и складываем их в очередь на отправку. На этом работа хендлера прекращается. Кто и как отправит эти сообщения — его совершенно не волнует.
И помните чуть выше я упоминал, что в результате работы хендлера он возвращает какие-то текстовые данные. И если эта строка не пустая — мы этот текст должны отправить пользователю. Вот именно этот функционал мы и использовали при отработке команды ID:


case ID:
                return "Your telegramID: " + update.getMessage().getFrom().getId();

Хендлер вернет текст с ID пользователя тому, кто его вызвал и уже там сформируется сообщение на отправку, которое и уйдет потом в очередь.

И в начале статьи я упомянул, что мы реализуем такой вариант обработки сообщения пользователя, которому на работу нужно время. И чтобы он не мешал нашим обработчикам — мы его выделим в отдельный поток и пусть он занимается своим делами, не отвлекая остальных.
В качестве такого «тяжеловесного» потока я придумал команду notify. Принцип её работы такой.

Передав боту команду вида:
/notify 300

Бот должен вам сообщить, что команду понял и через 300 секунд он пришлет вам какое-то уведомление, что 300 секунд прошли. У этой команды может быть даже практическое применение :)

Например вы поставили пельмешки на огонь и вам их нужно снять через 5 минут. Бот с этим отлично справится и кинет вам в чат уведомление о том, что время вышло.

Или возьмем задачу более серьезную. Вы идете на важную встречу и вы знаете, что при общении с собеседником вам нужно будет прервать разговор. Для этого обычно просят друзей или позвонить или написать сообщение, что и будет являться мотивом не на долго отвлечься от беседы и сделать какие-то действия. Но зачем мучать друзей, когда у вас есть бот? Задав ему заранее задание и указав время — вы получите нужное уведомление в телеграм. Но это все лирика. Задание и эта команда придуманы лишь для того, чтобы показать вам как выделить в отдельный поток что-то, чья работа может занимать очень большой промежуток времени.

Итак, NotifyHandler:


import com.example.telegrambot.ability.Notify;
import com.example.telegrambot.bot.Bot;
import com.example.telegrambot.command.ParsedCommand;
import org.apache.log4j.Logger;
import org.telegram.telegrambots.api.objects.Update;

public class NotifyHandler extends AbstractHandler {
    private static final Logger log = Logger.getLogger(NotifyHandler.class);
    private final int MILLISEC_IN_SEC = 1000;
    private String WRONG_INPUT_MESSAGE = "Wrong input. Time must be specified as an integer greater than 0";

    public NotifyHandler(Bot bot) {
        super(bot);
    }

    @Override
    public String operate(String chatId, ParsedCommand parsedCommand, Update update) {
        String text = parsedCommand.getText();
        if ("".equals(text))
            return "You must specify the delay time. Like this:\n" +
                    "/notify 30";
        long timeInSec;
        try {
            timeInSec = Long.parseLong(text.trim());
        } catch (NumberFormatException e) {
            return WRONG_INPUT_MESSAGE;
        }
        if (timeInSec > 0) {
            Thread thread = new Thread(new Notify(bot, chatId, timeInSec * MILLISEC_IN_SEC));
            thread.start();
        } else return WRONG_INPUT_MESSAGE;
        return "";
    }
}

Проверяем, передали ли нам в тексте время задержки. Если нет — ругаемся. Если да — стартуем новый поток, куда мы передаем вводные по нашему заданию. Выполнением этого задания займется отдельный класс Notify.
Функционал предельно простой. Он спит указанное колличество секунд. Но в процессе его сна ваш бот умеет принимать любые другие сообщения, общаться с вами, запускать еще дополнительные уведомления. И все это работает отдельно друг от друга.

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

private AbstractHandler getHandlerForCommand(Command command) {
        if (command == null) {
            log.warn("Null command accepted. This is not good scenario.");
            return new DefaultHandler(bot);
        }
        switch (command) {
            case START:
            case HELP:
            case ID:
                SystemHandler systemHandler = new SystemHandler(bot);
                log.info("Handler for command[" + command.toString() + "] is: " + systemHandler);
                return systemHandler;
            case NOTIFY:
                NotifyHandler notifyHandler = new NotifyHandler(bot);
                log.info("Handler for command[" + command.toString() + "] is: " + notifyHandler);
                return notifyHandler;
            default:
                log.info("Handler for command[" + command.toString() + "] not Set. Return DefaultHandler");
                return new DefaultHandler(bot);
        }
    }

Теперь, если вы захотите добавить еще какие-то команды, нужно будет сделать следующее:

  1. В классе Command добавить синтаксис команды.
  2. В ресивере в функции getHandlerForCommand указать, кто будет ответственен за обработку этой команды.
  3. И собственно написать этот хендлер.

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

О чем мы поговорим в следующих частях?

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

Не стесняйтесь, задавайте вопросы. Если в статье что-то не указано или какой-то момент не ясен — напишите мне об этом. Я обязательно поправлю, отредактирую или уточню спорные моменты.

Программируйте в удовольствие и да прибудет с вами сила и красивый код :)

пы.сы.

Бот, написанный в этой части статьи, работает. Помучать его можно тут: @test_habr_bot
Так же можно помучать моего планировщика: @EventCheckPlanner_Bot
И Дерзкого киномана: @FilmFanAmateurBot.

Комментарии (5)


  1. bvn13
    23.12.2019 17:14
    +1

    next: StateMachine pattern, MQ для очередей сообщений, Apache Camel для обработчиков сообщений из очередей (многопоточность встроенная)


    1. dp_ua Автор
      23.12.2019 17:25

      Интересно, но не в контексте именно этого мануала, я думаю.
      По сути то, что перечислено — это больше касается изучения самой java и технологий вокруг нее.
      У меня проект направлен на изучение именно TelegramAPI и все что связано с ним в контексте языка java.
      Как бы так сказать… сделать сложно — проще всего. Я пытаюсь найти ту грань, где мы не выпрыгиваем на какие-то сложные вещи и пытаемся реализовать базовый функционал на core платформе.
      Почему так? Наверное после выхода первых статей я опирался на те вопросы, что задавались мне в личку. И отталкивался я в этом мануале от фразы, что чаще всего боты на питоне пишут а под джаву ничего толкового нет.


  1. krylovsergey1999
    23.12.2019 17:17
    +1

    Ждем еще!


  1. ruslanys
    23.12.2019 18:45
    +1

    Обратил внимание на Thread.sleep(). Мне кажется, вместо ConcurrentLinkedQueue в данном конкретном случае лучше взять BlockingQueue и тогда не придется усыплять поток (Thread.sleep). Метод queue.poll() будет его блокировать, а отпускать именно в тот момент, когда появляется сообщение.


    А вообще рекомендую глянуть мою статью на эту тему: https://habr.com/ru/post/445072/


    1. dp_ua Автор
      23.12.2019 18:58

      Мне нравится такая мысль. Посмотрю, дополню пост.