- Python 94.8%
- HTML 4.1%
- CSS 0.5%
- Dockerfile 0.3%
- JavaScript 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
Some checks failed
CI / checks (push) Has been cancelled
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. |
||
| .github/workflows | ||
| .kiro | ||
| .opencode | ||
| openspec | ||
| packages/common | ||
| scripts/legacy_users_import | ||
| services | ||
| .env.example | ||
| .gitignore | ||
| .pre-commit-config.yaml | ||
| .python-version | ||
| AGENTS.md | ||
| docker-compose.dev.yml | ||
| docker-compose.local.yml | ||
| docker-compose.yml | ||
| Makefile | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
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
uv0.12+- Docker with Compose v2 (
docker compose ..., not the standalonedocker-composebinary) - Python 3.14 — see "Interpreter version" below;
uvwill fetch it
Local setup
-
make install—uv sync --frozen --all-packages. A staleuv.lockfails the install instead of being silently updated. -
Copy
.env.exampleto each per-service file and replace every placeholder (.env.api,.env.bot,.env.worker,.env.webare gitignored;.env.exampleholds 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_TOKENequalsAPI_BOT_SERVICE_TOKEN, and so on). -
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.txtThe password in
secrets/postgres_api_password.txtmust match the one embedded inAPI_DATABASE_URLin.env.api. -
make lint && make typecheck && make test— verifies the toolchain before you touch any infrastructure.make testpasses with no database running: every test that needs one skips itself whenAPI_DATABASE_URLis unset or unreachable. -
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.
-
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
--passwordto be prompted securely (recommended: passing--passwordexposes 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 printsok: admin id=<id> username=<name>. Every admin holds identical rights; there is no role argument. -
Open the admin UI at
http://localhost:8001and log in with the new credentials. -
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 --helpOnce the first admin exists, further admins can be added through the authenticated
POST /v1/adminsHTTP 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/broadcastsismultipart/form-data: apayloadfield carrying theBroadcastCreateJSON plus an optionalimagefile part.GET /v1/broadcasts/{id}/image(Web credential) andGET /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-pg18by digest) andapi(yacourier-api:0.1.0) alone on the internaldb_privatenetwork; the PostgreSQL volume mounts at/var/lib/postgresql.redis(redis:8.2-alpineby digest) sits alone onbroker_net, which onlyapiand the worker (yacourier-bot:0.1.0with ataskiq worker yacourier_bot.worker:brokerentrypoint) also join, sowebandbothave no network path to the broker.api,web(yacourier-web:0.1.0),bot, and the worker share theapp_netnetwork for all HTTP traffic.- Only
webpublishes 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:roinit script mount onpostgres). The web image servesyacourier_web.wsgi:appunder gunicorn (onegthreadprocess withWEB_THREADSthreads, default 4, 90-second worker timeout); itson_startinghook 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:
- Create a bot with BotFather, copy its token into
BOT_TELEGRAM_TOKENandWORKER_TELEGRAM_TOKEN(.env.bot,.env.worker), and set matching service credentials across.env.apiand the other env files. 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 athttp://localhost:8001.- From a personal Telegram account,
/startthe bot, complete the city/transport/phone flow, and confirm the courier appears in the admin user list with the chosen codes. - 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
donestatus 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. - 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.