Перейти до вмісту

Telegram-бот: user-сценарії (UML + sequence)

Повний набір сценаріїв Abitly Telegram-бота (@abitlybot, aiogram 3 · AWS ECS) очима користувача — на крос-сервісному рівні цього хабу. Кожен потік простежується до коду; під діаграмами — посилання на хендлер/сервіс у репозиторії abitly-tg-bot-v3.

Однакові скорочення у всіх sequence-діаграмах нижче:

Скор.Учасник
UАбітурієнт (користувач Telegram)
AАдмін (id ∈ ADMIN_IDS)
TGTelegram Bot API (long polling)
BBot — aiogram 3 на AWS ECS (хендлери → сервіси → репозиторії)
PGPostgres: схема abitly (дані вступу) + telegram (логи апдейтів)
RRedis (sidecar): FSM-storage + одноразові link-токени
APIabitly-api-v2 — власник схеми; мінтить link-токени
SNDMessageSender — черга масової відправки (rate-cap, 429-retry)
MINIMini App (Next.js · Railway), запускається кнопкою web_app

Use-case: усі сценарії на одній мапі (UML use case)

Section titled “Use-case: усі сценарії на одній мапі (UML use case)”
flowchart LR
    U(["👤 Абітурієнт"])
    A(["👤 Адмін"])
    SCH(["⏰ Планувальник 07:00"])
    BE(["🔧 abitly-api-v2"])

    subgraph bot["@abitlybot"]
        direction TB
        subgraph uc_info["Інформація"]
            uc1["Вставити URL офера → бали й рейтинг"]
            uc2["Шанси на вступ (deep-link)"]
            uc3["Дні відкритих дверей ЗВО (deep-link)"]
        end
        subgraph uc_track["Моніторинг"]
            uc4["Додати / прибрати офер (ліміт 5)"]
            uc5["Мої офери"]
            uc6["Моя статистика"]
        end
        subgraph uc_me["Профіль і події"]
            uc7["Мій профіль"]
            uc8["Мої дні відкритих дверей"]
            uc9["Фільтри сповіщень"]
            uc10["Увімкнути / вимкнути сповіщення"]
        end
        subgraph uc_account["Акаунт"]
            uc11["Прив'язати веб-акаунт (deep-link)"]
            uc12["Відкрити веб-застосунок / Mini App"]
        end
        subgraph uc_admin["Адмін"]
            uc13["Розсилка всім (broadcast)"]
            uc14["Фан-аут оновлень днів відкритих дверей"]
        end
        uc15["Щоденні нагадування про дні відкритих дверей"]
    end

    U --> uc1 & uc2 & uc3
    U --> uc4 & uc5 & uc6
    U --> uc7 & uc8 & uc9 & uc10
    U --> uc11 & uc12
    A --> uc13 & uc14
    SCH --> uc15
    BE -. мінтить токен .-> uc11
    uc15 -. нагадування .-> U

Індекс: команда / кнопка → сценарій

Section titled “Індекс: команда / кнопка → сценарій”
ТригерТипСценарій
/startкомандаПривітання
/start link_<token>deep-linkПрив’язка акаунту
/start chances_<spec>_<score>deep-linkШанси на вступ
/start promo_blogdeep-linkПромокод −200 грн
/start open_day_university_<id>deep-linkДні відкритих дверей ЗВО
(вставлений URL офера)текст (catch-all)Статистика офера
add:<id> / remove:<id>callbackМоніторинг офера
/myoffersкомандаМої офери
/myprofile, «👤 Профіль»команда / кнопкаПрофіль
/statisticsкомандаСтатистика
/myopendays, «📅 Мої події»команда / кнопкаМої дні відкритих дверей
/opendayfiltersкомандаФільтри сповіщень
/notifications, 🔔/🔕команда / кнопкаСповіщення
/helpкомандаДовідка (no-op)
/broadcast, /notifyOpenDaysUpdateadminАдмін-розсилки

Спільний шлях кожного апдейту (middleware)

Section titled “Спільний шлях кожного апдейту (middleware)”

Цей конвеєр однаковий для всіх сценаріїв нижче; далі показано лише специфічну частину кожного.

