H Разработка telegram бота с использованием Spring из песочницы

Пишете телеграмм ботов? Ваша производительность разработки желает лучшего? Ищете чего-то нового? Тогда прошу под кат.



Идея заключается в следующем: слямзить архитектуру spring mvc и перенести на telegram api.
Выглядеть должно как-то так:


@BotController
public class SimpleOkayController {
    @BotRequestMapping(value = "/ok")
    public SendMessage ok(Update update) {
        return new SendMessage()
                .setChatId(update.getMessage().getChatId())
                .setText("okay bro, okay!");
    }
}

или


Пример с бинами
@BotController
public class StartController {
    @Autowired
    private Filter shopMenu;

    @Autowired
    private PayTokenService payTokenService;

    @Autowired
    private ItemService itemService;

    @BotRequestMapping("/shop")
    public SendMessage generateInitMenu(Update update) {
            return  new SendMessage()
                    .setChatId(update.getMessage().getChatId().toString())
                    .setText("Товары моего магазинчика!")
                    .setReplyMarkup(shopMenu.getSubMenu(0L, 4L, 1L)); // <--
    }

    @BotRequestMapping(value = "/buyItem", method = BotRequestMethod.EDIT)
    public List<BotApiMethod> bayItem(Update update) {
        ....................
        Item item = itemService.findById(id); // <--

        return Arrays.asList(new EditMessageText()
                .setChatId(update.getMessage().getChatId())
                .setMessageId(update.getMessage().getMessageId())
                .setText("Подтвердите ваш выбор, в форме ниже"),

                new SendInvoice()
                        .setChatId(Integer.parseInt(update.getMessage().getChatId().toString()))
                        .setDescription(item.getDescription())
                        .setTitle(item.getName())
                        .setProviderToken(payTokenService.getPayToken())
                        ........................
                        .setPrices(item.getPrice())
        );
    }

}

Это даёт следующие преимущества:


  • Не надо писать кастомную логику для выбора обработчика сообщения от пользователя
  • Возможность инжектить разлиные бины в наш @BotController
  • Как следствие из предыдущих двух пунктов — существенное сокращение объемов кода
  • Потенциально (хотя я этого еще не сделал) аргументы кастомного метода обработчика, могут быть выражены в виде тех аргументов, которые действительно нужны!
  • Возможность создавать серьезные энтерпрайз решения, используя spring

Давайте теперь посмотрим как это можно завести в нашем проекте


Аннотации
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Component
public @interface BotController {
    String[] value() default {};
}

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface BotRequestMapping {
    String[] value() default {};
    BotRequestMethod[] method() default {BotRequestMethod.MSG};
}

Создаем свой контейнер обработчиков в виде обычной HashMap


Контейнер
public class BotApiMethodContainer {
    private static final Logger LOGGER = Logger.getLogger(BotApiMethodContainer.class);

    private Map<String, BotApiMethodController> controllerMap;

    public static BotApiMethodContainer getInstanse() {
        return Holder.INST;
    }

    public void addBotController(String path, BotApiMethodController controller) {
        if(controllerMap.containsKey(path)) throw new BotApiMethodContainerException("path " + path + " already add");
        LOGGER.trace("add telegram bot controller for path: " +  path);
        controllerMap.put(path, controller);
    }

    public BotApiMethodController getBotApiMethodController(String path) {
        return controllerMap.get(path);
    }

    private BotApiMethodContainer() {
        controllerMap = new HashMap<>();
    }

    private static class Holder{
        final static BotApiMethodContainer INST = new BotApiMethodContainer();
    }
}

В контейнере будем хранить контроллеры обертки (для пары @BotController и @BotRequestMapping)


Контроллер обертка
public abstract class BotApiMethodController {
    private static final Logger LOGGER = Logger.getLogger(BotApiMethodController.class);

    private Object bean;
    private Method method;
    private Process processUpdate;

    public BotApiMethodController(Object bean, Method method) {
        this.bean = bean;
        this.method = method;

        processUpdate = typeListReturnDetect() ? this::processList : this::processSingle;
    }

    public abstract boolean successUpdatePredicate(Update update);

    public List<BotApiMethod> process(Update update) {
        if(!successUpdatePredicate(update)) return null;

        try {
            return processUpdate.accept(update);
        } catch (IllegalAccessException | InvocationTargetException e) {
            LOGGER.error("bad invoke method", e);
        }

        return null;
    }

