No description
  • Python 94.8%
  • HTML 4.1%
  • CSS 0.5%
  • Dockerfile 0.3%
  • JavaScript 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
ilavir db07ee0810
Some checks failed
CI / checks (push) Has been cancelled
feat(broadcasts): optional broadcast image with caption limit and abort path
Multipart create validates and stores one JPEG/PNG/WebP image, tightens the
caption limit to 1024 with an image, serves stored bytes to web and worker,
delivers via send_photo with a per-task file_id cache, and aborts the whole
broadcast on an image-level Telegram rejection. Adds Pillow and
python-multipart, a broadcast_images side table, composer preview/CSP blob:,
and syncs the archived openspec change.
2026-09-30 15:06:47 +03:00
.github/workflows fix(review): address platform hardening review findings 2026-09-29 21:29:48 +03:00
.kiro chore: update OpenSpec prompts and skills from upgrade 2026-09-28 14:01:53 +03:00
.opencode chore: update OpenSpec prompts and skills from upgrade 2026-09-28 14:01:53 +03:00
openspec feat(broadcasts): optional broadcast image with caption limit and abort path 2026-09-30 15:06:47 +03:00
packages/common feat(broadcasts): optional broadcast image with caption limit and abort path 2026-09-30 15:06:47 +03:00
scripts/legacy_users_import fix(review): address platform hardening review findings 2026-09-29 21:29:48 +03:00
services feat(broadcasts): optional broadcast image with caption limit and abort path 2026-09-30 15:06:47 +03:00
.env.example fix(review): address platform hardening review findings 2026-09-29 21:29:48 +03:00
.gitignore feat: users list overhaul, explicit compose dev config, legacy import ordering 2026-09-22 18:38:56 +03:00
.pre-commit-config.yaml chore: scaffold uv workspace with tooling and CI 2026-09-01 15:20:00 +03:00
.python-version chore: scaffold uv workspace with tooling and CI 2026-09-01 15:20:00 +03:00
AGENTS.md feat(broadcasts): optional broadcast image with caption limit and abort path 2026-09-30 15:06:47 +03:00
docker-compose.dev.yml feat: users list overhaul, explicit compose dev config, legacy import ordering 2026-09-22 18:38:56 +03:00
docker-compose.local.yml fix(review): address platform hardening review findings 2026-09-29 21:29:48 +03:00
docker-compose.yml fix(review): address platform hardening review findings 2026-09-29 21:29:48 +03:00
Makefile feat(scripts): backfill registration timestamp for complete legacy rows, cover scripts in lint/typecheck/test 2026-09-29 16:17:32 +03:00
pyproject.toml feat(scripts): backfill registration timestamp for complete legacy rows, cover scripts in lint/typecheck/test 2026-09-29 16:17:32 +03:00
README.md feat(broadcasts): optional broadcast image with caption limit and abort path 2026-09-30 15:06:47 +03:00
uv.lock feat(broadcasts): optional broadcast image with caption limit and abort path 2026-09-30 15:06:47 +03:00

yacourier_bot

YaCourier admin platform: a Telegram courier bot, its broadcast worker, a FastAPI admin API, and a Flask admin UI in one uv workspace with a single dependency lock.

For architecture and code conventions, see AGENTS.md. Capability specs live in openspec/specs/.

Prerequisites

  • uv 0.12+
  • Docker with Compose v2 (docker compose ..., not the standalone docker-compose binary)
  • Python 3.14 — see "Interpreter version" below; uv will fetch it

Local setup

  1. make install — uv sync --frozen --all-packages. A stale uv.lock fails the install instead of being silently updated.

  2. Copy .env.example to each per-service file and replace every placeholder (.env.api, .env.bot, .env.worker, .env.web are gitignored; .env.example holds placeholders only and is the committed template). Generate each service credential and secret independently:

    python3 -c "import secrets; print(secrets.token_urlsafe(32))"
    

    The three service credentials must be pairwise distinct; equal values fail API startup. Each service's token must match the corresponding API_*_SERVICE_TOKEN (BOT_SERVICE_TOKEN equals API_BOT_SERVICE_TOKEN, and so on).

  3. Create the two Docker secrets Compose expects (the secrets/ directory is gitignored):

    mkdir -p secrets
    openssl rand -hex 24 > secrets/postgres_bootstrap_password.txt
    openssl rand -hex 24 > secrets/postgres_api_password.txt
    

    The password in secrets/postgres_api_password.txt must match the one embedded in API_DATABASE_URL in .env.api.

  4. make lint && make typecheck && make test — verifies the toolchain before you touch any infrastructure. make test passes with no database running: every test that needs one skips itself when API_DATABASE_URL is unset or unreachable.

  5. make up — builds the three application images and starts all six services (see "Production deployment" for the topology). The API applies pending migrations before serving; every other service waits up to 30 seconds for its dependencies and exits non-zero if one never answers.