sequenceDiagram
    autonumber
    actor U as Абітурієнт
    participant TG as Telegram API
    participant B as Bot (aiogram · ECS)
    participant PG as Postgres
    U->>TG: команда / текст / натискання кнопки
    TG->>B: update (long polling)
    Note over B: DbSessionMiddleware (outer) — 1 сесія = 1 транзакція
    B->>PG: INSERT telegram.update_log (вхідний апдейт)
    Note over B: ReposMiddleware — будує repos+services<br/>UserUpsertMiddleware — upsert telegram_users, last_interacted_at
    B->>B: маршрутизація у хендлер (admin→start→…→offer_text LAST)
    B-->>TG: відповідь (sendMessage / editMessageText)
    B->>PG: INSERT telegram.sent_log (вихідний виклик)
    Note over B: commit при успіху · rollback при винятку

Код: middlewares/ (db_session, repos, user_upsert, update_log, outgoing_log), handlers/__init__.py. Логи — ADR 0009/0010.

sequenceDiagram
    autonumber
    actor U as Абітурієнт
    participant B as Bot
    participant PG as Postgres (abitly)
    U->>B: /start
    alt новий користувач (tg_user_is_new)
        B-->>U: привітання + reply-меню (📅 / 🔔 / 👤)
    end
    B->>PG: count_upcoming_events + count_tracked_open_days
    PG-->>B: лічильники подій
    B-->>U: динамічне привітання + inline «🌐 Відкрити веб-застосунок»

Якщо в /start є payload (link_…, chances_…, open_day_university_…) — спрацьовує відповідна гілка нижче замість дефолтного привітання. Код: handlers/start.py, services/start_service.py.

Section titled “/start link_<token> — прив’язка веб-акаунту”
sequenceDiagram
    autonumber
    participant API as abitly-api-v2
    participant R as Redis (link-токени)
    actor U as Абітурієнт
    participant B as Bot
    participant PG as Postgres
    API->>R: SET abitly:link:<token> = {web_user_id} (TTL ~600с)
    API-->>U: deep-link t.me/abitlybot?start=link_<token>
    U->>B: /start link_<token>
    B->>R: GETDEL abitly:link:<token>
    alt відсутній / прострочений / повторний
        R-->>B: nil
        B-->>U: «посилання недійсне»
    else валідний
        R-->>B: {web_user_id}
        B->>PG: перевірити users + UPDATE telegram_users.web_user_id
        B-->>U: «акаунт під'єднано»
    end

GETDEL робить токен строго одноразовим (повтор → nil). Бот — writer telegram_users.web_user_id у цьому напрямку.

Код: services/linking_service.py, handlers/start.py.

/start chances_… — шанси на вступ

Section titled “/start chances_… — шанси на вступ”
sequenceDiagram
    autonumber
    participant Web as Веб-калькулятор (abitly.org)
    actor U as Абітурієнт
    participant B as Bot
    participant PG as Postgres
    Web-->>U: deep-link chances_<specialityId>_<score×100>
    U->>B: /start chances_<spec>_<score>
    B->>B: parse_chances_payload (бал у 0..200) + ремап спец-id на 2025
    B->>PG: офери спеціальності з прохідними балами (ліміт 10)
    PG-->>B: офери
    B->>B: build_chance — порівняти бал із cutoff (бюджет / контракт)
    alt є відповідні офери
        B-->>U: список «куди вступиш» + розрив балів
    else порожньо
        B-->>U: «нічого не знайдено» + кнопка «🔍 Усі пропозиції»
    end

Округлення розриву балів навмисно повторює JS Math.round (half-up) заради паритету з TS-ботом (ADR 0004). Код: services/admission_chances_service.py.

/start promo_blog — промокод −200 грн

Section titled “/start promo_blog — промокод −200 грн”

Deep-link із блог-поста «Абітлі промокод». Єдиний сценарій, де бот викликає api-v2 по HTTP (а не через спільну БД): POST /admin/promo-codes/mint з x-admin-key.

sequenceDiagram
    autonumber
    actor U as Абітурієнт
    participant B as Bot
    participant API as api-v2
    U->>B: /start promo_blog
    B->>API: POST /admin/promo-codes/mint {campaign:"blog", telegramUserId}
    API-->>B: {code, discountUah:200, used, expiresAt}
    alt новий/активний код
        B-->>U: особистий код + кнопка «Застосувати знижку» (/uk/simulation?ref=<code>)
    else used=true
        B-->>U: «код уже використано»
    else API недоступний
        B-->>U: вибачення + посилання на симуляцію без коду
    end

