На дворе 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 и выдавать ошибку, если что-то сходится.
Итак, соответственно дальше нам понадобится схема БД, поэтому сразу проектируем ее. Примерный план такой, что у нас будет список поддерживаемых городов (чтобы контролировать и оставлять только актуальные и те, где можно проверить, что, например, не добавляются коды к подъездам и т.д., а только действительно заведения). Во-вторых, нужно сохранять какой город выбрал пользователь, для логики. В-третьих, нужен список мест, связанный с городами, в котором будет храниться адрес, тип заведения, его короткое название, пин-код и кем он был обновлен в последний раз (важное поле, чтобы отслеживать тех, кто будет обновлять на неправильные всегда). И для сохранения статуса переписки также понадобится доп. таблица с состоянием переписки (но об этом позже). Итого получается примерно такая схема:
Пишем миграции ко всему этому делу. Полностью приводить не буду, вот просто пример одной миграции, они достаточно простые:
<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 приложение, а значит возможности по реализуемой логике ограничены лишь вашей фантазией, знаниями и возможностями. А, ну и чуть не забыл, ссылка на проект, если кто-то захочет поизучать поподробнее, следить за развитием (если оно, конечно, будет, что я не гарантирую) и т.д.
DarkSavant
Коль уж конструктор у вашего бина только один, то ни к чему добавлять везде Autowired в аргументах. Для единственного конструктора спринг и так заинжектит всё. Это спецификация из Java EE не помню которая.