Fuel Notifier
Бот-агрегатор "заправка/топливо появилось" с настраиваемыми пользовательскими сценариями.
Ядро логики (сравнение выборок, сценарии, дедупликация уведомлений) не зависит от Telegram —
Telegram-бот является одним из возможных NotificationChannel.
Архитектура (по слоям)
client/ — получение сырых данных (HttpFuelSourceClient за интерфейсом FuelSourceClient)
dto/ — маппинг входящего JSON
domain/ — JPA-сущности:
GasStationBrand (сеть, напр. "Татнефть")
GasStation (конкретная АЗС, ссылается на бренд)
StationEvent (факт смены статуса станции — FUEL_ARRIVED/LOW_STOCK/QUEUE_FORMED/OUT_OF_STOCK)
UserAccount (имя/фамилия из профиля мессенджера, платформа, онбординг)
UserScenarioSettings (сценарий, частота, фильтр по топливу, включённые типы событий)
UserBrandPreference (личный список пользователя: бренд -> FAVORITE/RESERVE)
NotificationLog + enum'ы
repository/ — Spring Data JPA
service/sync — StationSyncService + StationStatusClassifier: сравнивает свежую выборку со
снапшотом в БД, классифицирует статус в один из 4 типов и, если категория
изменилась с прошлого опроса, фиксирует StationEvent (TTL = fuel.event.ttl-minutes)
service/scenario — ScenarioMatcher (чистая функция "событие + сценарий -> подходит/нет"),
ScenarioDefaults (дефолты для новых пользователей)
service/notification — NotificationEngine (платформо-независимый оркестратор рассылки),
интерфейс NotificationChannel — под каждую платформу своя реализация
service/migration — LegacyUserMigrationService: разовый сброс онбординга у пользователей без
списков брендов (см. ниже), по умолчанию выключен
service/cleanup — периодическая чистка протухших событий/логов
scheduler/ — FuelPollingScheduler, @Scheduled(fixedRate = 10 мин)
telegram/ — FuelTelegramBot (UI), TelegramNotificationChannel (implements NotificationChannel),
onboarding/ — мастер настройки при первом /start + точечное редактирование
web/ — служебный REST для простановки рейтинга бренду (сейчас не участвует в матчинге,
оставлен для будущего использования/админ-обзора)
⚠ Сознательно НЕ используется telegrambots-spring-boot-starter — см. комментарий в pom.xml
и TelegramBotConfig (у него свой авто-регистрирующий бин, падает на Guice-биндинге BotSession).
Используются только core-модули telegrambots + telegrambots-meta версии 6.9.7.1, регистрация —
вручную через new TelegramBotsApi(DefaultBotSession.class).
Чтобы добавить бота VK (или любого другого), нужно реализовать NotificationChannel
(send(...) + sendSystemMessage(...)) и свой адаптер UI поверх OnboardingService — он ничего
не знает про Telegram API. NotificationEngine и StationSyncService трогать не нужно.
Модель сценариев
Пользователь настраивает четыре независимых измерения, каждое можно поменять в любой момент:
- Главный сценарий (
ScenarioType) — какие БРЕНДЫ подходят:FAVORITES_ONLY— только из личного списка "избранные"FAVORITES_AND_RESERVE— "избранные" ИЛИ "резервные"ALL— любой брендSILENT— уведомления не приходят вообще
- Списки брендов (
UserBrandPreference) — личные списки "избранные"/"резервные", пользователь отмечает их из живого списка брендов в БД (постраничная клавиатура в боте, кнопки-тогглы). - Типы уведомлений (
StationEventType, множественный выбор) — о каких переходах статуса сообщать:FUEL_ARRIVED— status=="yes" и fuels_now непустойLOW_STOCK— status=="low"QUEUE_FORMED— status=="queue"OUT_OF_STOCK— status=="no"/null/что угодно ещё (безопасный дефолт)
- Фильтр по топливу (опционально, как раньше) — 92/95/98/100/ДТ/Газ; действует только для
событий
FUEL_ARRIVED, для остальных типов игнорируется (у "мало топлива"/"очередь"/"кончилось" нет осмысленного "какой именно бензин").
Плюс частота уведомлений (NotificationFrequency) — от "сразу" до "раз в день", по умолчанию
всегда INSTANT, но пользователь может изменить.
Все изменения (бренды, типы событий, топливо) пишутся в БД сразу по тапу на кнопку, без промежуточного буфера — это переживает перезапуск приложения без потери выбора.
Мастер первичной настройки (/start, /settings)
Шаг 1/6 сценарий → 2/6 избранные бренды → 3/6 резервные бренды → 4/6 типы уведомлений → 5/6 топливо → 6/6 частота → готово. Каждый шаг можно пропустить.
Точечное редактирование в любой момент
/scenario /favorites /reserve /events /fuels /frequency — те же экраны, что в мастере,
но НЕ трогают onboardingCompleted/onboardingStep, поэтому уведомления не прерываются на время
правки настроек.
Имя и фамилия пользователя
UserAccount.firstName/lastName заполняются и обновляются на КАЖДОЕ обращение к боту (любое
сообщение или нажатие кнопки, см. FuelTelegramBot.onUpdateReceived → getOrCreateUser(...)) —
профиль в мессенджере может меняться, поэтому проще держать его свежим, чем спрашивать один раз.
Геопозиция в уведомлениях (Telegram)
TelegramNotificationChannel.attachLocations(...) — отдельный приватный метод, вызывается из
send(...) последним шагом и полностью изолирован: шлёт нативное Telegram-сообщение с точкой на
карте (SendLocation) по каждой станции из дайджеста (не больше MAX_LOCATIONS_PER_DIGEST=5 за
раз, чтобы не заспамить чат). Отключается одной строчкой в application.yml:
telegram:
notifications:
send-location: false
Либо совсем убрать вызов attachLocations(...) в конце метода send() — код локализован в одном
методе и больше нигде не используется.
"Час хранения" события
fuel.event.ttl-minutes (по умолчанию 60) — StationEvent живёт это время. Пользователь,
изменивший настройки в течение этого часа, всё равно получит недавние события на ближайшем цикле
NotificationEngine (дедупликация через NotificationLog, чтобы не слать дважды).
EventCleanupService подчищает протухшее по крону fuel.event.cleanup-cron.
Миграция легаси-пользователей на новую модель сценариев
LegacyUserMigrationService при старте приложения (если включено) находит пользователей, у
которых нет вообще ни одной записи в user_brand_preference (то есть они завели аккаунт ДО этого
обновления и никогда не выбирали избранные/резервные бренды), шлёт им сообщение с просьбой
настроиться заново и сбрасывает onboardingCompleted=false / onboardingStep=AWAIT_SCENARIO.
По умолчанию выключено. Включайте разово в application.yml при выкладке этого обновления
на прод:
fuel:
migration:
notify-legacy-users-enabled: true # выключить обратно после успешного прогона!
Если не выключить обратно — сообщение будет улетать заново при каждом рестарте приложения всем, кто ещё не прошёл повторную настройку.
VK-бот
Второй адаптер поверх того же ядра (см. NotificationChannel) — com.fuelbot.vk. Переиспользует
OnboardingService/StationLookupService/NearbyLookupService как есть, ничего не меняя в них.
Отличия от Telegram-версии:
- нет режима "отслеживание" — у VK нет аналога Telegram Live Location (проверено: VK Bots API умеет только разовое геовложение в сообщении, никакой фоновой трансляции);
- "Заправки рядом" — только разовый запрос, кнопка с
action.type=location(аналогrequest_locationу Telegram); - callback-кнопки настроены через JSON
payload: {"d": "..."}— та же самая строковая схема callback'ов, что у Telegram (sc:,bt:,menu:и т.д.), просто обёрнутая в один JSON-ключ; - редактирование "на месте" есть (
messages.edit), работает так же, какEditMessageTextу Telegram; - кнопки цены (
StationPriceService) пока не подключены — можно добавить по аналогии с Telegram-версией при необходимости.
Опрос идёт через собственный Long Poll цикл в отдельном потоке (VkBot.pollLoop) — готовой
VK-библиотеки в зависимостях проекта нет, а протокол (groups.getLongPollServer + HTTP GET на
возвращённый сервер) достаточно простой, чтобы не тащить лишнюю зависимость ради него.
Настройка
- Создать сообщество VK, включить Long Poll API (Управление → Работа с API → Long Poll API → Включён, версия событий — последняя).
- Создать ключ доступа сообщества (Управление → Работа с API → Ключи доступа) с правами на сообщения.
- Задать переменные окружения:
VK_BOT_ENABLED=trueVK_BOT_GROUP_ID=<id сообщества>VK_BOT_TOKEN=<ключ доступа сообщества>VK_API_VERSION— по умолчанию5.199, менять не обязательно.
По умолчанию vk.bot.enabled=false — без явного включения бот не пытается стартовать вообще
(не будет ни ошибок в логах, ни обращений к VK API).
Админ-функциональность
Полноценной админ-панели с ролями пока нет — список админов задаётся переменными окружения
(ADMIN_TELEGRAM_IDS/ADMIN_VK_IDS, id через запятую). При старте AdminBootstrapService
гарантирует для каждого UserAccount (создаёт, если админ никогда не писал боту) и
AdminSettings со всеми типами уведомлений включёнными по умолчанию. Точечно отключить
конкретный тип пока можно только прямой правкой БД (таблица admin_settings +
admin_enabled_event_types) — команда в боте для этого напрашивается логичным следующим шагом,
когда станет понятно, какие типы событий на практике оказываются шумными.
Уведомления админам
AdminNotificationService.notify(AdminEventType, text) — единая точка входа, шлёт через тот же
NotificationChannel, что и обычным пользователям. Два источника событий:
- платформо-независимые (
UserRegisteredEvent,OnboardingCompletedEvent) — публикуются изOnboardingServiceчерезApplicationEventPublisher, слушаются вAdminNotificationService; - VK-специфичные (
group_join/wall_reply_new/like_add) — разбираются прямо вVkBot, для них нет платформо-независимого аналога в принципе (у Telegram-бота нет понятия "участник сообщества"/"комментарий на стене"). Чтобы они реально начали приходить, в Управление сообществом → Работа с API → Long Poll API → Типы событий нужно дополнительно отметить «Вступление в сообщество», «Новый комментарий на стене», «Лайк добавлен» — отдельно от чекбоксов для сообщений, которые уже настраивали.
Статистика
StatisticsService — текущий срез по городу, разбивка по брендам, счётчики событий за период.
Важный нюанс: StationEvent живёт всего ~1 час (TTL для дедупа push-уведомлений, см. выше) —
для суточной статистики этого недостаточно, поэтому завели отдельный insert-only журнал
StationEventLog (пишется параллельно с обычным StationEvent в StationSyncService, хранится
fuel.stats.retention-days — по умолчанию 30 дней — и чистится отдельным cron, независимо от
TTL уведомлений).
Контент-предложения (урезанная версия)
ContentSuggestionService дважды в день (по умолчанию 9:00 и 18:00, fuel.content.suggestion- cron) готовит админам, подписанным на AdminEventType.CONTENT_SUGGESTIONS, картинку дайджеста
(ContentImageService, SVG → PNG через Apache Batik — чистая Java, без системных зависимостей
вроде wkhtmltoimage/ImageMagick в контейнере) и несколько текстовых вариантов подписи.
Сознательно не публикует ничего сама — только присылает материалы в личные сообщения, публикация на стену/в истории вручную. Автопостинг — следующий шаг, после того как станет понятно, что тексты/картинки получаются стабильно хорошими без ручной правки.
Тренды и средние значения
StatsSnapshot — сырые периодические срезы (пишет StatsSnapshotService раз в
fuel.stats.snapshot-cron, по умолчанию каждые 30 минут) в трёх измерениях: TOTAL (город
целиком), BRAND (на каждый бренд), FUEL (на каждый вид топлива из domain.FuelCodes).
Специально ОДНА таблица с полем-дискриминатором, а не отдельная под каждое измерение — все три
запрашиваются одинаково (среднее/тренд за период для конкретного измерения), заводить схему под
каждое не было смысла.
Средние и тренды считаются на лету поверх этих срезов (TrendService.getAverage/getTrend) — не
хранятся предвычисленными на каждый период: при таком объёме данных (даже год 30-минутных срезов —
это ~17.5 тысяч строк на измерение) агрегация в момент запроса дешевле, чем городить отдельные
таблицы под час/6 часов/сутки/неделю/месяц/год, и не требует менять схему, если понадобится ещё
период (см. TrendPeriod). "Тренд" — сравнение среднего за период с таким же по длине периодом
до него (например, эта неделя против прошлой), направление считается по изменению доли станций с
топливом.
Хранится с запасом на год (fuel.stats.snapshot-retention-days, по умолчанию 400 дней) — отдельно
от StationEventLog (тот для суточных дайджестов, 30 дней по умолчанию, другая ретенция и другая
цель).
Пока это только слой данных — вывод трендов в контент/уведомления не подключён, это следующий шаг.
Запуск
- PostgreSQL, БД/пользователь из
application.yml(илиSPRING_DATASOURCE_URL/_USERNAME/_PASSWORD). telegram.bot.username/telegram.bot.token,fuel.source.url.mvn spring-boot:run(Java 8).
ddl-auto: update — для быстрого старта. Схема в этой версии менялась ощутимо (новые таблицы
station_event, user_brand_preference, user_enabled_event_types; у user_scenario_settings
другой набор колонок) — если апгрейдитесь с более старой версии схемы в дев-окружении без ценных
данных, проще всего дропнуть старые таблицы вручную и дать Hibernate создать всё заново:
DROP TABLE IF EXISTS notification_log, station_event, user_enabled_event_types,
user_scenario_settings, user_brand_preference, gas_station, gas_station_brand, user_account CASCADE;
На проде — validate + Liquibase/Flyway с явными миграциями.
Известные MVP-ограничения
- Рейтинг бренда (
GasStationBrand.rating,PATCH /api/admin/brands/{name}/rating) сохранён в схеме и API, но сейчас не участвует в матчинге сценариев — модель сценариев теперь целиком на списках брендов. Можно вернуть как доп. фильтр при желании. - Геокодирование пользовательских адресов не реализовано (функция адресов из ранней версии убрана в пользу списков брендов).
- Постраничный выбор брендов — 6 на страницу (
KeyboardFactory.BRANDS_PAGE_SIZE), без поиска по названию; при очень большом числе брендов в регионе стоит добавить текстовый поиск.