Мінт ідемпотентний per (campaign, telegram_user_id); env бота: ABITLY_API_URL, ABITLY_ADMIN_API_KEY, PROMO_ENABLED, PROMO_LANDING_URL. Деталі механіки знижки — картка фічі. Код: handlers/start.py, services/promo_codes.py.

/start open_day_university_<id> — дні відкритих дверей ЗВО

Section titled “/start open_day_university_<id> — дні відкритих дверей ЗВО”
sequenceDiagram
    autonumber
    actor U as Абітурієнт
    participant B as Bot
    participant PG as Postgres
    U->>B: /start open_day_university_<id>
    B->>PG: university + майбутні open_days ЗВО
    alt ЗВО не знайдено
        B-->>U: «університет не знайдено»
    else немає днів
        B-->>U: повідомлення + «📬 Підписатися на події ЗВО»
    else є дні
        B-->>U: список (2/стор.) + пагінація + «📬 Підписатися»
    end
    U->>B: ⬅️ / ➡️ (uni_events_<id>_<page>)
    B->>PG: інша сторінка днів
    B-->>U: edit_text — нова сторінка
    U->>B: 📬 Підписатися (subscribe_uni_<id>)
    Note over B: фільтри ЗВО прибрано (ADR 0008) — без запису в БД
    B-->>U: прибрати кнопку + пропозиція керувати у веб-застосунку

Код: handlers/start.py, services/start_service.py, keyboards/inline.py.

Вставлення URL офера — статистика, бал, рейтинг

Section titled “Вставлення URL офера — статистика, бал, рейтинг”

Catch-all текстовий хендлер (реєструється останнім). Спершу — логіка маршрутизації тексту (UML activity):

flowchart TD
    T["вхідний текст"] --> M{"містить посилання на офер?<br/>abitly.org/uk/offers/result/ID або vstup.edbo.gov.ua/offer/ID"}
    M -- ні --> URL{"це взагалі URL?"}
    URL -- ні --> IGN["проігнорувати (no-op)"]
    URL -- так --> HINT["«посилання недійсне»"]
    M -- так --> FOUND{"офер знайдено?"}
    FOUND -- ні --> NF["«не знайдено»"]
    FOUND -- так --> YEAR{"year == поточний?"}
    YEAR -- ні --> IY["«неактуальний рік»"]
    YEAR -- так --> OK["статистика + бал/рейтинг + кнопки моніторингу"]
sequenceDiagram
    autonumber
    actor U as Абітурієнт
    participant B as Bot
    participant PG as Postgres (abitly)
    U->>B: вставляє URL офера
    B->>B: regex → offer_id
    B->>PG: офер (+ коефіцієнти) + аналітика + пул абітурієнтів
    opt акаунт прив'язано (web_user_id)
        B->>PG: оцінки користувача → бал + рейтинг (чисті функції)
    end
    B-->>U: статистика офера + «🔔 Додати в моніторинг»

Бал і рейтинг показуються лише для прив’язаного акаунту з оцінками; інакше — загальна статистика офера. Код: handlers/offer_text.py, services/offer_service.py, score_service.py, ranking_service.py.

Моніторинг офера: додати / прибрати

Section titled “Моніторинг офера: додати / прибрати”
sequenceDiagram
    autonumber
    actor U as Абітурієнт
    participant B as Bot
    participant PG as Postgres
    U->>B: 🔔 Додати в моніторинг (add:<offer_id>)
    B->>PG: чи вже відстежується?
    alt уже відстежується
        B-->>U: «вже в моніторингу»
    else
        B->>PG: лічильник відстежуваних
        alt count >= 5 (ліміт)
            B-->>U: «досягнуто ліміт (5 оферів)»
        else
            B->>PG: INSERT telegram_user_monitoring_offers
            B-->>U: «додано» + перемальована кнопка «🔕 Прибрати»
        end
    end
    U->>B: 🔕 Прибрати (remove:<offer_id>)
    B->>PG: DELETE telegram_user_monitoring_offers
    B-->>U: «прибрано» + кнопка «🔔 Додати»