    boolean typeListReturnDetect() {
        return List.class.equals(method.getReturnType());
    }

    private List<BotApiMethod> processSingle(Update update) throws InvocationTargetException, IllegalAccessException {
        BotApiMethod botApiMethod = (BotApiMethod) method.invoke(bean, update);
        return botApiMethod != null ? Collections.singletonList(botApiMethod) : new ArrayList<>(0);
    }

    private List<BotApiMethod> processList(Update update) throws InvocationTargetException, IllegalAccessException {
        List<BotApiMethod> botApiMethods = (List<BotApiMethod>) method.invoke(bean, update);
        return botApiMethods != null ? botApiMethods : new ArrayList<>(0);
    }

    private interface Process{
        List<BotApiMethod> accept(Update update) throws InvocationTargetException, IllegalAccessException;
    }
}

Теперь когда у нас есть данная кодовая база возникает вопрос: как Spring заставить автоматически наполнять контейнер, чтобы мы могли им пользоваться?


Для этого реализуем специальный бин — BeanPostProcessor. Это дает возможность отлавливать бины во время их инициализации. Наши контроллеры имеют scope по умолчанию — синглтон, значит инициализироваться они будут со стартом контекста!


TelegramUpdateHandlerBeanPostProcessor
@Component
public class TelegramUpdateHandlerBeanPostProcessor implements BeanPostProcessor, Ordered {
    private static final Logger LOGGER = Logger.getLogger(TelegramUpdateHandlerBeanPostProcessor.class);

    private BotApiMethodContainer container = BotApiMethodContainer.getInstanse();
    private Map<String, Class> botControllerMap = new HashMap<>();

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        Class<?> beanClass = bean.getClass();
        if (beanClass.isAnnotationPresent(BotController.class))
            botControllerMap.put(beanName, beanClass);
        return bean;
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if(!botControllerMap.containsKey(beanName)) return bean;

        Object original = botControllerMap.get(beanName); 
        Arrays.stream(original.getClass().getMethods())
                .filter(method -> method.isAnnotationPresent(BotRequestMapping.class))
                .forEach((Method method) -> generateController(bean, method));
        return bean;
    }

    private void generateController(Object bean, Method method) {
        BotController botController = bean.getClass().getAnnotation(BotController.class);
        BotRequestMapping botRequestMapping = method.getAnnotation(BotRequestMapping.class);

        String path = (botController.value().length != 0 ? botController.value()[0] : "")
                    + (botRequestMapping.value().length != 0 ? botRequestMapping.value()[0] : "");

        BotApiMethodController controller = null;

        switch (botRequestMapping.method()[0]){
            case MSG:
                controller = createControllerUpdate2ApiMethod(bean, method);
                break;
            case EDIT:
                controller = createProcessListForController(bean, method);
                break;
            default:
                break;
        }

        if (controller != null) {
            container.addBotController(path, controller);
        }
    }

    private BotApiMethodController createControllerUpdate2ApiMethod(Object bean, Method method){
        return new BotApiMethodController(bean, method) {
            @Override
            public boolean successUpdatePredicate(Update update) {
                return update!=null && update.hasMessage() && update.getMessage().hasText();
            }
        };
    }

    private BotApiMethodController createProcessListForController(Object bean, Method method){
        return new BotApiMethodController(bean, method) {
            @Override
            public boolean successUpdatePredicate(Update update) {
                return update!=null && update.hasCallbackQuery() && update.getCallbackQuery().getData() != null;
            }
        };
    }

    @Override
    public int getOrder() {
        return 100;
    }
}

Инициализируем контекст, в котором прописаны все наши бины и — вуаля! Подбирать обработчики для сообщений можно, например, так:


Выбор обработчика
public class SelectHandle {
    private static BotApiMethodContainer container = BotApiMethodContainer.getInstanse();

    public static BotApiMethodController getHandle(Update update) {
        String path;
        BotApiMethodController controller = null;

        if (update.hasMessage() && update.getMessage().hasText()) {
            path = update.getMessage().getText().split(" ")[0].trim();
            controller = container.getControllerMap().get(path);
            if (controller == null) controller = container.getControllerMap().get("");

        } else if (update.hasCallbackQuery()) {
            path = update.getCallbackQuery().getData().split("/")[1].trim();
            controller = container.getControllerMap().get(path);
        }

        return controller != null ? controller : new FakeBotApiMethodController();
    }
}

