Skip to content

Architecture

Core stance

The local SQLite projection and CLI are the core of this package.

The optional daemon is still present for testing or local HTTP workflows, but it is no longer required in the preferred call-baxter integration model. In that model, Telegram updates can enter through call-baxter, and tg-agent-cli is used as the projection/index layer only.

Layers

api/
  Telegram Bot API adapter and generated Telegram request/response models

ingest/
  update normalization, media extraction, and persistence policy

store/
  SQLite schema and small DB helpers

services/
  query services, outbound action services, polling service, hook registry

cli/
  pydantic-settings command tree

daemon/
  FastAPI application exposing the same service operations over HTTP

Division between CLI and daemon

CLI

The CLI is optimized for:

  • local inspection of already ingested state
  • shell automation
  • ref-based outbound actions
  • manual polling or schedule management
  • reproducible examples of modern pydantic-settings CLI composition

daemon

The daemon remains useful for:

  • local debugging of projection services over HTTP
  • temporary integration before call-baxter is introduced
  • service-style deployments that still want an HTTP wrapper around the projection DB

But the package no longer requires a daemon to stay useful.

Polling model

Polling is represented explicitly instead of being hidden in a worker script.

tg_poll_schedules
  desired cadence and filters

tg_poll_runs
  execution evidence

tg_poll_schedule_claims
  cross-process execution ownership with expiring leases

PollingService
  live getUpdates call + normalization + hook emission

The scheduler model is intentionally simple:

  • schedules have interval_seconds, timeout_seconds, limit, allowed_updates, hook_names, and source
  • run_due_schedules() is the primitive loop
  • external supervisors can call that periodically
  • each due run is claimed atomically in SQLite, preventing two daemon requests or processes from running the same schedule concurrently
  • the active worker renews its claim while getUpdates and normalization run; an expired claim can neither be revived nor completed, and lease loss is recorded on the poll run
  • the next execution is scheduled from actual completion time rather than batch-start time, avoiding immediate reruns after a long poll
  • hook names and Telegram polling bounds are validated before schedule persistence or API execution

This keeps the backend fixed and testable while leaving deployment policy outside the core library.

Projection ordering

Telegram update rows remain an appendable audit history, while each normalized message row represents the newest known message version. Message upserts compare edit_date and date; a delayed original-message retry cannot replace a newer edit or its entity/media projections.

Atomic projection visibility

Ingestion is split into two phases:

  1. Telegram file metadata and bytes are fetched before a SQLite write transaction starts.
  2. The update, chat, users, message, entities, callbacks/reactions, and media rows commit together in one BEGIN IMMEDIATE transaction.

File-mode media is downloaded into a unique .part file under a deterministic identity directory. A pre-existing file is reused only when a committed projection row already references it; otherwise each worker prepares its own copy. The prepared file is atomically renamed during the projection transaction. If any later row write fails, SQLite rolls back and cleanup removes only the exact filesystem inode finalized by that worker, and only after confirming that no committed row references the path. After a successful edit, old unreferenced local media files are removed. Housekeeping failures are conservative and do not turn an already committed ingest into a false failure. This avoids holding a database write lock during network I/O while making projection rows atomically visible.

HTTP client lifetime

The daemon lazily creates at most one live and one offline Telegram API client and closes them during application shutdown. The Call Baxter Telegram plugin owns and closes the temporary clients it creates for polling, actions, and projection work.

Media policy

Media persistence is normalized into tg_message_media and controlled by MediaPolicy.

storage_mode = skip
  metadata only

storage_mode = file
  bytes written under media base dir; DB links to local_path

storage_mode = blob
  bytes stored in SQLite BLOB column

Default scaffold policy is file, because it keeps the DB browse-friendly while avoiding unbounded database growth for larger assets.

WAL mode is established during serialized schema initialization with bounded retries. Normal connections do not change journal mode, which avoids concurrent-startup lock races.

Hooking point

The scaffold does not ship a full callback DSL yet. Instead it offers a smaller extension point:

HookRegistry.register(name, callable)
PollingService(..., hook_registry=registry)

That gives later services a stable place to subscribe to polled update batches without entangling the core browse model with arbitrary callback semantics.