В этом посте хочется разобрать создание ботов в телеграмме, ведь их очень интересно писать (по крайней мере, для новичков).

Мы попробуем :

  • написать расширяемого бота

  • использовать спринг

Для начала нам нужно создать приложение на спринге. Но я думаю, каждый уже умеет это делать.

Затем добавим зависимости, многие пользуются telegrambots-spring-boot-starter, но мне как-то не довелось увидеться с ним, поэтому используем самый обычный API.

<dependency>
            <groupId>org.telegram</groupId>
            <artifactId>telegrambots</artifactId>
            <version>6.5.0</version>
</dependency>

Теперь создадим файл application.yaml в папке resources. В нём напишем токен бота.
Telegram-bots ещё требует имя, но вводить настоящее - не обязательно.

bot:
  token: 6098243395:AAFwSeKCFxh6kOTPPfcSYTdTuhqRZyBfULA

Создадим наш первый и основной компонент. В нём мы будем регистрировать бота и обрабатывать сообщения.

@Component
public class BotComponent extends TelegramLongPollingBot {

    // Создаём их объект для регистрации
    private final TelegramBotsApi telegramBotsApi = new TelegramBotsApi(DefaultBotSession.class);

    // Достаём токен бота
    @Value("${bot.token}")
    private String botToken;

    @PostConstruct
    private void init() throws TelegramApiException {
        telegramBotsApi.registerBot(this); // Регистрируем бота
    }

    public BotComponent() throws TelegramApiException {}

    @Override
    public void onUpdateReceived(Update update) {
      //Проверим, работает ли наш бот.
        System.out.println(update.getMessage().getText());
    }

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

    @Override
    public String getBotToken() {
        return botToken;
    }
}

Теперь начинаем работать с косяками api телеграмма и как-то их обрабатывать.
Самая главная проблема - у api телеграмма отсутствует один общий интерфейс, который бы объединял все возможные виды апдейта (за исключением BotApiMethod). Обычное сообщение и SendPhoto разделены и у них нет ничего общего, а нам нужно выдавить абстракции для того, чтобы всё легко расширялось, поэтому нам придётся поговнокодить. (Возможно реализация этого может выглядеть лучше).

В том числе, нам нужно определить тип сообщения, для дальнейшего правильного использования.

Для этого создадим класс ClassifiedUpdate. Я использую Lombok, если вас это испугало, то почитайте, что это такое.

public class ClassifiedUpdate {
    @Getter
    private final TelegramType telegramType; // enum, чтобы всё выглядило красиво

    @Getter
    private final Long userId; // тот же chat-id, но выглядит красивее и получить его легче

    @Getter
    private String name; // получим имя пользователя. Именно имя, не @username

    @Getter
    private String commandName; // если это команда, то запишем её

    @Getter
    private final Update update; // сохраним сам update, чтобы в случае чего, его можно было достать

    @Getter
    private final List<String> args; // просто поделим текст сообщения, в будущем это поможет

    @Getter
    private String userName; // @username

    public ClassifiedUpdate(Update update) {
        this.update = update;
        this.telegramType = handleTelegramType();
        this.userId = handleUserId();
        this.args = handleArgs();
        this.commandName = handleCommandName();
    }
    
    //Обработаем команду.
    public String handleCommandName() {
        if(update.hasMessage()) {
            if(update.getMessage().hasText()) {
                if(update.getMessage().getText().startsWith("/")) {
                    return update.getMessage().getText().split(" ")[0];
                } else return update.getMessage().getText();
            }
        } if(update.hasCallbackQuery()) {
            return update.getCallbackQuery().getData().split(" ")[0];
        }
        return "";
    }
    
    //Обработаем тип сообщения
    private TelegramType handleTelegramType() {

        if(update.hasCallbackQuery())
            return TelegramType.CallBack;

        if(update.hasMessage()) {
            if(update.getMessage().hasText()) {
                if(update.getMessage().getText().startsWith("/"))
                    return TelegramType.Command;
                else
                    return TelegramType.Text;
            } else if(update.getMessage().hasSuccessfulPayment()) {
                return TelegramType.SuccessPayment;
            } else if(update.getMessage().hasPhoto())
                return TelegramType.Photo;
        } else if(update.hasPreCheckoutQuery()) {
            return TelegramType.PreCheckoutQuery;
        } else if(update.hasChatJoinRequest()) {
            return TelegramType.ChatJoinRequest;
        } else if(update.hasChannelPost()) {
            return TelegramType.ChannelPost;
        }
        else if(update.hasMyChatMember()) {
            return TelegramType.MyChatMember;
        }
        if(update.getMessage().hasDocument()) {
            return TelegramType.Text;
        }
        return TelegramType.Unknown;
    }