Initial admin bootstrap

POST /v1/admins is an ordinary post-bootstrap mutation: it requires the acting-admin guard (X-Admin-Id of an existing admin), so it cannot create the first admin on an empty roster. Bootstrap the first admin with the API-local operator CLI, which runs only inside the API service (the sole database owner) and exists in neither the web, bot, nor worker images. Both commands write a null-actor system audit row.

  1. With the stack up, create the first admin inside the API container:

    docker compose exec api uv run python -m yacourier_api.operator_cli admin-create --username <name>
    

    Omit --password to be prompted securely (recommended: passing --password exposes the plaintext via the process list and shell history). --telegram-id <id> is optional. The password must be 12-128 characters and non-blank; usernames must be unique — duplicates fail with a non-zero exit and no row is created or changed. Success prints ok: admin id=<id> username=<name>. Every admin holds identical rights; there is no role argument.

  2. Open the admin UI at http://localhost:8001 and log in with the new credentials.

  3. Maintenance (same container, same module):

    docker compose exec api uv run python -m yacourier_api.operator_cli admin-set-telegram --username <name> --telegram-id <id>
    docker compose exec api uv run python -m yacourier_api.operator_cli admin-set-telegram --username <name> --clear
    docker compose exec api uv run python -m yacourier_api.operator_cli --help
    

    Once the first admin exists, further admins can be added through the authenticated POST /v1/admins HTTP route instead of the CLI.

Running the database-backed tests

The database is not published to the host (see below), so host-run pytest needs the committed docker-compose.local.yml overlay (loopback-only 127.0.0.1:5432):

docker compose -f docker-compose.yml -f docker-compose.local.yml up -d postgres

set -a; . ./.env.api; set +a
export API_DATABASE_URL="postgresql+psycopg://api:$(cat secrets/postgres_api_password.txt)@127.0.0.1:5432/yacourier"

uv run alembic -c services/api/alembic.ini upgrade head
uv run pytest

With the four API_* variables exported, the run also covers the migration and real-database tests that are otherwise skipped.

Running the Redis-backed tests

The broadcast lease's Redis implementation is exercised against a real server when one is reachable; the same local overlay publishes Redis on loopback (127.0.0.1:6379):

docker compose -f docker-compose.yml -f docker-compose.local.yml up -d redis
export WORKER_REDIS_URL="redis://127.0.0.1:6379/0"

uv run pytest

With WORKER_REDIS_URL exported, the Redis tier runs instead of skipping; when it is unset or the server does not answer, those tests skip so make test still passes without Redis.

Broadcast images