Постскриптум
Telegram развивается очень стремительно. Используя ботов мы можем организовывать свои магазины, давать команды различным своим интернет-вещам, организовывать блог-каналы и многое многое другое. А самое главное, что всё это в едином приложении!


Ссылки:


+15
~11200

комментарии (17)

+7
+8 –1
ExplosiveZ ,  
Чувак, ну ты вообще бешеный.
+3
vershinin ,  
А чем плохо-то? На спринге так и пишут. Многие, правда, из спринга знают только MVC-шные аннотации да DI, но чтобы не лезть в дикую императивщину, кто-то должен сделать за вас этот FizzBuzz.
–14
+1 –15
Noiwex ,  

Зачем это на Java писать? Есть гораздо лучшие средства для этого, например, Node.js.

+2
Borz ,  

точно лучше? если проект на java

0
+1 –1
alex4321 ,   * (был изменён)
Я, конечно, больше по питону, но уж лучше жаба, чем жабоскрипт :-)

з.ы. а если серьёзно — какие плюсы тут даст нода, которые не дадут другие средства? Единственное, что приходит в голову — асинхронщина, так её куда только не завезли
0
+1 –1
TimurJD ,  
Лучше на Node.js?! Интересно чем же лучше?
+2
sergpank ,  
Чем Java, естественно ))

привет от Армянского радио
0
TimurJD ,  
Тонко)
0
scawn ,  
Смысл от спринга, если все равно создаем сами синглтон?
+1
PqDn ,  
Контейнер, который является синглтоном, можно скрыть от конечной целевой разработки
+2
nebachadnezzair ,  
Сомнительное решение.
1. В методе TelegramUpdateHandlerBeanPostProcessor.postProcessAfterInitialization аргумент bean — это прокся, у которой на методах уже нет никаких аннотаций.
Решение: заменить обращения к bean на botControllerMap.get(beanName)
2. TelegramUpdateHandlerBeanPostProcessor вызывается в середине процесса настройки бинов. Сохраняемый «контроллер» не факт, что полностью настроен.
Решение: наполнять контейнер только после инициализации всего спринга (например, через ApplicationListener). Или сохранять не конкретный «контроллер», а его beanName, чтобы в момент обработки брать настроенный «контроллер» из контекста спринга.
3. Если «контроллер» будет иметь скоуп отличный от синглтона, то это решение тоже работать не будет, так как его создание будет происходить не при старте спринга, а когда-то.
Решение: наполнять контейнер на этапе BeanFactoryPostProcessor, сохраняя тройки path-beanName-method
0
PqDn ,   * (был изменён)
1) Спасибо за замечание, поправлю. Хотя в данном примере АОП не используется и соответственно бины в прокси не оборачиваются
2) На самом деле для нашего случая в каком месте будут обрабатываться наши бины неважно.
Также я специально реализовал интерфейс Ordered, в методе которого я указал низкий приоритет, чтобы TelegramUpdateHandlerBeanPostProcessor обрабатывал бины в конце.
3) Наверно для данной целевой области не имеет смысла. Ну а решить эту таску всегда можно при необходимости.
+1
eugenehr ,  
Очень даже Spring- и Java- way. Я бы в дополнение к этому добавил еще новый Spring Scope для привязки бинов к пользователям и поддержки stateful диалогов.

SpringConfig.java
public class SpringConfig implements BeanFactoryPostProcessor {

    @Bean
    public UserScope userScope(ConfigurableListableBeanFactory beanFactory) {
        return new UserScope(beanFactory);
    }

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
        final UserScope userScope = beanFactory.getBean(UserScope.class);
        beanFactory.registerScope(UserScope.SCOPE, userScope);
    }
}



UserScope.java
public class UserScope implements Scope {

    public static final String SCOPE = "user";
    private static final Logger logger = LoggerFactory.getLogger(UserScope.class);
    private static final ThreadLocal<User> USER = new ThreadLocal<>();

    private final Object lock = new Object();
    private final ConfigurableListableBeanFactory beanFactory;
    private final Cache<String, Map<String, Object>> conversations;

