Skip to content

Runtime Flows

Step-by-step C4 Level 4 / runtime views of the important paths. Each diagram is traceable to code; file references are given under each flow. For the static structure, see Architecture.

python -m abitly_bot__main__.run()main(). Order matters: the health probe comes up first, then the DB is checked fail-fast before anything else starts.

sequenceDiagram
    autonumber
    participant Main as __main__.main()
    participant HC as Healthcheck (aiohttp)
    participant DB as PostgreSQL
    participant DP as Dispatcher
    participant SCH as Scheduler
    participant TG as Telegram

    Main->>HC: run_healthcheck_server(PORT)
    Note over HC: /healthcheck → 200 OK as early as possible
    Main->>DB: connect + SELECT 1
    alt DB unreachable
        DB-->>Main: error
        Main->>Main: dispose engine/redis/health → SystemExit(1)
    else ok
        Main->>Main: build sessionmaker, bot, MessageSender
        Main->>DP: build_dispatcher(settings, redis, sessionmaker)
        Main->>DP: dp["settings"|"redis"|"sender"] = singletons
        Main->>SCH: register 07:00 daily job + start()
        Main->>TG: set_my_commands(BOT_COMMANDS)
        Main->>TG: dp.start_polling(bot)
    end

The fail-fast (SystemExit(1)) mirrors the TS bot and ensures the platform restarts a process that cannot reach its database, rather than serving a half-broken bot. On shutdown, the finally block stops the scheduler and closes bot/redis/engine/health.

Code: src/abitly_bot/__main__.py.

Every update flows through the middleware chain (Architecture) into a router. Example: a user pastes an offer URL.

sequenceDiagram
    autonumber
    participant U as User
    participant TG as Telegram
    participant DP as Dispatcher + middleware
    participant H as offer_text handler
    participant OS as OfferService
    participant Repo as Offer/Monitoring/BaseUser repos
    participant DB as PostgreSQL

    U->>TG: paste offer URL
    TG-->>DP: update (long-poll)
    DP->>DP: open session → build repos/services → upsert tg_user
    DP->>H: handle_text(message, tg_user, offer_service)
    H->>H: regex match offer id
    alt not an offer URL
        H-->>U: invalid-link hint (or ignore non-URL text)
    else offer id matched
        H->>OS: build_offer_view(offer_id, tg_user)
        OS->>Repo: offer + analytics + applicants + (grades if linked)
        Repo->>DB: SELECT ...
        OS->>OS: score (pure) + rank (pure) + render text/keyboard
        OS-->>H: OfferView(OK | NOT_FOUND | INVALID_YEAR)
        H-->>U: message.answer(text, keyboard)
    end
    DP->>DB: commit (or rollback on exception)

The handler is a thin adapter; OfferService does the work and returns a rendered OfferView (ADR 0002). Score/rank are pure functions. Code: handlers/offer_text.py, services/offer_service.py, services/score_service.py, services/ranking_service.py.

The backend mints a one-time token in Redis; the user opens https://t.me/<bot>?start=link_<token>; the bot redeems it. The bot is the writer of telegram_users.web_user_id in this direction.

sequenceDiagram
    autonumber
    participant Web as abitly-api-v2
    participant R as Redis
    participant U as User
    participant H as start handler
    participant LS as linking_service.redeem
    participant DB as PostgreSQL

    Web->>R: SET abitly:link:<token> = {web_user_id, created_at} (TTL ~600s)
    Web-->>U: deep link t.me/bot?start=link_<token>
    U->>H: /start link_<token>
    H->>LS: redeem(token, redis, repos, chat_id)
    LS->>R: GETDEL abitly:link:<token>
    alt missing / expired / replayed / malformed
        R-->>LS: nil
        LS-->>H: None
        H-->>U: link-invalid message
    else valid
        R-->>LS: {web_user_id}
        LS->>DB: verify users row exists
        LS->>DB: UPDATE telegram_users SET web_user_id = ...
        LS-->>H: web_user_id
        H-->>U: profile-connected message
    end

GETDEL makes the token strictly single-use (a replay returns nil). The Redis key abitly:link:<token> is the only hard bot↔backend contract for linking. The minting side (backend writing the key) is the outstanding backend prerequisite. Code: services/linking_service.py, handlers/start.py:57-70.

APScheduler fires at 07:00 Europe/Kyiv. The job opens its own session (it runs outside the update pipeline) and enqueues reminders for events that are exactly 1 or 3 days away.

sequenceDiagram
    autonumber
    participant SCH as APScheduler (07:00 Kyiv)
    participant Job as _daily_open_days_job
    participant NS as NotificationService
    participant DB as PostgreSQL
    participant SND as MessageSender
    participant TG as Telegram

    SCH->>Job: trigger (coalesce; misfire grace 1h)
    Job->>Job: open AsyncSession
    Job->>NS: send_daily_open_day_notifications(sender)
    NS->>DB: monitoring rows for events in [today, +3d]
    NS->>NS: build_daily_notifications() — keep only 1- or 3-day-away (pure)
    NS->>SND: add_messages(messages)
    NS->>SND: start() + wait_for_completion()
    loop bounded concurrency + rate cap
        SND->>TG: send_message (429 → sleep & re-queue; blocked/err → drop+log)
    end
    Job-->>SCH: log count sent

The day-window filtering is a pure, unit-tested function; the send path uses the MessageSender (see next flow). coalesce=True means a restart-missed run fires once on resume. Code: infra/scheduler.py, services/notification_service.py, __main__.py:69-76.

Fan-out send & 429 handling (MessageSender)

Section titled “Fan-out send & 429 handling (MessageSender)”

Used by the daily cron, /broadcast, and /notifyOpenDaysUpdate. The drain keeps at most SEND_MAX_CONCURRENCY sends in flight, rate-capped by aiolimiter, and isolates errors per message so one bad recipient never aborts the batch.

flowchart TD
    A["add_messages(list)"] --> Q[(queue)]
    S["start() → background drain"] --> L{"in-flight < max<br/>and queue non-empty?"}
    L -- yes --> J["pop job → _run_job"]
    L -- no --> W["await FIRST_COMPLETED"]
    W --> L
    J --> RL["aiolimiter: rate cap"]
    RL --> SEND["bot.send_message"]
    SEND -->|429 TelegramRetryAfter| RT{"attempts < max_retry?"}
    RT -- yes --> SL["sleep(retry_after) → re-queue"] --> Q
    RT -- no --> DROP1["log + drop"]
    SEND -->|TelegramForbiddenError| DROP2["user blocked → log + drop"]
    SEND -->|other exception| DROP3["log + drop, keep draining"]
    SEND -->|ok| DONE["sent"]

Rationale and trade-offs: ADR 0005. Code: infra/sender.py. Admin triggers: handlers/admin.py.

Open-day update fan-out (admin, filter-gated)

Section titled “Open-day update fan-out (admin, filter-gated)”

/notifyOpenDaysUpdate (admin) finds open days edited today and sends an update to each matching user. A user matches when they have at least one filter set and every set filter dimension matches the open day (user_matches_open_day).

⚠️ This path reads the filter_specialities / filter_universities relationships and therefore depends on the two pending join tables — it errors until the backend creates them (Data Model, ADR 0003). The matching predicate itself is pure and unit-tested. Code: services/notification_service.py:60-105.