На дворе 2023 год. На хабре до сих пор пишут статьи по созданию телеграмм ботов, скажите вы, но не спешите с выводами. Во-первых, пожалеем студентов, во многих вузах на программистских направлениях до сих пор заставляют писать ботов хотя бы раз в течение обучения. И многие гайды в интернете немножко outdated в плане деплоя (хероку умер, доздравствует... но об этом немного позже). Ну и сам бот у нас будет также не скучный и не самый простой, какие часто в гайдах, а с полноценной привязкой к базе данных, т.е. полноценное Spring приложение.

Ну так о чем же бот и как мне пришла в голову такая идея? Обойдя 2 города вдоль и поперек, я заметил одну неприятную тендцению: множество уборных сейчас на пин-кодах. А сами коды доступны по чеку. И иногда бывает такое, что прямо надо, но покупать ничего не хочется, спрашивать просто так как-то тоже не удобно, чеки ходить искать в том числе. Что же делать? Именно по этой причине я решил создать бота, в котором будут храниться и обновляться пин-коды к подобным заведениям. Ведь какая цель у подобных кодов вообще - это защитить туалет от потенциальных вредителей. Но думаю те, кто сумеют воспользоваться телеграммом и найти бота, такими не являются, поэтому надеюсь никаких законов такой бот не нарушает, все-таки цель - просто помощь людям.

И так, с идеей понятно, с чего же начнем? Начнем, как всегда, со Spring initializr. Выбираем maven, spring версию можно любую, но я оставил ту, что по дефолту, вписываем нормальную группу и артефакт id, название и описание (если хотите потом это куда-то загрузить в открытый доступ, читайте мою стать по оформлению пет-проектов), выбираем jar packaging (нам еще это понадбоится), java по желанию, я выбрал 17, и добавляем 3 зависимости: Spring Data Jpa (мы будем использовать БД), Lombok (пригодится) и Spring Web (без него не запускалось приложение на Tomcat сервере), и liquibase (миграции, еще пригодятся). Также можно было бы прямо здесь добавить драйвер к нашей базе данных, а именно postgres, но вдруг вы захотите использовать что-то другое, поэтому оставим пока так и добавим вручную в pom (нет, это конечно же не потому, что я уже сделал скриншот и мне было лень переделывать).

Далее, как я и сказал, добавим драйвер в наш pom файлик и зависимость от нужной нам библиотеки, для работы с API телеграмма:

        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.telegram</groupId>
            <artifactId>telegrambots</artifactId>
            <version>6.8.0</version>
        </dependency>

Для удобства разработки сразу сделаем compose файл с нашей БД:

services:
  postgres:
    image: 'postgres:14-alpine'
    container_name: postgres
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: postgres
    volumes:
      - ./imports/init.sql:/docker-entrypoint-initdb.d/init.sql

Здесь мы в файле imports/init.sql мы сразу иницируем нужную нам схему, чтобы не делать это вручную перед каждым запуском (т.к. в liquibase почему-то создание схемы не срабатывает и просто в миграцию положить это не удается): CREATE SCHEMA IF NOT EXISTS pins. Далее настраиваем файл application.yml (рекомендую переключиться на yaml с properties):