    public UserScope(ConfigurableListableBeanFactory beanFactory) {
        this.beanFactory = beanFactory;
        // По истечению 1 часа пользовательские бины удаляются
        conversations = CacheBuilder.newBuilder()
                .expireAfterAccess(1, TimeUnit.HOURS)
                .removalListener(notification -> {
                    if (notification.wasEvicted()) {
                        Map<String, Object> userScope = (Map<String, Object>) notification.getValue();
                        userScope.values().forEach(this::removeBean);
                    }
                })
                .build();
    }

    public static User getUser() {
        return USER.get();
    }

    public static void setUser(User user) {
        USER.set(user);
    }

    @Override
    public Object get(String name, ObjectFactory<?> objectFactory) {
        final String userId = getConversationId();
        if (userId != null) {
            final String userName = MessageUtils.getSenderName(getUser());
            Map<String, Object> beans = conversations.getIfPresent(userId);
            if (beans == null) {
                synchronized (lock) {
                    beans = conversations.getIfPresent(userId);
                    if (beans == null) {
                        beans = new ConcurrentHashMap<>();
                        conversations.put(userId, beans);
                        logger.debug("Bean storage for user '{}' is initialized", userName);
                    }
                }
            }
            Object bean = beans.get(name);
            if (bean == null) {
                bean = objectFactory.getObject();
                beans.put(name, bean);
                logger.debug("Bean {} is created for user '{}'", bean, userName);
            }
            return bean;
        }
        //return null;
        throw new RuntimeException("There is no current user");
    }

    @Override
    public Object remove(String name) {
        final String userId = getConversationId();
        if (userId != null) {
            final Map<String, Object> userBeans = conversations.getIfPresent(userId);
            if (userBeans != null) {
                return userBeans.remove(name);
            }
        }
        return null;
    }

    @Override
    public void registerDestructionCallback(String name, Runnable callback) {

    }

    @Override
    public Object resolveContextualObject(String key) {
        return null;
    }

    @Override
    public String getConversationId() {
        final User user = getUser();
        return user == null ? null : user.getId().toString();
    }

    public void removeConversation() {
        final String userId = getConversationId();
        if (userId != null) {
            final String userName = MessageUtils.getSenderName(getUser());
            final Map<String, Object> beans = conversations.getIfPresent(userId);
            if (beans != null && !beans.isEmpty()) {
                beans.values().forEach(this::removeBean);
                synchronized (lock) {
                    conversations.invalidate(userId);
                    logger.debug("Bean storage for user '{}' is invalidated", userName);
                }
            }
        }
    }

    private void removeBean(Object bean) {
        try {
            beanFactory.destroyBean(bean);
        } catch (Exception ex) {
            logger.error("An error has occurred during destroying bean {}", bean, ex);
        }
    }
}



Т.е. в бинах с аннотацией Scope(UserScope.SCOPE) можно смело хранить состояние диалога с одним, конкретным пользователем в течение небольшого времени
0
Virviil ,  

Я не пишу на Java, но TelegramUpdateHandlerBeanPostProcessor это шутка такая?
Это набирать вообще реально? Или в Java это так и надо делать...

0
PqDn ,  
К сожалению путь к простому лежит через терни сложного
0
Borz ,  

в нормальных IDE достаточно писать только заглавные буквы, чтобы тебе IDE предложила подставить полное название, поэтому нет заморочек на "надо имя класса покороче"

0
ruslanys ,  
Меня единственного беспокоит вопрос сценариев? Каждый раз открываю статью про Telegram API и надеюсь, что в ней хоть капельку затронут систему сценариев.

На мой взгляд, бот, который получает одну команду и по ней сразу формирует ответ — это чрезмерно тривиальная задача. И пусть та же задача обернута здесь в зеленую упаковку Spring MVC, тем не менее, задача остается тривиальной, а бот примитивным.

Вопрос открыт: Как спроектировать гибкую систему сценариев?
Так, чтобы клиент выбрал сценарий (а программист без труда его составил), и ему предоставлялось целое дерево всевозможных вопросов, каждый из которых зависит от предыдущего и обладает полным контекстом ответов.
А в итоге, например, вызывается финальный метод, который программист должен переопределить, в котором будет получен финальный результат — ответы на все вопросы сценария.

У меня есть идеи на этот счет. Например, использование DSL от Groovy или Kotlin для описания сценариев, но вопрос все еще открыт.
Быть может, когда-нибудь закончу, да напишу статью.
А пока — было бы здорово выслушать мысли людей, подискутировать по действительно проблемной теме.