    //Достанем userId, имя и username из любого типа сообщений.
    private Long handleUserId() {
        if (telegramType == TelegramType.PreCheckoutQuery) {
            name = getNameByUser(update.getPreCheckoutQuery().getFrom());
            userName = update.getPreCheckoutQuery().getFrom().getUserName();
            return update.getPreCheckoutQuery().getFrom().getId();
        } else if(telegramType == TelegramType.ChatJoinRequest) {
            name = getNameByUser(update.getChatJoinRequest().getUser());
            userName = update.getChatJoinRequest().getUser().getUserName();
            return update.getChatJoinRequest().getUser().getId();
        } else if (telegramType == TelegramType.CallBack) {
            name = getNameByUser(update.getCallbackQuery().getFrom());
            userName = update.getCallbackQuery().getFrom().getUserName();
            return update.getCallbackQuery().getFrom().getId();
        } else if(telegramType == TelegramType.MyChatMember){
            name = update.getMyChatMember().getChat().getTitle();
            userName = update.getMyChatMember().getChat().getUserName();
            return update.getMyChatMember().getFrom().getId();
        } else {
            name = getNameByUser(update.getMessage().getFrom());
            userName = update.getMessage().getFrom().getUserName();
            return update.getMessage().getFrom().getId();
        }
    }

    //Разделим сообщение на аргументы
    private List<String> handleArgs() {
        List<String> list = new LinkedList<>();

        if(telegramType == TelegramType.Command) {
            String[] args = getUpdate().getMessage().getText().split(" ");
            Collections.addAll(list, args);
            list.remove(0);

            return list;
        } else if (telegramType == TelegramType.Text) {
            list.add(getUpdate().getMessage().getText());

            return list;
        } else if (telegramType == TelegramType.CallBack) {
            String[] args = getUpdate().getCallbackQuery().getData().split(" ");
            Collections.addAll(list, args);
            list.remove(0);

            return list;
        }
        return new ArrayList<>();
    }

    //Вынесли имя в другой метод
    private String getNameByUser(User user) {
        if(user.getIsBot())
            return "BOT";

        if(!user.getFirstName().isBlank() || !user.getFirstName().isEmpty())
            return user.getFirstName();

        if(!user.getUserName().isBlank() || !user.getUserName().isEmpty())
            return user.getUserName();

        return "noname";
    }

    //Лог
    public String getLog() {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append("USER_ID : " + getUserId());
        stringBuilder.append("\nUSER_NAME : " + getName());
        stringBuilder.append("\nTYPE : " + getTelegramType());
        stringBuilder.append("\nARGS : " + getArgs().toString());
        stringBuilder.append("\nCOMMAND_NAME : " + getCommandName());

        return stringBuilder.toString();
    }

Это выглядит ужасно и некрасиво, обязательно как-то отрефакторим это, но не сегодня.
Хотел бы объяснить, зачем я разделил @username и Имя Фамилия.

Дело в том, что некоторые пользователи не имеют имя и фамилию в настройках профиля, а некоторые имеют только это. В общем, мы предусмотрели этот момент. И теперь если мы захотим написать: Привет, Илья! У нас никогда не будет: Привет, null!. Мы ведь не хотим отставать от глаза бога.

Тем, кому лень писать код, держите TelegramType:

public enum TelegramType {
    Command, Text, Photo, SuccessPayment, PreCheckoutQuery, 
      ChannelPost, ChatJoinRequest, Unknown, CallBack, MyChatMember
}

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

Это нам очень сильно поможет в будущем, нужно только верить.

@Data
public class Answer {
    private SendDocument sendDocument;
    private SendPhoto sendPhoto;
    private SendVideo sendVideo;
    private SendVideoNote sendVideoNote;
    private SendSticker sendSticker;
    private SendAudio sendAudio;
    private SendVoice sendVoice;
    private SendMediaGroup sendMediaGroup;
    private SetChatPhoto setChatPhoto;
    private AddStickerToSet addStickerToSet;
    private SetStickerSetThumb setStickerSetThumb;
    private CreateNewStickerSet createNewStickerSet;
    private UploadStickerFile uploadStickerFile;
    private EditMessageMedia editMessageMedia;
    private SendAnimation sendAnimation;
    private BotApiMethod<?> botApiMethod;
}

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

Теперь нам как-то нужно работать с пользователями, поэтому с помощью Spring JPA создадим сущность пользователя.

@Entity
@Table(name = "users")
@Getter
@Setter
public class User {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(unique = true, nullable = false)
    private Long chatId;

    @Column(nullable = false)
    private Long permissions;

    @OneToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @JoinColumn
    private State state;
  
    @Column(unique = true)
    private String userName;
}

Как вы можете заметить, у пользователя есть состояние, это поможет нам для проведения интерактивов и т.д. Также я использую у permissions тип Long, потому что обычно это:

  • 0 - Default User

  • 1 - Какой-нибудь VIP

  • 2 - Moderator

  • 3 - Admin

Это просто и удобно и лениво, но если кто-то хочет, то может заморочиться.
Вернёмся к состоянию, напишем простую сущность для состояния :

@Entity
@Table(name = "state")
@Getter
@Setter
public class State {
    
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long Id;

    @Column(name = "value")
    private String stateValue;

    public boolean inState() {
        return stateValue != null;
    }

    @OneToOne(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @JoinColumn
    private User user;
}

Для чего нам нужно состояние?
К примеру, пользователь захотел пополнить баланс, и мы просим его ввести сумму пополнения. Если мы не узнаем, что прямо сейчас он вводит сумму пополнения, то будем обрабатывать его команду: 100, как обычную. В общем, нам нужно состояние.

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

@MappedSuperclass
public interface Handler {
  // Какой тип сообщения будет обработан
    TelegramType getHandleType();
  // Приоритет обработчика
    int priority();
  // Условия, при которых мы воспользуемся этим обработчиком
    boolean condition(User user, ClassifiedUpdate update);
  // В этом методе, с помощью апдейта мы будем получать answer
    Answer getAnswer(User user, ClassifiedUpdate update);
}

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

@MappedSuperclass
public interface Command {
  // Каким обработчиком будет пользоваться команда
    Class handler();
  // С помощью чего мы найдём эту команду
    Object getFindBy();
  // Ну и тут мы уже получим ответ на самом деле
    Answer getAnswer(ClassifiedUpdate update, User user);
}

Теперь как-то надо найти команды для обработчика, поэтому создадим класс AbstractHandler.

@MappedSuperclass
public abstract class AbstractHandler implements Handler {

    protected final Map<Object, Command> allCommands = new HashMap<>();
  // Найдём все команды для обработчика
    @Autowired
    private List<Command> commands;

    protected abstract HashMap<Object, Command> createMap();

  // Тут мы распихиваем команды по хэшмапе, чтобы потом было удобнее доставать :/
    @PostConstruct
    private void init() {
        commands.forEach(c -> {
            allCommands.put(c.getFindBy(), c);
            if(Objects.equals(c.handler().getName(), this.getClass().getName())) {
                createMap().put(c.getFindBy(), c);

                System.out.println(c.getClass().getSimpleName() + " was added for " + this.getClass().getSimpleName());
            }
        });
    }
}

Это конечно всё хорошо, но нам нужно собрать все обработчики в одном месте. И отправить наш ClassifiedUpdate в эту бездонную бочку. Назовём эту штуку HandlersMap, просто потому что я снова распихиваю обработчики по хэшмапе :)

@Component
public class HandlersMap {
    private HashMap<TelegramType, List<Handler>> hashMap = new HashMap<>();
    private final List<Handler> handlers;

  // Тут точно также находим все обработчики, просто в первом случае я использовал
  // @Autowired. Это немного лучше.
    public HandlersMap(List<Handler> handlers) {
        this.handlers = handlers;
    }

    @PostConstruct
    private void init() {
        for(Handler handler : handlers) {
            if(!hashMap.containsKey(handler.getHandleType()))
                hashMap.put(handler.getHandleType(), new ArrayList<>());

            hashMap.get(handler.getHandleType()).add(handler);
        }

        hashMap.values().forEach(h -> h.sort(new Comparator<Handler>() {
            @Override
            public int compare(Handler o1, Handler o2) {
                return o2.priority() - o1.priority();
            }
        }));
    }

    public Answer execute(ClassifiedUpdate classifiedUpdate, User user) {
        if(!hashMap.containsKey(classifiedUpdate.getTelegramType()))
            return new Answer();

        for (Handler handler : hashMap.get(classifiedUpdate.getTelegramType())) {
            if(handler.condition(user, classifiedUpdate))
                return handler.getAnswer(user, classifiedUpdate);
        }
        return null;
    }
}

Теперь нам нужна ещё прослойка в виде ClassifiedUpdateHandler'a. Там мы будем доставать пользователя из базы данных и может что-то ещё. Просто добавим его.
Класс ClassifiedUpdateHandler:

@Service
public class ClassifiedUpdateHandler {

    private final UserService userService;

    private final HandlersMap commandMap;

    public ClassifiedUpdateHandler(UserService userService, HandlersMap commandMap) {
        this.userService = userService;
        this.commandMap = commandMap;
    }

    public Answer request(ClassifiedUpdate classifiedUpdate) {
        return commandMap.execute(classifiedUpdate,
                userService.findUserByUpdate(classifiedUpdate));
    }
}

Тут ничего особенного, пропустим объяснения. Намного интереснее в классе UserService.
До этого, благо, мы успели всё обработать и на 100% достать id пользователя и его имя.

@Service
public class UserService {