spring:
  datasource:
    url: ${POSTGRES_JDBC_URL:jdbc:postgresql://localhost:5432/postgres}
    username: ${POSTGRES_USERNAME:postgres}
    password: ${POSTGRES_PASSWORD:postgres}
    driver-class-name: org.postgresql.Driver
    hikari:
      maximum-pool-size: 10
  jpa:
    hibernate:
      ddl-auto: validate
    properties:
      hibernate.default_schema: ${POSTGRES_SCHEMA:pins}
  liquibase:
    default-schema: ${POSTGRES_SCHEMA:pins}
    change-log: classpath:/${POSTGRES_SCHEMA:pins}/master.yml

bot:
  name: "PinCityBot"
  token: {$BOT_TOKEN:}

В общем-то, много каких-то не интересных нам системных настроек, которые я опустил (ссылку на гит оставлю в конце туториала), расскажу подробнее о важных моментах: то, что в виде ${name:value}- это environment переменные, и они нам пригодятся, т.к. для деплоя мы их будем менять, а через двоеточие указано дефолтное значение, а именно до нашего докер постгреса, для легкого локального запуска. Hikari - это connection pool к базе данных. Настройки liquibase сделаны таким образом, что в папке ресурсов, в папке с названием нашей схемы, будут лежать миграции. Настройки с префиксом bot - это настройки для телеграмма, обратите внимание, что здесь также используется env переменная. ddl-auto: validate указывает, что hibernate будет валидировать схему бд в сравнении с нашими entity и выдавать ошибку, если что-то сходится.

Итак, соответственно дальше нам понадобится схема БД, поэтому сразу проектируем ее. Примерный план такой, что у нас будет список поддерживаемых городов (чтобы контролировать и оставлять только актуальные и те, где можно проверить, что, например, не добавляются коды к подъездам и т.д., а только действительно заведения). Во-вторых, нужно сохранять какой город выбрал пользователь, для логики. В-третьих, нужен список мест, связанный с городами, в котором будет храниться адрес, тип заведения, его короткое название, пин-код и кем он был обновлен в последний раз (важное поле, чтобы отслеживать тех, кто будет обновлять на неправильные всегда). И для сохранения статуса переписки также понадобится доп. таблица с состоянием переписки (но об этом позже). Итого получается примерно такая схема:

Не обращаем внимания на _databasechangelog, это служебная таблица liquibase
Не обращаем внимания на _databasechangelog, это служебная таблица liquibase

Пишем миграции ко всему этому делу. Полностью приводить не буду, вот просто пример одной миграции, они достаточно простые:

<changeSet id="0.0.1-1" author="kat">
        <createTable tableName="cities">
            <column name="id" type="int">
                <constraints primaryKey="true" nullable="false"/>
            </column>
            <column name="name" type="varchar">
                <constraints nullable="false"/>
            </column>
        </createTable>
</changeSet>

Пишем основной класс. Реализовывать будем LongPolling схему общения бота. Потому что вебхуки локально неудобно тестировать, да и они нам ни к чему при такой схеме общения (а при деплоее скорее всего тем более был бы геморрой).

@Component
@RequiredArgsConstructor
@Slf4j
public class TelegramBot extends TelegramLongPollingBot {

    public final BotProperties botProperties;

    public final CommandsHandler commandsHandler;

    public final CallbacksHandler callbacksHandler;

    @Override
    public String getBotUsername() {
        return botProperties.getName();
    }

    @Override
    public String getBotToken() {
        return botProperties.getToken();
    }

    @Override
    public void onUpdateReceived(Update update) {
        if (update.hasMessage() && update.getMessage().hasText()) {
            String chatId = update.getMessage().getChatId().toString();
            if (update.getMessage().getText().startsWith("/")) {
                sendMessage(commandsHandler.handleCommands(update));
            } else {
                sendMessage(new SendMessage(chatId, Consts.CANT_UNDERSTAND));
            }
        } else if (update.hasCallbackQuery()) {
            sendMessage(callbacksHandler.handleCallbacks(update));
        }
    }

    private void sendMessage(SendMessage sendMessage) {
        try {
            execute(sendMessage);
        } catch (TelegramApiException e) {
            log.error(e.getMessage());
        }
    }
}

Как видите вся схема работы разделена логически на 2 случая: когда пользователь присылает нам команду, и когда сообщение приходит нам коллбэком. Второй случай будет происходить, когда пользователь будет нажимать на клавиатуры, которые мы будем ему посылать в ответ. BotProperties - это то, что хранит токен и имя бота, делается это просто:

@Component
@ConfigurationProperties(prefix = "bot") // тот самый префикс
@Data // lombok
@PropertySource("classpath:application.yml") // наш yaml файлик
public class BotProperties {

    String name;

    String token;

}

CommandsHandler выглядит следующим образом. В нем мы регистрируем список обработчиков по команде. Сначала я хранил их функцией, но затем понял, что легче создать интерфейс и всем обработчикам реализовывать его. В итоге получаем что-то такое:

@Component
@Slf4j
public class CommandsHandler {

    private final Map<String, Command> commands;

    public CommandsHandler(@Autowired StartCommand startCommand,
                           @Autowired PinCommand pinCommand) {
        this.commands = Map.of(
                "/start", startCommand,
                "/pin", pinCommand
        );
    }

    public SendMessage handleCommands(Update update) {
        String messageText = update.getMessage().getText();
        String command = messageText.split(" ")[0];
        long chatId = update.getMessage().getChatId();

        var commandHandler = commands.get(command);
        if (commandHandler != null) {
            return commandHandler.apply(update);
        } else {
            return new SendMessage(String.valueOf(chatId), Consts.UNKNOWN_COMMAND);
        }
    }

}

Про какие-то вспомогательные вещи по типу класса констант, класса BotInit, регистрирующего нашего бота, пакет дтошек, реализацию entity и репозиториев говорить подробно не буду (тем более что кто-то использует "умные" репозитории JPA, кто-то их делает вручную, как я) и json парсере, т.к. этого в достатке в других гайдах и интернетах. Остановлюсь на реализации нашей команды старта:

@RequiredArgsConstructor
@Component
public class StartCommand implements Command {

    private final CityRepository repository;

    @Override
    public SendMessage apply(Update update) {
        long chatId = update.getMessage().getChatId();
        SendMessage sendMessage = new SendMessage();
        sendMessage.setChatId(String.valueOf(chatId));
        sendMessage.setText(Consts.START_MESSAGE);

        List<CityEntity> allCities = repository.findAll();

        addKeyboard(sendMessage, allCities);
        return sendMessage;
    }

    private void addKeyboard(SendMessage sendMessage, List<CityEntity> allCities) {
        InlineKeyboardMarkup inlineKeyboardMarkup = new InlineKeyboardMarkup();
        List<InlineKeyboardButton> keyboardButtonsRow = new ArrayList<>();
        for (var city : allCities) {
            InlineKeyboardButton inlineKeyboardButton = new InlineKeyboardButton();
            inlineKeyboardButton.setText(city.getName());
            String jsonCallback = JsonHandler.toJson(List.of(CallbackType.CITY_CHOOSE, city.getId().toString()));
            inlineKeyboardButton.setCallbackData(jsonCallback);
            keyboardButtonsRow.add(inlineKeyboardButton);
        }
        List<List<InlineKeyboardButton>> rowList = new ArrayList<>();
        rowList.add(keyboardButtonsRow);
        inlineKeyboardMarkup.setKeyboard(rowList);
        sendMessage.setReplyMarkup(inlineKeyboardMarkup);
    }

}

Как видите, все достаточно просто, здесь мы ищем список городов из нашей БД и добавляем их в клавиатуру к пользователю, отправляя вместе с каждой клавишей callback с типом CITY_CHOOSE и отправляем id выбранного города в качестве данных. Почему листом? Потому что, к сожалению, размер коллбэка в телеграмме ограничен 64 байтами, поэтому лишние поля нам не нужны. Дальше разберем обработчик коллбэков, в которых и будет происходить основная логика. Вообще-то говоря, если я бы изначально хранил состояние чата в БД, то можно было обойтись и обычными сообщениями, но т.к. я изначально развивал схему с коллбэками, и только затем, когда коллбэками было уже не обойтись, начал сохранять состояние в БД, пришлось оставить такую, смешанную схему. Но думаю в дальнейшем, при усложнении логики бота, можно будет перейти на схему со состояниями в БД. Про места улучшения, над которыми можно будет поработать, скажу в конце. Обработчик коллбэков выглядит следующим образом:

@Component
public class CallbacksHandler {

    private final Map<CallbackType, CallbackHandler> callbacks;

    public CallbacksHandler(@Autowired TypeChooseCallback typeChooseCallback,
                            @Autowired CityChooseCallback cityChooseCallback,
                            @Autowired AddressChooseCallback addressChooseCallback,
                            @Autowired PinReviewCallback pinReviewCallback,
                            @Autowired PinActionCallback pinActionCallback) {
        this.callbacks = Map.of(CallbackType.TYPE_CHOOSE, typeChooseCallback,
                CallbackType.CITY_CHOOSE, cityChooseCallback,
                CallbackType.ADDRESS_CHOOSE, addressChooseCallback,
                CallbackType.PIN_OK, pinReviewCallback,
                CallbackType.PIN_WRONG, pinReviewCallback,
                CallbackType.PIN_ADD, pinActionCallback,
                CallbackType.PIN_DONT_ADD, pinActionCallback
        );
    }

    public SendMessage handleCallbacks(Update update) {
        List<String> list = JsonHandler.toList(update.getCallbackQuery().getData());
        long chatId = update.getCallbackQuery().getMessage().getChatId();

        SendMessage answer;
        if (list.isEmpty()) {
            answer = new SendMessage(String.valueOf(chatId), Consts.ERROR);
        } else {
            Callback callback = Callback.builder().callbackType(CallbackType.valueOf(list.get(0))).data(list.get(1)).build();
            CallbackHandler callbackBiFunction = callbacks.get(callback.getCallbackType());
            answer = callbackBiFunction.apply(callback, update);
        }

        return answer;
    }

}

Как видите, сама логика обработки похожа, имеется мапа, в которой по типу коллбэка сохраняется обработчик, и, как вы видите, обработчиков на этот раз куда больше. Думаю подробно все не буду описывать, приведу скриншот работы бота и тогда сразу станет понятна схема его работы. Если коротко, сначала выбирается город, затем тип заведения, затем конкретное заведение, дальше по следующей логике:

Также возможен случай, когда выдается пин-код, помеченный кем-то неактуальным, и человек может пометить его актуальным, либо неактуальным, и затем предоставить свой (или не предоставить). В общем все в скриншот не влезло, но вы поняли суть. Пример конкретного обработчика коллбэка приведу самый простой, т.к. и так статья длинная получается, и он также нам понадобится дальше, чтобы не приводить код двух разных:

@RequiredArgsConstructor
@Component
public class PinActionCallback implements CallbackHandler {

    private final PlacesRepository placesRepository;

    private final ChatsPinsRepository chatsPinsRepository;

    public SendMessage apply(Callback callback, Update update) {
        long chatId = update.getCallbackQuery().getMessage().getChatId();
        long userId = update.getCallbackQuery().getFrom().getId();
        SendMessage answer = new SendMessage();
        Integer addressId = Integer.valueOf(callback.getData());
        if (callback.getCallbackType() == CallbackType.PIN_DONT_ADD) {
            answer = new SendMessage(String.valueOf(chatId), Consts.PIN_DONT_ADD_BYE);
        } else if (callback.getCallbackType() == CallbackType.PIN_ADD) {
            placesRepository.updateState(PinState.OUTDATED, addressId, userId);
            chatsPinsRepository.merge(new ChatsPinsEntity(chatId, addressId));
            answer = new SendMessage(String.valueOf(chatId), Consts.PIN_ADD_MSG);
        }

        return answer;
    }

}

Как видите, суть здесь примерно такая же, как в обработчике команд, конкретно здесь мы не отправляем клавиатуру уже в ответ с коллбэком, но как это сделать вы видели выше в обработчике команд. Здесь же случай, когда человек выбирает хочет он или нет добавить актуальный пин-код, в случае положительном, мы сохраняем по chat_id id заведения, и просим отправить пин-код в виде команды /pin 1234#. Делается это для того, чтобы мы могли видеть, что это команда, и только затем проверять адрес в бд. Иначе бы пришлось с каждым сообщением проверять статус в БД, что было бы лишней нагрузкой на нее. И затем уже просто сохраняем данный пин, удаляя запись в таблице статусов. Кажется, с точки зрения кода и логики у нас все, напоследок приведу все-таки пример репозитория, почему стоит использовать hibernate напрямую, а не "умные" репозитории JPA:

@Repository
public class PlacesRepository extends BaseRepository<PlacesEntity> {

    public List<AddressDto> getAddressesOfType(PlaceType placeType, Long chatId) {
        return em.createQuery("""
                        select new kg.arzybek.bots.pincity.dto.AddressDto(p.id,p.address, p.name)
                        from PlacesEntity p
                        inner join ChatsCitiesEntity c
                        on p.cityId = c.cityId
                        where p.type =: placeType and c.chatId =: chatId
                        """, AddressDto.class)
                .setParameter("placeType", placeType)
                .setParameter("chatId", chatId)
                .getResultList();
    }
...

Во-первых, да, я наследуюсь от базового репозитория, который также написал сам, в нем пара команд, которые понадобятся в любом репозитории и которые можно написать с дженерик типом: persist и merge. Основным же преимуществом является то, что запросы SQL здесь можно писать напрямую связывая их с Java кодом, и писать их любой сложности, проводить здесь же даже какие-то операции с данными, выбирать что возвращать и т.д. Не уверен, что такое возможно в тех самых "умных" репозиториях, которые интерфейсы, но в любом случае, думаю это не так удобно.

Ну что же, теперь наконец-то перейдем к деплою. Т.к. хероку перестал предоставлять бесплатный тариф, да и не дает зарегистрироваться из России, насколько я помню, пришлось искать другие варианты, удобные нам. И идеальным является сервис fly.io (не реклама). Он предоставляет тариф, в котором у вас будет до 3 ЦП с РАМ до 256 мб и 1гб хранилища. Для бота вполне сойдет. Регистрируемся, дальше устанавливаем flyctl:
curl -L https://fly.io/install.sh | sh (важно: в конце установки он может сказать вам добавить пару переменных в ваш .profile, если скажет - сделайте, иначе не будет запускаться с терминала). Дальше заходим в наш аккаунт fly auth login.

Далее нам понадобится postgres для нашего сервиса. Из compose файлика, к сожалению, это сделать не получится, поэтому воспользуемся утилитой fly. Пишем flyctl postgres create, далее выбираем имя, организацию поставит автоматически персональную, регион выбираем на ваш вкус, конфигурацию выбираем "developement", это то, что входит в бесплатный тариф. Далее он выдаст вам ваши креды к БД. Обязательно сохраните их, они нам еще понадобятся!

$ fly postgres create
? Choose an app name (leave blank to generate one): pincity-db
? Select Organization: Arzybek (personal)
? Select regions: Singapore, Singapore (sin)
? Select configuration: Development - Single node, 1x shared CPU, 256MB RAM, 1GB disk

Далее нам понадобится создать dockerfile для нашего приложения. Самый простой сработает. Здесь из ENV задаем только те, что не отличаются в локальной и удаленной БД, и которые не жаль запушить в гит. Про параметр -XX расскажу позже.

FROM openjdk:17
EXPOSE 8080
COPY target/pin-city-0.0.1-SNAPSHOT.jar app.jar
ENV POSTGRES_SCHEMA "pins"
ENTRYPOINT ["java","-XX:MaxRAM=100M", "-jar", "/app.jar"]

Далее можно запушить этот образ куда-либо, либо же написать fly launch , и он сам найдет ваш докер файл в папке, поймет что вы хотите собрать его и начнет собирать его. Здесь также выберите регион и организацию. Создастся файл fly.toml, это конфигурационный файл для деплоя, он нам еще пригодится. Далее как раз настраиваем креды доступа к БД, делается это так: flyctl secrets set POSTGRES_PASSWORD=***, здесь пишите тот пароль, который он вам выдал. Далее самое главное, ссылка доступа к БД. Т.к. это все крутится уже не локально, ссылка будет другой:

flyctl secrets set POSTGRES_JDBC_URL=jdbc:postgresql://yourapp.internal:5432/databasename

Посмотрите то сообщение, что он выдал вам после создания postgres, там вы найдете адрес с internal, его и вставьте вместо yourapp (а, ну и чуть не забыл, не забудьте задать BOT_TOKEN переменную, конечно).

Казалось бы все готово, но не тут то было. Т.к. fly предоставляем только 256мб памяти, с liquibase при старте ваше приложение просто упадет, т.к. будет просить больше, даже если ему это не надо. Для этого и нужен был флаг -XX:MaxRAM=100M, однако даже одного флага будет недостаточно. Даже если указать 70мб, этого будет не хватать, поэтому нужно также в том самом файле fly.toml добавить строчку swap_size_mb = 512, это активирует swap и нашему приложению будет более чем достаточно памяти, чтобы стартовать и работать без проблем. Также в файлике рекомендую сменить auto_stop_machines = false , если не хотите чтобы ваш бот выключался если нет трафика, и закомментировать # force_https = true, кажется это октрывает порты к приложению, но честно говоря я не уверен на каком порту работают телеграмм боты, вроде по https, да и само приложение, как вы видели, я запускаю на 8080, и вроде работает, в любом случае можете поэксперементировать или написать в комментарии, если знаете. Все, после этого вам останется набрать fly deploy и ваш бот начнет деплоиться, после чего вы сможете проверить его работоспособность.

Бот и БД в панели управления после успешного деплоя
Бот и БД в панели управления после успешного деплоя

Да уж, длинная получилась статья, но сам бот получился относительно простенький по логиике. Изначально я планировал подключить апи гугла и сделать возможность искать заведение не по списку, загружаемому при старте в БД, как это реализовано сейчас, а по адресу, вводимому пользователем, но для начала решил сделать с ограниченным списком заведений и городов, потому что с апи google maps, кажется, придется повозиться. В любом случае, надеюсь эта статья кому-то будет полезна, как минимум потому, что в ней я показал не только как актуально бесплатно деплоить в 2023 году, но и как превратить телеграмм бота в полноценное spring приложение, а значит возможности по реализуемой логике ограничены лишь вашей фантазией, знаниями и возможностями. А, ну и чуть не забыл, ссылка на проект, если кто-то захочет поизучать поподробнее, следить за развитием (если оно, конечно, будет, что я не гарантирую) и т.д.

Комментарии (1)


  1. DarkSavant
    23.09.2023 22:22

    Коль уж конструктор у вашего бина только один, то ни к чему добавлять везде Autowired в аргументах. Для единственного конструктора спринг и так заинжектит всё. Это спецификация из Java EE не помню которая.