A broadcast may carry one image: JPEG, PNG, or WebP; at most 10,000,000 bytes; width + height at most 10000 pixels; and longer-to-shorter side ratio at most 20. The format is detected by decoding the content, never from the filename or the declared type, and an image whose declared pixel count exceeds the 25,000,000 cap is refused before decoding. Message text is 1-4096 characters, or 1-1024 when an image is attached (Telegram's caption limit) — enforced in the web composer, the API, and a database CHECK. Text-only broadcasts are unchanged.

  • POST /v1/broadcasts is multipart/form-data: a payload field carrying the BroadcastCreate JSON plus an optional image file part.
  • GET /v1/broadcasts/{id}/image (Web credential) and GET /internal/broadcasts/{id}/image (Worker credential) return the stored bytes with the stored content type, 404 without one.
  • POST /internal/broadcasts/{id}/abort (Worker credential) fails every still-pending delivery with the reason after an image-level Telegram rejection and is idempotent.
  • The admin UI serves images through its own authenticated proxy at GET /broadcasts/{id}/image, shows a preview in the composer (revoked object URL), and marks image broadcasts in the history and detail pages.

New locked dependencies: pillow>=12.3.0 and python-multipart>=0.0.32. The web Content-Security-Policy img-src is 'self' data: blob: — the blob: source covers the local composer preview, which static/js/broadcast_composer.js creates and revokes.

Commands

make install    # uv sync --frozen --all-packages
make lint       # ruff check + ruff format --check on packages/ services/ scripts/
make typecheck  # mypy, strict, service src/ trees plus scripts/
make test       # pytest across every workspace member and scripts/
make up         # docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build

Production deploys the base file alone. A bare docker compose up also stays production-shaped, because no auto-loaded docker-compose.override.yml exists:

docker compose -f docker-compose.yml up --build -d

Finer-grained work goes through uv run directly:

uv run pytest services/api/tests/test_security.py -v
uv run alembic -c services/api/alembic.ini heads
uv run alembic -c services/api/alembic.ini upgrade head
docker compose exec api uv run alembic -c services/api/alembic.ini heads
docker compose exec api uv run python -m yacourier_api.operator_cli --help

Environment variables

Every variable is prefixed with its service name, including log level and environment — there is no shared unprefixed LOG_LEVEL. Only the API may hold a database URL. All values below are placeholders; real secrets live in gitignored .env.<service> files (see "Secrets").

Shared by every service (prefixed per service, e.g. API_LOG_LEVEL, WEB_ENVIRONMENT): *_LOG_LEVEL (optional, default INFO, one of DEBUG/INFO/WARNING/ERROR/CRITICAL case-insensitive) and *_ENVIRONMENT (optional, default production, only development or production — anything else aborts startup). The tables below omit these two.

ApiSettings:

Variable Required Placeholder Notes
API_WEB_SERVICE_TOKEN yes 32+ random chars Must differ from the other two
API_BOT_SERVICE_TOKEN yes 32+ random chars Must differ from the other two
API_WORKER_SERVICE_TOKEN yes 32+ random chars Must differ from the other two
API_DATABASE_URL yes postgresql+psycopg://api:<password>@postgres:5432/yacourier The workspace's only database URL
API_REDIS_URL no (required in production) redis://redis:6379/0 Without it the API uses an in-memory publisher and crash recovery is disabled

BotSettings:

Variable Required Placeholder Notes
BOT_TELEGRAM_TOKEN yes Telegram bot token Present only for bot and worker
BOT_SERVICE_TOKEN yes 32+ random chars Must equal API_BOT_SERVICE_TOKEN
BOT_API_BASE_URL no http://api:8000
BOT_MODE no polling The only supported v1 value

WorkerSettings (same image as the bot with a worker entrypoint):

Variable Required Placeholder Notes
WORKER_TELEGRAM_TOKEN yes Telegram bot token Same token as the bot
WORKER_SERVICE_TOKEN yes 32+ random chars Must equal API_WORKER_SERVICE_TOKEN; also authorizes the shared internal Admin Telegram-id list and courier read by id used for registration notifications
WORKER_API_BASE_URL no http://api:8000
WORKER_REDIS_URL no (required in production) redis://redis:6379/0 Read by the worker entrypoint; without it the worker refuses to start when WORKER_ENVIRONMENT=production (tasks 3.3, D12)

WebSettings:

Variable Required Placeholder Notes
WEB_SERVICE_TOKEN yes 32+ random chars Must equal API_WEB_SERVICE_TOKEN
WEB_API_BASE_URL no http://api:8000
WEB_SESSION_SECRET_KEY yes Random signing key, 32+ chars Cookie/CSRF signing key; shorter keys abort startup
WEB_SESSION_COOKIE_SECURE no true false only with WEB_ENVIRONMENT=development
WEB_HOST no 0.0.0.0 Bind for gunicorn and python -m yacourier_web
WEB_PORT no 8001 1-65535; gunicorn follows it, Compose's 8001:8001 mapping must be updated to match
WEB_THREADS no 4 1-32; gunicorn gthread thread pool for the single web process
WEB_USER_PAGE_SIZE no 25 1-100; users list rows per page

Web sessions are stateless signed cookies (yacourier_session, signed under salt yacourier-web-session-v2) carrying the Admin id, username, issue time, and last-request time. They survive a web restart, and any web process with the same WEB_SESSION_SECRET_KEY accepts them. Logout clears the cookie, but a copied cookie stays usable until its idle expiry (30 minutes without a request) or absolute expiry (12 hours from issue), whichever comes first; rotating WEB_SESSION_SECRET_KEY is the kill switch that rejects every previously issued cookie. The web runs one gunicorn gthread process (WEB_THREADS threads), which is what makes the in-memory login lockout exact; the lockout resets on web restart.

PostgreSQL bootstrap (Compose only, never given to application containers):

Variable Required Placeholder Notes
POSTGRES_BOOTSTRAP_PASSWORD yes Random password Via secrets/postgres_bootstrap_password.txt
POSTGRES_API_PASSWORD yes Random password Must match the password in API_DATABASE_URL, via secrets/postgres_api_password.txt

Secrets

Nothing secret is committed. Real values live in gitignored .env.<service> files (.env.api, .env.bot, .env.worker, .env.web) and the gitignored secrets/ directory. Each service holds only its own credential, and only the API holds the database URL. The Worker_Credential is additionally authorized for two shared internal reads — the Admin Telegram-id list and the courier read by id — because the worker delivers registration notifications; the Admin membership check stays bot-only. Deployment replaces both the env files and secrets/ with a secret manager without renaming any variable.

Interpreter version

Python 3.14 (.python-version). greenlet 3.5.5 and psycopg-binary 3.3.5 gate the choice: at lock time both resolved to prebuilt cp314 wheels for linux x86_64 rather than building from source, so the workspace stays on 3.14.

Production deployment

Six services from three application images plus two pinned third-party images:

  • postgres (pgvector/pgvector:0.8.6-pg18 by digest) and api (yacourier-api:0.1.0) alone on the internal db_private network; the PostgreSQL volume mounts at /var/lib/postgresql.
  • redis (redis:8.2-alpine by digest) sits alone on broker_net, which only api and the worker (yacourier-bot:0.1.0 with a taskiq worker yacourier_bot.worker:broker entrypoint) also join, so web and bot have no network path to the broker.
  • api, web (yacourier-web:0.1.0), bot, and the worker share the app_net network for all HTTP traffic.
  • Only web publishes a host port (8001:8001); nothing else is published and no service uses a bind mount (the sole exception is the read-only ./services/api/db-init:/docker-entrypoint-initdb.d:ro init script mount on postgres). The web image serves yacourier_web.wsgi:app under gunicorn (one gthread process with WEB_THREADS threads, default 4, 90-second worker timeout); its on_starting hook waits up to 30 seconds for the API and exits non-zero when the backend never answers.
  • Per-service env files and Docker secrets for the two PostgreSQL passwords; every one of the six containers runs with a non-root user and data volumes initialize with the configured ownership.

For development, make up adds docker-compose.dev.yml explicitly: bind mounts for each service source plus packages/common, reload for the API/web/worker, the API and web ports rebound to loopback-only, and the insecure-cookie flag set only alongside an explicit WEB_ENVIRONMENT=development. PostgreSQL stays unpublished in both files; run database diagnostics through the API container (docker compose exec api uv run alembic -c services/api/alembic.ini heads), or use the committed docker-compose.local.yml overlay to publish it to the host for tests.

Manual Telegram verification

The test suite deliberately never calls the live Telegram API. Before trusting a release against real Telegram, verify manually:

  1. Create a bot with BotFather, copy its token into BOT_TELEGRAM_TOKEN and WORKER_TELEGRAM_TOKEN (.env.bot, .env.worker), and set matching service credentials across .env.api and the other env files.
  2. docker compose -f docker-compose.yml up --build -d, then create the first admin as described in "Initial admin bootstrap" and open the admin UI at http://localhost:8001.
  3. From a personal Telegram account, /start the bot, complete the city/transport/phone flow, and confirm the courier appears in the admin user list with the chosen codes.
  4. As the admin, compose a broadcast to that courier's city/transport, confirm the count preview, send it, and confirm delivery on the device plus done status and zero failures in the broadcast detail page. Then block the bot on the device (Stop), send a second broadcast, and confirm the courier flips to the blocked badge and the failure list records it.
  5. For an image broadcast, attach a JPEG/PNG/WebP in the composer, confirm the local preview and the tightened 1024-character caption limit, and send it. Confirm the photo with its caption arrives on the device and the detail page shows the stored image. A text-only broadcast must look exactly as before.