Telegram боты на Java и где они обитают

от автора

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

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

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

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

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

Затем добавим зависимости, многие пользуются 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();     } }

Конец

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

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

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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/735666/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *