В этом посте хочется разобрать создание ботов в телеграмме, ведь их очень интересно писать (по крайней мере, для новичков).
Мы попробуем :
написать расширяемого бота
использовать спринг
Для начала нам нужно создать приложение на спринге. Но я думаю, каждый уже умеет это делать.
Затем добавим зависимости, многие пользуются 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(); } }
Конец
Я постарался сделать практическое пособие. Тут нужно много чего дорабатывать.
Код я писал очень давно, поэтому что-то возможно уже нужно обновить, просто решил опубликовать свои наработки в открытый доступ.
В итоге должен получиться простой и расширяемый бот.
Если эта статья вам понравиться, то можно всё допилить и получить невероятно мощную штуку для написания телеграмм ботов, к примеру, выкатить свои аннотации и т.д.
Спасибо за внимание!
