Telegram боты на Java и где они обитают
В этом посте хочется разобрать создание ботов в телеграмме, ведь их очень интересно писать (по крайней мере, для новичков).
Для начала нам нужно создать приложение на спринге. Но я думаю, каждый уже умеет это делать.
Затем добавим зависимости, многие пользуются telegrambots-spring-boot-starter, но мне как-то не довелось увидеться с ним, поэтому используем самый обычный API.
org.telegram telegrambots 6.5.0
Теперь создадим файл application.yaml в папке resources. В нём напишем токен бота.
Telegram-bots ещё требует имя, но вводить настоящее — не обязательно.
bot: token: 6098243395:AAFwSeKCFxh6kOTPPfcSYTdTuhqRZyBfULA
Создадим наш первый и основной компонент. В нём мы будем регистрировать бота и обрабатывать сообщения.
@Component public class BotComponent extends TelegramLongPollingBot < // Создаём их объект для регистрации private final TelegramBotsApi telegramBotsApi = new TelegramBotsApi(DefaultBotSession.class); // Достаём токен бота @Value("$") 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 Listargs; // просто поделим текст сообщения, в будущем это поможет @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 handleArgs() < Listlist = 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()
Это выглядит ужасно и некрасиво, обязательно как-то отрефакторим это, но не сегодня.
Хотел бы объяснить, зачем я разделил @username и Имя Фамилия.
Дело в том, что некоторые пользователи не имеют имя и фамилию в настройках профиля, а некоторые имеют только это. В общем, мы предусмотрели этот момент. И теперь если мы захотим написать: Привет, Илья! У нас никогда не будет: Привет, null!. Мы ведь не хотим отставать от глаза бога.
Тем, кому лень писать код, держите TelegramType:
Двигаемся дальше, мы обработали их апдейт и теперь нам пора обработать свой апдейт, но перед этим нам нужно создать ещё свой ответ. Выглядит он не так ужасно, но ужасно 🙂
Это нам очень сильно поможет в будущем, нужно только верить.
@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 BotApiMethodbotApiMethod; >
На самом деле, всё можно сделать и без этого класса, если вы собираетесь отвечать пользователю только сообщениями или коллбэками. Потому что в будущем этот класс ещё и увеличит немного кода. Я лишь стараюсь увеличить расширяемость, чтобы внедрение новой фичи делалось быстро и легко.
Теперь нам как-то нужно работать с пользователями, поэтому с помощью Spring JPA создадим сущность пользователя.
@Entity @Table(name = "users") @Getter @Setter public class User
Как вы можете заметить, у пользователя есть состояние, это поможет нам для проведения интерактивов и т.д. Также я использую у permissions тип Long, потому что обычно это:
Это просто и удобно и лениво, но если кто-то хочет, то может заморочиться.
Вернёмся к состоянию, напишем простую сущность для состояния :
@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 MapallCommands = new HashMap<>(); // Найдём все команды для обработчика @Autowired private List commands; protected abstract HashMap 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> hashMap = new HashMap<>(); private final List handlers; // Тут точно также находим все обработчики, просто в первом случае я использовал // @Autowired. Это немного лучше. public HandlersMap(List 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() < @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 HashMaphashMap = new HashMap<>(); @Override protected HashMap 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(); >>
Я постарался сделать практическое пособие. Тут нужно много чего дорабатывать.
Код я писал очень давно, поэтому что-то возможно уже нужно обновить, просто решил опубликовать свои наработки в открытый доступ.
В итоге должен получиться простой и расширяемый бот.
Если эта статья вам понравиться, то можно всё допилить и получить невероятно мощную штуку для написания телеграмм ботов, к примеру, выкатить свои аннотации и т.д.
Спасибо за внимание!
Кнопки у телеграм-бота
Начал понемногу осваивать ботов в телеграме. Знаю немного Java. Сейчас работает бот, который через switch проходит по нужным ответам и выдает то, что ему задано. Добавил 4 кнопки: команда 1, команда 2, команда 3 и команда 4. Они выполняют свою функцию. При любом ответе эти поля остаются, а хотелось бы, чтобы они менялись. Допустим, пользователь выбрал кнопку «Команда 1», а ему в ответ предложили 2 варианта вопроса, например «команда 1-1», «команда 1-2», команда «3-1» etc. Код:
import org.omg.CORBA.PUBLIC_MEMBER; import org.telegram.telegrambots.api.objects.replykeyboard.ReplyKeyboardMarkup; import org.telegram.telegrambots.api.objects.replykeyboard.buttons.KeyboardRow; import org.telegram.telegrambots.exceptions.TelegramApiException; import org.telegram.telegrambots.ApiContextInitializer; import org.telegram.telegrambots.TelegramBotsApi; import org.telegram.telegrambots.api.methods.send.SendMessage; import org.telegram.telegrambots.api.objects.Message; import org.telegram.telegrambots.api.objects.Update; import org.telegram.telegrambots.bots.TelegramLongPollingBot; import java.security.Key; import java.util.ArrayList; import java.util.List; public class SimpleBot extends TelegramLongPollingBot < public static void main(String[] args) < ApiContextInitializer.init(); TelegramBotsApi telegramBotsApi = new TelegramBotsApi(); try < telegramBotsApi.registerBot(new SimpleBot()); >catch (TelegramApiException e) < e.printStackTrace(); >> @Override public String getBotUsername() < return "botname"; >@Override public String getBotToken() < return "bottoken"; >public void onUpdateReceived(Update update) < Message message = update.getMessage(); if (message != null && message.hasText()) < switch (message.getText()) < case "/start": sendMsg(message, "Это команда старт!"); System.out.println(message.getText()); break; case "Команда 1": sendMsg(message, "Это команда 1"); System.out.println(message.getText()); break; case "Команда 2": sendMsg(message, "Это команда 2"); System.out.println(message.getText()); break; default: sendMsg(message, "Это дефолт! Брейк!"); System.out.println(message.getText()); break; >> > public void sendMsg (Message message, String text) < SendMessage sendMessage = new SendMessage(); sendMessage.enableMarkdown(true); // Создаем клавиатуру ReplyKeyboardMarkup replyKeyboardMarkup = new ReplyKeyboardMarkup(); sendMessage.setReplyMarkup(replyKeyboardMarkup); replyKeyboardMarkup.setSelective(true); replyKeyboardMarkup.setResizeKeyboard(true); replyKeyboardMarkup.setOneTimeKeyboard(false); // Создаем список строк клавиатуры Listkeyboard = new ArrayList<>(); // Первая строчка клавиатуры KeyboardRow keyboardFirstRow = new KeyboardRow(); // Добавляем кнопки в первую строчку клавиатуры keyboardFirstRow.add("Команда 1"); keyboardFirstRow.add("Команда 2"); // Вторая строчка клавиатуры KeyboardRow keyboardSecondRow = new KeyboardRow(); // Добавляем кнопки во вторую строчку клавиатуры keyboardSecondRow.add("Команда 3"); keyboardSecondRow.add("Команда 4"); // Добавляем все строчки клавиатуры в список keyboard.add(keyboardFirstRow); keyboard.add(keyboardSecondRow); // и устанавливаем этот список нашей клавиатуре replyKeyboardMarkup.setKeyboard(keyboard); sendMessage.setChatId(message.getChatId().toString()); sendMessage.setReplyToMessageId(message.getMessageId()); sendMessage.setText(text); try < execute(sendMessage); >catch (TelegramApiException e) < e.printStackTrace(); >> >
Telegram Ability Bot: бот, умеющий вести диалог: Часть 2
ЧАСТЬ 1 Чтобы позже не запутаться в частях программы, я стараюсь всю логику разделять на отдельные классы. Собственно фразы, которыми будет отвечать бот, будут хранится в интерфейсе Constants . Создадим там строку:
String START_DESCRIPTION = "Hello";
public Ability replyToStart() < return Ability .builder() .name("start") .info(Constants.START_DESCRIPTION) .locality(ALL) .privacy(PUBLIC) .action(ctx ->silent.send("Hello!", ctx.chatId())) .build(); >
Теперь бот может приветствовать его клиентов в ответ на стандартную команду /start . Попробуйте запустить его: бот уже немного живой! Но, как и любому монстру Франкештейна, ему не хватает парочки конечностей.
Использование встроенной клавиатуры
Чтобы бот смог построить с нами диалог, нам понадобятся еще два класса: MessageFactory и KeyboardFactory . Первый будет считывать ответы людей и генерировать сообщения, а второй — создавать кнопки с ответами.
public class KeyboardFactory < public static ReplyKeyboard startButtons() < InlineKeyboardMarkup inlineKeyboard = new InlineKeyboardMarkup(); List> rowsInline = new ArrayList<>(); ListrowInline = new ArrayList<>(); rowInline.add(new InlineKeyboardButton().setText("DISCUSSION").setCallbackData(Constants.DISCUSSION)); rowInline.add(new InlineKeyboardButton().setText("SMALL TALK").setCallbackData(Constants.SMALL_TALK)); rowsInline.add(rowInline); inlineKeyboard.setKeyboard(rowsInline); return inlineKeyboard; > > String START_REPLY = "Start using the telegram bot if you are lonely or bored"; String CHOOSE_OPTION = "Make a choice"; String DISCUSSION = "Let's discuss!"; String SMALL_TALK = "Let's talk!";
Теперь мы можем просто вызвать статический метод нашей фабрики, чтобы использовать встроенную клавиатуру. Самая важная часть кода — это setCallbackData() . Она распознает, какая кнопка была нажата пользователем. Переходим в MessageFactory :
public class MessageFactory < private final MessageSender sender; //используется для отправки сообщений обратно пользователю public MessageFactory(MessageSender sender) < this.sender = sender; >public void start (long chatId) < try < sender.execute(new SendMessage() .setText(Constants.START_REPLY) .setChatId(chatId)); sender.execute(new SendMessage() .setText(Constants.CHOOSE_OPTION) .setChatId(chatId) .setReplyMarkup(KeyboardFactory.startButtons())); >catch (TelegramApiException e) < e.printStackTrace(); >> >
public Ability replyToStart() < return Ability .builder() .name("start") .info(Constants.START_DESCRIPTION) .locality(ALL) .privacy(PUBLIC) .action(ctx ->messageFactory.start(ctx.chatId())) .build(); >
private TelegramBot(String botToken, String botUsername)Попробуйте перезапустить ваш бот. Теперь он предложит встроенную клавиатуру в ответ на ваши действия. Полезные лайфхаки для новичков: Если вы пользуетесь Идеей и хотите посмотреть документацию по классу, то выделите класс или метод и нажмите Ctrl+J на Mac или Ctrl+Q на Windows. Также можно сделать правой кнопкой мыши-> Go to-> Declaration of usages. Таким образом, к примеру, можно узнать, что наш AbilityBot на самом деле наследуется от стандартного TelegramLongPollingBot. Только в нем используются лямбды, что существенно сокращает код. В следующей (финальной) части будет развитие диалога и деплой на Heroku.