Skip to content

Architecture

How the bot is built internally: the runtime containers, the layered components, and the per-update dependency-injection pipeline. This is the C4 Level 2 (Container) and Level 3 (Component) view. For the context, see System Overview; for step-by-step flows, see Runtime Flows.

The bot runs as one process that concurrently runs three cooperating loops on a single asyncio event loop, plus its external stores.

C4Container
    title Container view — abitly-bot process

    Person(user, "Applicant / Admin")
    System_Ext(tg, "Telegram Bot API")

    Container_Boundary(proc, "abitly-bot (single Python process)") {
        Container(poller, "Dispatcher (long-poll)", "aiogram 3", "getUpdates loop → middleware chain → routers/handlers")
        Container(sched, "Scheduler", "APScheduler", "07:00 Europe/Kyiv daily open-day job")
        Container(sender, "MessageSender", "asyncio + aiolimiter", "Bounded-concurrency outbound queue; 429 retry")
        Container(health, "Healthcheck", "aiohttp", "GET /healthcheck → 200 OK (liveness probe)")
    }

    SystemDb_Ext(pg, "PostgreSQL (schema 'abitly')")
    SystemDb_Ext(redis, "Redis")

    Rel(user, tg, "Messages")
    Rel(poller, tg, "Long-polls updates; sends replies")
    Rel(sched, sender, "Enqueues daily reminders")
    Rel(sender, tg, "Sends fan-out messages")
    Rel(poller, pg, "Reads/writes via per-update session")
    Rel(poller, redis, "FSM state; link-token redeem")
    Rel(sched, pg, "Reads open days + monitors")

All four are started in src/abitly_bot/__main__.py. They share settings, the Redis client, and the MessageSender singleton via the dispatcher’s workflow_data (dp["settings"], dp["redis"], dp["sender"]).

Within the dispatcher, code is organised in strict layers. Dependencies point downward only; each layer has one responsibility and a narrow interface.

flowchart TD
    TG[Telegram update] --> MW[Middleware chain]
    MW --> H["Handlers (handlers/)<br/>thin: Telegram I/O ↔ service calls"]
    H --> S["Services (services/)<br/>fat: business logic, returns text/keyboard"]
    S --> R["Repositories (repositories/)<br/>thin: data access only"]
    R --> M["Models (db/models/)<br/>SQLAlchemy 2.0 mappings"]
    S -. pure .-> P["score_service / ranking_service<br/>dependency-free functions"]
    S --> T["texts/<br/>verbatim message strings"]
    S --> K["keyboards/<br/>inline & reply markup"]
    M --> DB[(PostgreSQL)]

Why this shape: handlers are nearly untestable in isolation, so logic lives in services that return (text, keyboard) and never touch aiogram; repositories isolate data access; score/rank are pure functions verified by golden fixtures. See ADR 0002.

PathResponsibility
src/abitly_bot/__main__.pyBoot: healthcheck → DB fail-fast → engine/sessionmaker → bot+sender → dispatcher → scheduler → set_my_commands → long-poll.
src/abitly_bot/config.pyTyped settings from env/.env (pydantic-settings); db_dsn, redis_url, admin_ids.
src/abitly_bot/bot.pyBot factory (HTML parse mode, no link preview) + the /setMyCommands list.
src/abitly_bot/dispatcher.pyDispatcher factory: Redis FSM storage + middleware + router wiring.
src/abitly_bot/middlewares/Per-update DI chain — see below.
src/abitly_bot/handlers/One router per feature area; thin adapters.
src/abitly_bot/services/Business logic; returns rendered (text, keyboard) / result objects.
src/abitly_bot/repositories/Thin data access (one class per aggregate).
src/abitly_bot/db/models/SQLAlchemy 2.0 models mirroring the backend-owned schema.
src/abitly_bot/db/engine.pyAsync engine + sessionmaker; TLS context; search_path.
src/abitly_bot/infra/Cross-cutting runtime: sender, scheduler, healthcheck, redis.
src/abitly_bot/keyboards/Inline & reply keyboard builders.
src/abitly_bot/texts/Verbatim Ukrainian message templates + date formatting.
src/abitly_bot/filters/aiogram filters (IsAdmin).
tests/Offline unit tests; tests/integration/ needs live DB (-m integration).

aiogram middlewares populate a data dict that is injected into handlers by kwarg name. The chain is registered in dispatcher.py and runs in this order:

sequenceDiagram
    participant TG as Telegram update
    participant DbS as DbSessionMiddleware (outer)
    participant Repos as ReposMiddleware
    participant UU as UserUpsertMiddleware
    participant H as Handler

    TG->>DbS: update
    DbS->>DbS: open AsyncSession (one txn)
    DbS->>Repos: data["session"]
    Repos->>Repos: build repos + services on the session
    Repos->>UU: data[...repos/services...]
    UU->>UU: upsert telegram_users; set data["tg_user"], ["tg_user_is_new"]
    UU->>H: invoke with injected kwargs
    H-->>DbS: result
    alt handler raised
        DbS->>DbS: session.rollback()
    else ok
        DbS->>DbS: session.commit()
    end
  • DbSessionMiddleware (registered as outer so it wraps the whole chain): opens one AsyncSession per update, commits on success, rolls back on exception. Every update is a single transaction. middlewares/db_session.py.
  • ReposMiddleware: constructs all repositories and services bound to that session and puts them in data (offer_service, profile_service, …). Handlers ask for exactly what they need: async def my_offers(message, tg_user, offer_service): .... middlewares/repos.py.
  • UserUpsertMiddleware: upserts the telegram_users row, refreshes last_interacted_at, and injects tg_user + tg_user_is_new (drives the one-time welcome). middlewares/user_upsert.py.

Critical interaction: UserUpsertMiddleware runs session.get(TelegramUser) on every update. That is precisely why the notification-filter relationships are lazy="raise" — see ADR 0003.

Routers are collected in handlers/__init__.py:collect_routers() and registered in a deliberate order — specific command/callback routers first, the catch-all text router last so it stays a fallback (mirrors the TS bot.on('text')):

admin → start → profile → offers → statistics → notifications
→ offer_callbacks → open_days → filters → offer_text (catch-all, LAST)

offer_text matches any F.text: an offer URL renders stats, a non-offer URL replies with the invalid-link hint, and other text is silently ignored.

  • FSM storage: RedisStorage with key prefix abitly_bot_fsm (dispatcher.py). Redis is shared with the backend; namespacing keeps the keyspaces apart (infra/redis.py).
  • Process singletons (in dp[...]): settings, redis, sender. The MessageSender queue/limiter is in-process — a key reason the bot runs as a single instance (ADR 0005).
  • Pure functions: score and rank hold no state.
ConcernWhereNotes
TransactionsDbSessionMiddlewareone session/txn per update
Config / secretsconfig.pySecretStr for token/passwords; env-driven
Rate limitinginfra/sender.pyaiolimiter + bounded concurrency (ADR 0005)
Schedulinginfra/scheduler.pyexplicit Europe/Kyiv TZ; coalesce
TLSdb/engine.pyverification ON (ADR 0007)
Auth (admin)filters/IsAdminid ∈ settings.admin_ids
Logginglogging.pyconfigured at boot from LOG_LEVEL
Healthinfra/healthcheck.py/healthcheck for the platform probe