Skip to content

Architecture

How the pieces of darkob fit together, from an incoming update to your handler and back out to the API.

Three surfaces, two transports

darkob.Bot(Session, Updates, Methods)      # Bot API v3, HTTP only
darkob.Client(Methods)                     # user API; deprecated name
darkob.Messenger(Client)                   # user API, HTTP + WebSocket
darkob.Shad(Messenger)                     # same, Shad platforms

Bot talks to one fixed base URL — https://botapi.rubika.ir/v3/<token> — so there is nothing to discover. Messenger and Shad share a client that first asks the platform for its data-centre list, then keeps a pooled session per host.

The two surfaces therefore have two request engines and two dispatch engines:

Concern Bot Messenger / Shad
Request engine darkob/bot/request.py darkob/client/core/api.py
Endpoints One, from the token Discovered, ranked, rotated
Retries Attempt budget Attempt budget across endpoints
Circuit breaker Not used One per endpoint
Rate limiter acquire(chat_id) before each attempt acquire() before each attempt
Dispatch engine darkob/bot/registry.py Api._dispatch_update
Handler lookup Linear scan of every handler Indexed by update name
Concurrency Polling awaits each update; webhook updates run under a semaphore Handlers spawned as tasks

Inside the Bot

Bot.__init__ validates the token, sets base_url, and builds four collaborators: HandlerRegistry (handlers, middlewares, lifecycle hooks), DecoratorBridge (the on_* families), PluginManager and ConversationManager. Methods is a mixin chain of endpoint families (send_message, get_file, upload_file, …), and every async method on it is wrapped so it also works synchronously.

An update arrives either from the polling loop or from the webhook handler in darkob/bot/updates.py. The webhook path resolves pending conversations first (_safe_feed); the polling loop feeds the registry directly. Past that point both paths are the same:

update → ConversationManager.resolve()      # webhook only: ask() may consume it
       → HandlerRegistry.feed()
       → middleware chain, outermost first
       → every (constraints, handler) pair, in registration order
       → handler(bot, event)

A handler that raises is logged; the next one still runs. With run_all_handlers=False the first match wins and the scan stops.

Inside the client

Messenger.start() connects: it creates the transport object (Api), restores the stored session's auth, guid and private key, and — on the first request — loads the data-centre list with get_dcs, which fills api_priority and picks the preferred endpoint. One pooled aiohttp session is shared by every call, and updates arrive over a WebSocket at the discovered socket URL.

Each outgoing call goes through Api._send_request, which is where most of the reliability machinery lives:

_request(data)
  → endpoint candidates, ordered by (breaker open, measured latency, discovery order)
  → drop endpoints whose breaker is open; give up if none remain
  → plugin before_request (one chance, before any attempt)
  → loop until the attempt budget is spent:
        rate limiter acquire()
        POST to candidates[attempts % n]
        429 / TOO_REQUESTS → on_throttle(retry_after), maybe give up
        success → promote endpoint, record breaker success, record latency,
                  plugin after_response, limiter on_success(), return
        failure → breaker record_failure, plugin on_error, jittered sleep

Incoming updates are read over the WebSocket, then _dispatch_update runs plugins, conversations, and the handler index.

The dispatch index

The client answers "who handles this update name?" from a dict built once:

index = {}                       # "NewMessage" -> [(func, handler), ...]
for func, handler in client.handlers.items():
    index.setdefault(handler.__name__, []).append((func, handler))

The index is cached together with a signature, (len(handlers), id(handlers)), and rebuilt only when that changes; invalidate_dispatch_index() forces a rebuild. Handler classes are instantiated once, at build time, instead of once per incoming event.

Bot deliberately does not do this. Its handlers carry arbitrary filter objects rather than one update type, so a flat list plus await constraint.evaluate(event) is the honest data structure — with the price that every update scans the registered handlers.

One update, both surfaces

flowchart TD
    A[Incoming update] --> B{Surface}

    B -->|Bot| C["ConversationManager.resolve()<br/>webhook path"]
    C --> D["HandlerRegistry.feed()"]
    D --> E["Middleware chain"]
    E --> F["Linear scan of (constraints, handler)"]
    F --> G["await handler(bot, event)"]

    B -->|Messenger / Shad| H["PluginManager.run_on_update()"]
    H --> I["ConversationManager.resolve()"]
    I --> J["_handlers_for(update name)"]
    J --> K["Dispatch index hit"]
    K --> L["Filter, then spawn a task"]

    G --> M["Handler calls an API method"]
    L --> M

    M --> N{Surface}
    N -->|Bot| O["rate_limiter.acquire(chat_id)"]
    O --> P["POST base_url/endpoint"]
    P --> Q["Retry budget, then typed error"]

    N -->|Messenger / Shad| R["Candidates: breaker + latency order"]
    R --> S["rate_limiter.acquire()"]
    S --> T["POST candidate"]
    T --> U["429 / TOO_REQUESTS → on_throttle"]
    T --> V["Success → promote, breaker, latency, on_success"]

Shared, loop-owned state

Both engines keep their mutable state in plain dicts and counters: the dispatch index, the endpoint map and latency averages, the breakers, the rate-limit buckets, the conversation registry and the metric registry. None of those critical sections await, which is what makes them safe without locks — and why the client belongs to one event loop. See Concurrency.

See also