Стан картки офера (UML state)

Section titled “Стан картки офера (UML state)”
stateDiagram-v2
    state "Не відстежується" as NotTracked
    state "Відстежується" as Tracked
    [*] --> NotTracked
    NotTracked --> Tracked: add (якщо < 5)
    NotTracked --> NotTracked: add при ліміті 5 → відмова
    Tracked --> NotTracked: remove
    Tracked --> Tracked: add → «вже в моніторингу»

Стан зберігається в telegram_user_monitoring_offers (БД), а не у FSM. Код: handlers/offer_callbacks.py.

sequenceDiagram
    autonumber
    actor U as Абітурієнт
    participant B as Bot
    participant PG as Postgres
    U->>B: /myoffers
    B->>PG: список відстежуваних оферів
    alt порожньо
        B-->>U: «немає відстежуваних оферів»
    else
        opt акаунт прив'язано
            B->>PG: оцінки → рейтинг + прохідність бюджету по кожному оферу
        end
        B-->>U: список оферів (з рейтингом, якщо прив'язано)
    end

Код: handlers/offers.py, services/offer_service.py (build_my_offers).

sequenceDiagram
    autonumber
    actor U as Абітурієнт
    participant B as Bot
    participant PG as Postgres
    U->>B: /myprofile (або «👤 Профіль»)
    B->>PG: профіль + лічильники подій (майбутні / усього)
    B-->>U: статус профілю (+ «🔍 Усі ЗВО», якщо список > 3)
    U->>B: 🔍 Усі ЗВО (view_all_universities_profile)
    B-->>U: edit_text — повний профіль

Список ЗВО завжди порожній (ADR 0008), тож кнопка «Усі ЗВО» фактично не з’являється; гілка лишена для паритету. Код: handlers/profile.py, services/profile_service.py.

/statistics — статистика позицій

Section titled “/statistics — статистика позицій”
sequenceDiagram
    autonumber
    actor U as Абітурієнт
    participant B as Bot
    participant PG as Postgres
    U->>B: /statistics
    B->>PG: к-сть відстежуваних оферів
    alt 0 оферів
        B-->>U: «спершу додай офери в моніторинг»
    else немає прив'язки / оцінок
        B-->>U: «потрібні оцінки — прив'яжи акаунт»
    else
        loop по кожному відстежуваному оферу
            B->>PG: бал + пул абітурієнтів → позиція + прохідність бюджету
        end
        B-->>U: середня / найкраща / найгірша позиція + бюджетні шанси
    end

aggregate_positions (середня half-up) — чиста, unit-tested функція. Код: handlers/statistics.py, services/statistics_service.py.

/myopendays — мої дні відкритих дверей

Section titled “/myopendays — мої дні відкритих дверей”
sequenceDiagram
    autonumber
    actor U as Абітурієнт
    participant B as Bot
    participant PG as Postgres
    U->>B: /myopendays (або «📅 Мої події»)
    B->>PG: майбутні відстежувані дні (monitoring_open_days)
    alt порожньо
        B-->>U: «немає майбутніх подій»
    else
        B-->>U: список (2/стор.) + пагінація
    end
    U->>B: ⬅️ / ➡️ (my_events_<page>)
    B->>PG: інша сторінка
    B-->>U: edit_text — нова сторінка

Код: handlers/open_days.py, services/open_day_service.py.

/opendayfilters — фільтри сповіщень

Section titled “/opendayfilters — фільтри сповіщень”
sequenceDiagram
    autonumber
    actor U as Абітурієнт
    participant B as Bot
    participant PG as Postgres
    U->>B: /opendayfilters
    B->>PG: фільтри користувача (тільки регіони)
    B-->>U: перелік фільтрів + «🌐 Відкрити веб-застосунок»

Керування фільтрами — у веб-застосунку; у боті лише перегляд. Фільтри ЗВО/спеціальностей прибрано (ADR 0008), лишилися регіони. Код: handlers/filters.py, services/filters_service.py.

/notifications — перемикач сповіщень

Section titled “/notifications — перемикач сповіщень”
sequenceDiagram
    autonumber
    actor U as Абітурієнт
    participant B as Bot
    participant PG as Postgres
    U->>B: /notifications (або 🔔 / 🔕)
    B->>B: інвертувати telegram_notifications_enabled
    B-->>U: новий статус + перемальоване reply-меню
    Note over B,PG: зміна фіксується commit-ом DbSessionMiddleware

Код: handlers/notifications.py.

/help — відоме обмеження (no-op)

Section titled “/help — відоме обмеження (no-op)”
sequenceDiagram
    autonumber
    actor U as Абітурієнт
    participant B as Bot
    U->>B: /help
    Note over B: окремого хендлера немає —<br/>текст «/help» доходить до catch-all (offer_text)
    B->>B: не URL → тихо ігнорується
    Note over U: відповіді немає (no-op)

Команда є в меню setMyCommands, але без хендлера. Див. «Відомі обмеження» в картці бота.

Адмін: /broadcast + /notifyOpenDaysUpdate

Section titled “Адмін: /broadcast + /notifyOpenDaysUpdate”
sequenceDiagram
    autonumber
    actor A as Адмін (ADMIN_IDS)
    participant B as Bot
    participant PG as Postgres
    participant SND as MessageSender
    participant TG as Telegram API
    A->>B: /broadcast <текст>
    Note over B: фільтр IsAdmin
    B->>PG: усі chat_id
    B->>SND: add_messages + start (у фоні)
    B-->>A: «розсилку запущено для N користувачів»
    loop обмежена конкурентність + rate-cap
        SND->>TG: send_message
        Note over SND: 429 → sleep + re-queue · заблокований → drop+log
    end
sequenceDiagram
    autonumber
    actor A as Адмін
    participant B as Bot
    participant PG as Postgres
    participant SND as MessageSender
    A->>B: /notifyOpenDaysUpdate
    B->>PG: дні, оновлені сьогодні + користувачі з фільтрами
    B->>B: match за регіоном (user_matches_open_day)
    B->>SND: повідомлення для кожного збігу → фан-аут
    B-->>A: «надіслано: N повідомлень»

Матчинг — лише за регіоном (фільтри ЗВО/спеціальностей прибрано — ADR 0008). Деталі rate-cap/429 — data-flows та ADR 0005. Код: handlers/admin.py, services/notification_service.py, infra/sender.py.

Системний: щоденні нагадування (cron 07:00 Київ)

Section titled “Системний: щоденні нагадування (cron 07:00 Київ)”

Не ініціюється користувачем, але саме його повідомлення отримує користувач.

sequenceDiagram
    autonumber
    participant SCH as APScheduler (07:00 Київ)
    participant B as Bot
    participant PG as Postgres
    participant SND as MessageSender
    actor U as Абітурієнт
    SCH->>B: тригер щоденної задачі
    B->>PG: monitoring-рядки подій у вікні [сьогодні, +3 дні]
    B->>B: лишити лише ті, що за 1 або 3 дні (чиста функція)
    B->>SND: enqueue нагадувань → фан-аут
    SND-->>U: «нагадування про день відкритих дверей»

Код: infra/scheduler.py, services/notification_service.py.

Доменна модель user-сценаріїв (UML class)

Section titled “Доменна модель user-сценаріїв (UML class)”

Сутності, яких торкаються сценарії вище. Бот пише лише власну підмножину; решта — read-only (власник схеми — abitly-api-v2).

classDiagram
    class TelegramUser {
        +bigint telegram_chat_id
        +uuid web_user_id
        +bool notifications_enabled
    }
    class Offer {
        +int id
        +int year
        +int speciality_id
    }
    class OpenDay {
        +int id
        +datetime date
        +int university_id
    }
    class MonitoringOffer
    class MonitoringOpenDay
    class BaseUser {
        +uuid id
    }
    class UserGrade {
        +int grade
    }
    TelegramUser "1" --> "0..5" MonitoringOffer : відстежує (write)
    MonitoringOffer --> Offer
    TelegramUser "1" --> "0..*" MonitoringOpenDay : відвідує (write)
    MonitoringOpenDay --> OpenDay
    TelegramUser ..> BaseUser : web_user_id (прив'язка, write)
    BaseUser "1" --> "0..*" UserGrade : оцінки (read)

Повна ER-схема всіх таблиць — у data-model бота. Власність читання/запису — картка бота та ADR 0006.