В этом посте хочется разобрать создание ботов в телеграмме, ведь их очень интересно писать (по крайней мере, для новичков).
Мы попробуем :
написать расширяемого бота
использовать спринг
Для начала нам нужно создать приложение на спринге. Но я думаю, каждый уже умеет это делать.
Затем добавим зависимости, многие пользуются 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)
Choonsky
17.05.2023 09:59Не вижу ничего особо ужасного и некрасивого в коде для решения конкретно этой задачи (объединение в единую абстракцию разных видов апдейтов с разными методами).
Кстати, для обращения к пользователю использую конструкцию Имя "@username" Фамилия - так точно не будет нулл, хоть иногда и получается громоздко (редко).
mrfloony
Очередной гайд по tb на Spring
Ничего нового
A_S_A_Voods
Вроде хорошо все расписал
h3llsize Автор
Когда-то увлекался этим, сейчас просто решил поделиться своими наработками).