    private final UserRepository userRepository;

    private final StateRepository stateRepository;

    public UserService(UserRepository userRepository, StateRepository stateRepository) {
        this.userRepository = userRepository;
        this.stateRepository = stateRepository;
    }

    public User findUserByUpdate(ClassifiedUpdate classifiedUpdate) {

      // Проверим, существует ли этот пользователь.
        if(userRepository.findByChatId(classifiedUpdate.getUserId()) != null) {
            User user = userRepository.findByChatId(classifiedUpdate.getUserId());

          // Если мы не смогли до этого записать имя пользователя, то запишем его.
            if(user.getUserName() == null && classifiedUpdate.getUserName() != null) 
              user.setUserName(classifiedUpdate.getUserName());

          // Проверим менял ли пользователя имя.
            if(user.getUserName() != null)
                if (!user.getUserName().equals(classifiedUpdate.getUserName())) 
                  user.setUserName(classifiedUpdate.getUserName());

            if(!user.getName().equals(classifiedUpdate.getName())) 
              user.setName(classifiedUpdate.getName());

            return user;
        }
        try {
            User user = new User();
            user.setName(classifiedUpdate.getName());
            user.setPermissions(0L);
            user.setChatId(classifiedUpdate.getUserId());
            user.setUserName(classifiedUpdate.getUserName());

            State state = new State();
            state.setStateValue(null);
            state.setUser(user);

            stateRepository.save(state);

            user.setState(state);
            userRepository.save(user);

            return user;
        } catch (Exception e) {
            e.printStackTrace();
        }

        return null;
    }
  }

Всё готово, теперь пора создать наш первый Handler и Command для примера. Но для начала напишем Builder для сообщений.

public class SendMessageBuilder {
    private SendMessage sendMessage;

    public SendMessageBuilder() {
        this.sendMessage = new SendMessage();
    }

    public SendMessageBuilder chatId(Long chatId) {
        this.sendMessage.setChatId(chatId);
        return this;
    }

    public SendMessageBuilder message(String message) {
        this.sendMessage.setText(message);
        return this;
    }

    public Answer build() throws Exception {
        if(sendMessage.getChatId() == null)
            throw new Exception("Id must be not null");

        Answer answer = new Answer();
        answer.setBotApiMethod(sendMessage);

        return answer;
    }
}

Вот теперь можем написать Handler и Command.

@Component
public class CommandHandler extends AbstractHandler {

    private HashMap<Object, Command> hashMap = new HashMap<>();

    @Override
    protected HashMap<Object, Command> createMap() {
        return hashMap;
    }

    @Override
    public TelegramType getHandleType() {
        return TelegramType.Command;
    }

    @Override
    public int priority() {
        return 1;
    }

    @Override
    public boolean condition(User user, ClassifiedUpdate update) {
        return hashMap.containsKey(update.getCommandName());
    }

    @Override
    public Answer getAnswer(User user, ClassifiedUpdate update) {
        return hashMap.get(update.getCommandName()).getAnswer(update, user);
    }
}
@Component
public class StartCommand implements Command {
    @Override
    public Class handler() {
        return CommandHandler.class;
    }

    @Override
    public Object getFindBy() {
        return "/start";
    }

    @SneakyThrows
    @Override
    public Answer getAnswer(ClassifiedUpdate update, User user) {
        return new SendMessageBuilder().chatId(user.getChatId()).message("Hello!").build();
    }
}

Конец

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

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

В итоге должен получиться простой и расширяемый бот.

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

Спасибо за внимание!

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


  1. mrfloony
    17.05.2023 09:59

    Очередной гайд по tb на Spring
    Ничего нового


    1. A_S_A_Voods
      17.05.2023 09:59

      Вроде хорошо все расписал


    1. h3llsize Автор
      17.05.2023 09:59

      Когда-то увлекался этим, сейчас просто решил поделиться своими наработками).


  1. APXEOLOG
    17.05.2023 09:59

    Что, опять? Сколько же можно-то...


  1. saboteur_kiev
    17.05.2023 09:59

    Юзаю бота на баш и чувствую себя превосходно


    1. callmemiku
      17.05.2023 09:59

      sigma male mentality


  1. Choonsky
    17.05.2023 09:59

    Не вижу ничего особо ужасного и некрасивого в коде для решения конкретно этой задачи (объединение в единую абстракцию разных видов апдейтов с разными методами).

    Кстати, для обращения к пользователю использую конструкцию Имя "@username" Фамилия - так точно не будет нулл, хоть иногда и получается громоздко (редко).


    1. h3llsize Автор
      17.05.2023 09:59

      Спасибо! Просто считаю, что можно сделать лучше)