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 трогать не нужно.

Модель сценариев

Пользователь настраивает четыре независимых измерения, каждое можно поменять в любой момент:

  1. Главный сценарий (ScenarioType) — какие БРЕНДЫ подходят:
    • FAVORITES_ONLY — только из личного списка "избранные"
    • FAVORITES_AND_RESERVE — "избранные" ИЛИ "резервные"
    • ALL — любой бренд
    • SILENT — уведомления не приходят вообще
  2. Списки брендов (UserBrandPreference) — личные списки "избранные"/"резервные", пользователь отмечает их из живого списка брендов в БД (постраничная клавиатура в боте, кнопки-тогглы).
  3. Типы уведомлений (StationEventType, множественный выбор) — о каких переходах статуса сообщать:
    • FUEL_ARRIVED — status=="yes" и fuels_now непустой
    • LOW_STOCK — status=="low"
    • QUEUE_FORMED — status=="queue"
    • OUT_OF_STOCK — status=="no"/null/что угодно ещё (безопасный дефолт)
  4. Фильтр по топливу (опционально, как раньше) — 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.onUpdateReceivedgetOrCreateUser(...)) — профиль в мессенджере может меняться, поэтому проще держать его свежим, чем спрашивать один раз.

Геопозиция в уведомлениях (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 на возвращённый сервер) достаточно простой, чтобы не тащить лишнюю зависимость ради него.

Настройка

  1. Создать сообщество VK, включить Long Poll API (Управление → Работа с API → Long Poll API → Включён, версия событий — последняя).
  2. Создать ключ доступа сообщества (Управление → Работа с API → Ключи доступа) с правами на сообщения.
  3. Задать переменные окружения:
    • VK_BOT_ENABLED=true
    • VK_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 дней по умолчанию, другая ретенция и другая цель).

Пока это только слой данных — вывод трендов в контент/уведомления не подключён, это следующий шаг.

Запуск

  1. PostgreSQL, БД/пользователь из application.yml (или SPRING_DATASOURCE_URL/_USERNAME/_PASSWORD).
  2. telegram.bot.username/telegram.bot.token, fuel.source.url.
  3. 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), без поиска по названию; при очень большом числе брендов в регионе стоит добавить текстовый поиск.
S
Description
Бот для поиска заправок с топливом
Readme
386 KiB
Languages
Java 99.4%
Go Template 0.4%
Dockerfile 0.2%