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.
Boot sequence
Section titled “Boot sequence”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.
Inbound update (the common path)
Section titled “Inbound update (the common path)”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.
Account linking
Section titled “Account linking”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.
Daily open-day reminders (cron)
Section titled “Daily open-day reminders (cron)”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_universitiesrelationships 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.