A Telegram bot for selling digital goods (accounts, keys, licensesâŠ): catalog and stock, cart, multiple payment methods, a role-based admin panel (inâchat and web), and optional Redis caching.
Selling physical goods instead? (inventory, shipping, delivery addresses) â use the Telegram Physical Goods Shop.
- Features
- Security
- Tech Stack
- Architecture
- Configuration
- Installation
- Admin panel
- Feature tour
- Testing
- Catalog â categories and products, per-unit stock that is either limited (one row per account/key, consumed on purchase) or unlimited (one value delivered every time), plus optional timeâlimited perâproduct sales.
- Search â find a product by name or description instead of paging categories; results
are paginated and open the normal product page. Backed by trigram (GIN) indexes on
PostgreSQL, with a graceful fallback when the
pg_trgmextension isn't available. - Cart & promo codes â add multiple items with quantities, apply a promo per item,
atomic multiâitem checkout with a receipt. Promo types:
percent,fixed,balance; with usage limits, expiry, and category/item binding. A promo stacks on top of an active sale; apercentpromo scales per unit while afixedone comes off the line once. - Restock notifications â an outâofâstock product offers "notify me"; when stock arrives (from the bot or the web panel) everyone waiting is messaged once and unsubscribed.
- Payments â CryptoPay (crypto), Telegram Stars, and Telegram Payments (fiat). Balance topâup model; purchases are paid from balance. Processing is idempotent and transactional.
- Reviews â 1â5â ratings with optional text, one per user per item.
- Referrals â configurable commission on referred users' topâups.
- Roles (RBAC) â 10 granular permission bits, builtâin
USER/ADMIN/OWNERplus custom roles. You can never grant a permission you don't hold yourself; the admin UI only shows buttons your role allows. - Admin â an inâchat admin menu and a web panel (SQLAdmin) with a builtâin help page, CSV export, and a full audit log. Broadcast messaging, user/balance management, catalog and promo management, statistics.
- Performance â fully async DB (
asyncpg+ async SQLAlchemy). Hot paths stay off both the database and the network: a whole update's rateâlimit decision is one Redis call, roles and blocked checks are served from process memory, audit rows are batched into one multiârow insert, cart promo validation is batched (no perâline queries), paginator counts and hot lookups are cached, and cache misses are singleâflighted so an expiring hot key doesn't stampede the DB. Writes invalidate caches by name in a single roundâtrip â never by scanning the keyspace. Every paginated list orders by a unique tiebreaker, so paging never repeats or skips a row. Optional Redis caching and persistent FSM storage; the bot runs fine without Redis (inâmemory FSM, no caching), and falls back to that automatically if Redis is configured but unreachable at startup rather than failing on every update. - Localization â Russian and English.
Implemented, and described honestly so you know what to rely on:
- Payments & money â serverâside amount validation before accepting a payment; idempotent
processing (a
unique(provider, external_id)constraint plus aFOR UPDATElookup, so a retried/duplicate callback credits once); balance changes run under row locks (ACID); a circuit breaker pauses CryptoPay calls after repeated failures; selfâreferral is blocked by DBCHECKconstraints and a transaction guard. Doubleâspend on a purchase is prevented at the database layer (row locks + stock removal + idempotent records), not by trusting the client. - Access control â TelegramâID authentication; a 10âbit permission bitmask with bitwise subset validation (you cannot create or assign a role exceeding your own). A permission bitmask lives in exactly one cache â an inâprocess tier backed by Redis (when enabled) â so there is a single place to invalidate: every role change, block, or webâpanel edit clears both tiers immediately.
- Rate limiting â global and perâaction limits with temporary bans. When Redis is enabled the limiter state is shared and the whole decision â ban check, both sliding windows, and the autoâban on a global overrun â is evaluated in one atomic script, so concurrent updates can't slip past the limit; without Redis it degrades to a perâprocess inâmemory limiter with the same verdicts. Admins bypass the windows but are still subject to a ban. The webâpanel login limiter (5 attempts / 15 min per IP) and 30âminute sessions remain perâprocess.
- Web panel â constantâtime credential/secret comparison; proxyâaware client IP (trusts
XâForwardedâForonly when the socket peer is loopback, so an external client can't spoof it); remote login with the defaultadmin/adminis blocked; every create/edit/delete is auditâlogged; financial tables are readâonly. - Input handling â all database access is parameterized via the SQLAlchemy ORM (no raw
SQL); userâfacing text is HTMLâescaped on render, and broadcast/category text is sanitized;
search queries have their
LIKEwildcards escaped, so typing100%searches for that literal rather than matching the whole catalog; CSV export neutralizes spreadsheet formula injection; item names are controlâcharacter filtered. - Staleâaction guard â taps on a transactional message older than 1 hour are rejected.
Python 3.11+ · aiogram 3 · PostgreSQL 16 (async SQLAlchemy 2.0 + asyncpg) · Alembic ·
Redis 7 (optional) · SQLAdmin + Starlette (web panel) · Pydantic · Docker.
System architecture (click to expand)
Everything runs in one process on one asyncio event loop: the bot, the web panel, and the background workers. There is no broker and no worker pool â the "services" below are just long-lived tasks.
How an update becomes a handler call
flowchart TD
U([Telegram user]) --> API[Telegram Bot API]
API -->|long polling · default| DP
API -->|webhook POST| WH["POST /webhook<br/>own Starlette app on WEBHOOK_PORT"]
WH -->|secret token compared in constant time| DP
DP["aiogram Dispatcher<br/>allowed updates: message, callback_query,<br/>pre_checkout_query, successful_payment"]
DP --> M1["RateLimit<br/>global 30/min + per-action buckets"]
M1 --> M2["Analytics<br/>metrics + conversion funnels"]
M2 --> M3["Auth<br/>role cache · blocked users"]
M3 --> M4["Security<br/>audit · maintenance gate · 1h replay guard"]
M4 --> R["Routers: admin â other â user"]
R --> H[Handler]
The middleware order is the order they are registered in
bot/main.py â aiogram runs the first-registered outermost, so
rate limiting rejects a flood before anything else does work.
What runs, and what it talks to
flowchart TD
ADMIN([Admin browser]) --> UV
TG[Telegram Bot API] <--> DP
subgraph proc["Bot process â one asyncio loop"]
DP["aiogram Dispatcher"]
UV["uvicorn · Starlette<br/>SQLAdmin · /health · /metrics · /export"]
RM["RecoveryManager<br/>CryptoPay sweep 5 min · health 60 s"]
CM["CleanupManager<br/>daily retention"]
CS["CacheScheduler<br/>stats hourly · daily 03:00"]
end
CP["CryptoPay API"]
PG[("PostgreSQL 16")]
RD[("Redis 7 â optional")]
FS["logs/ · data/"]
DP <--> CP
RM <--> CP
DP --> PG
UV --> PG
RM --> PG
CM --> PG
DP --> RD
CS --> RD
DP --> FS
UV -.->|restock notify| TG
Worth knowing:
- Webhook mode runs its own listener.
WEBHOOK_ENABLED=1starts a second, minimal Starlette app onWEBHOOK_HOST:WEBHOOK_PORTserving nothing butPOST {WEBHOOK_PATH}; the secret header is compared withhmac.compare_digest. It is deliberately not mounted on the admin app â Telegram has to reach the webhook, and nothing else should come along with it. Point your TLS-terminating reverse proxy at the webhook port only. - Redis is optional. Without it: in-memory FSM, no caching, and a per-process rate limiter and role cache. With it, that state is shared and survives a restart. The connection is verified with a PING at startup, so a configured-but-unreachable Redis degrades to in-memory storage instead of breaking every update.
- The web panel is not read-only bookkeeping. It runs in the same process as the bot, so an edit there clears the same caches and can message users â that is how a restock added in the panel reaches the people waiting for it.
- Shutdown is graceful: tasks stopped, metrics snapshot written to
data/final_metrics.json, webhook removed, buffered audit rows flushed, CryptoPay session and DB engine closed.
Database schema (click to expand)
Two views of the same 15 tables: the product side and the people/money side.
Exact columns, indexes and CHECK constraints live in
bot/database/models/main.py â the notes under the
diagrams say what each table is for.
Catalog & stock
erDiagram
categories ||--o{ goods: "groups"
goods ||--o{ item_values: "sellable units"
goods ||--o{ cart_items: "in carts"
goods ||--o{ reviews: "rated by"
goods ||--o{ stock_subscriptions: "waited for"
categories ||--o{ promo_codes: "optional binding"
goods ||--o{ promo_codes: "optional binding"
Users, money & access
erDiagram
roles ||--o{ users: "role_id (RESTRICT)"
users ||--o{ users: "referral_id (self)"
users ||--o{ payments: "top-ups (idempotent)"
users ||--o{ operations: "balance ledger"
users ||--o{ referral_earnings: "commission"
users ||--o{ bought_goods: "purchase history"
users ||--o{ promo_code_usages: "redeemed"
promo_codes ||--o{ promo_code_usages: "once per user"
audit_log is absent from both on purpose: its user_id carries no foreign
key, so the trail outlives the user it refers to.
The data model, in plain terms:
- users â one row per Telegram user: balance, role, and (optionally) who referred them.
- roles â a name plus a permission bitmask (see the permission table under Admin features).
- categories â goods (products) â item_values (stock) â a product belongs to a category and
its sellable units live in
item_values(one row per account/key, or a singleis_infinityrow for unlimited delivery). - cart_items / reviews â reference their product by foreign key, so a rename or delete
never leaves them dangling. A cart holds one row per product with a
quantity(unique per user+product,CHECK (quantity > 0)). - stock_subscriptions â who is waiting for an outâofâstock product. Rows are consumed when the notification is sent, which is what stops a restock from messaging twice.
- bought_goods â purchase history, one row per delivered unit (each carries its own value, and its price is the perâunit share of what was charged). It keeps the product name as a snapshot so history survives even if the product is later removed.
- payments â one row per topâup, unique per
(provider, external_id)so a duplicate/retried callback can only credit once. operations is the balance ledger (topâups, deductions, referral credits). - promo_codes (+ perâuser usages) â a promo can be bound to a category or a product. It
carries its own
scopebecause the bindings areON DELETE SET NULL. A promo whose target is gone stays scoped and applies to nothing. - referral_earnings, and an audit_log of every admin action. All money is stored as
exact
NUMERIC(12,2)â never floats.
Copy .env.example to .env and fill it in. TOKEN, OWNER_ID and the POSTGRES_* values
are required; everything else has a sensible default.
Telegram & payments
| Variable | Description | Default |
|---|---|---|
TOKEN |
Bot token from @BotFather | required |
OWNER_ID |
Your Telegram ID â becomes the first OWNER | required |
TELEGRAM_PROVIDER_TOKEN |
Token for Telegram Payments (fiat) | â |
CRYPTO_PAY_TOKEN |
CryptoPay API token | â |
STARS_PER_VALUE |
Telegram Stars exchange rate (0 disables Stars) |
0.91 |
PAY_CURRENCY |
Display currency (RUB, USD, EURâŠ) | RUB |
REFERRAL_PERCENT |
Referral commission % (0â99) | 0 |
PAYMENT_TIME |
Invoice validity, seconds | 1800 |
MIN_AMOUNT / MAX_AMOUNT |
Allowed topâup range | 20 / 10000 |
Links, locale & logging
| Variable | Description | Default |
|---|---|---|
CHANNEL_URL / CHANNEL_ID |
Optional news channel (newâproduct posts, subscription check) | â |
HELPER_ID |
Support user Telegram ID | â |
RULES |
Rules text shown in the bot | â |
BOT_LOCALE |
ru or en |
ru |
BOT_LOGFILE / BOT_AUDITFILE |
Log file paths | logs/bot.log / logs/audit.log |
LOG_TO_STDOUT / LOG_TO_FILE / DEBUG |
1/0 toggles |
1 / 1 / 0 |
REVIEWS_ENABLED |
Enable product reviews (1/0) |
1 |
Web admin panel
| Variable | Description | Default |
|---|---|---|
ADMIN_HOST / ADMIN_PORT |
Bind address / port | localhost / 9090 |
ADMIN_USERNAME / ADMIN_PASSWORD |
Panel login | admin / admin |
SECRET_KEY |
Session signing key | change-me-in-production |
ADMIN_COOKIE_SECURE |
Mark session cookie Secure; auto = on unless loopback-only |
auto (or 1 / 0) |
SECRET_KEY signs the admin session cookie, so leaving it at the shipped default means anyone
who can reach the panel can forge a logged-in session and export every user and payment.
The bot refuses to start with a default SECRET_KEY or ADMIN_PASSWORD when the panel is
reachable â that is, when ADMIN_HOST is not loopback or WEBHOOK_ENABLED=1. On a
loopback-only bind it is a warning instead, so local development still works.
In Docker the panel is published on 127.0.0.1:9090 only. ADMIN_COOKIE_SECURE (auto by
default) marks the session cookie Secure whenever the panel is not loopback-only; set it to
0 if you deliberately terminate TLS elsewhere and need plain HTTP on the hop.
Database, Redis, webhook, cleanup
| Variable | Description | Default |
|---|---|---|
POSTGRES_DB / POSTGRES_USER / POSTGRES_PASSWORD |
Database credentials | required |
POSTGRES_HOST / DB_PORT |
Host / port | localhost (or db in Docker) / 5432 |
DB_POOL_SIZE / DB_MAX_OVERFLOW |
Connection pool size and burst headroom | 10 / 20 |
REDIS_ENABLED |
1 = Redis caching + persistent FSM; 0 = inâmemory, no cache |
1 |
REDIS_HOST / REDIS_PORT / REDIS_DB / REDIS_PASSWORD |
Redis connection | localhost / 6379 / 0 / â |
WEBHOOK_ENABLED / WEBHOOK_URL / WEBHOOK_PATH / WEBHOOK_SECRET |
Webhook mode (default: long polling) | 0 / â / /webhook / â |
WEBHOOK_HOST / WEBHOOK_PORT |
Bind address of the webhook listener (its own app, not the panel) | 0.0.0.0 / 8080 |
AUDIT_RETENTION_DAYS / PAYMENTS_RETENTION_DAYS |
Autoâcleanup age in days; 0 or negative disables the sweep |
90 / 90 |
git clone https://github.com/interlumpen/Telegram-shop.git
cd Telegram-shop
cp .env.example .env # then edit .env
docker compose up -d --buildPostgres, Redis and the bot all start together, and the bot waits for the first two to report
healthy. To run without caching, set REDIS_ENABLED=0 in .env â the bot then ignores Redis
entirely (inâmemory FSM, no caching).
The container applies migrations (alembic upgrade head), seeds roles, starts the bot, and
launches the admin panel at http://localhost:9090/admin. Logs: docker compose logs -f bot.
On Linux, if
./logsor./datahit permission errors, setPUID/PGIDin.envto your host user (idshows them).
python3.11 -m venv venv && source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # then edit .env
alembic upgrade head # required â the app does not create the schema itself
python run.pyVerify: send /start to the bot (the OWNER_ID user gets the OWNER role), and open
http://localhost:9090/admin.
Two ways to manage the shop:
- Inâchat menu â open it from the bot; buttons are shown according to your permissions. Best for quick catalog and role edits (permissions are clickâtoâtoggle there).
- Web panel (SQLAdmin,
/admin) â browse/search/edit every table. The landing page is a builtâin cheat sheet explaining the productâstock workflow and the permission bitmask.
Selling goods: a Product is the listing; its sellable units are separate Stock Items
(one per account/key, or a single is_infinity unit for unlimited delivery). Renaming or
deleting a product keeps carts, reviews, and purchase history consistent automatically.
Creating a Stock Item in the panel is a first-class way to restock: it clears the product's cached stock count and notifies everyone waiting on that product, exactly as the inâchat flow does.
/healthâ liveness probe. Public callers get only{"status": "healthy"}/ 503 (503 when the DB is down); the full component breakdown (Redis, uptime) is returned only to an authenticated session./metrics,/metrics/prometheusâ metrics (auth required)./export/{users,purchases,operations,payments}â CSV export with optional date filtering.
Background workers recover stuck CryptoPay payments (checked every 5 min, verified against the API, idempotent), run periodic DB/Redis health checks (which also replay cache invalidations deferred during a Redis outage), and clean up old audit logs / pending payments. File logging is queued off the event loop, so disk writes never stall update handling; the audit trail is written to file synchronously and to the database in batches, so a crash can cost the DB copy of the last few seconds but never the log itself. Shutdown is graceful (tasks cancelled, metrics snapshot saved, log queues and the audit buffer flushed, connections closed).
đ€ User features (click to expand)
The bot's home screen. Admins additionally see an Admin panel button here.
Shop â categories â products in a category. Outâofâstock products are still listed but can't be bought.
The shop menu also offers đ Search: type a name or a keyword and get matching products straight away â matches are looked for in both the product name and its description, so you don't have to remember which category something lives in.
Each product shows its price (with any active sale/promo already applied), how many units are left (or â), and its review rating. Buying pays from your balance and the stock value (account/key/âŠ) is delivered instantly in chat.
If a product is sold out, the page offers đ Notify me when in stock instead of a dead end: you get a single message as soon as it's restocked, and the subscription is dropped.
The shop uses a balance model: you top up once (CryptoPay, Telegram Stars, or fiat via
Telegram Payments) and then spend from balance. The invoice is valid for PAYMENT_TIME seconds.
Add several products, set how many of each with the â/â stepper, attach a promo code per item, then check out in one atomic transaction with a formatted receipt. Every unit gets its own delivered value, so buying 3 keys hands you 3 different keys.
If the promo stops applying between adding and checkout â it expired, ran out, or its category was deleted â the line says so and shows the real price, and the checkout aborts rather than silently charging full price.
The same goes for stock: if fewer units are left than you asked for, the checkout is refused and nothing is charged (a product that has sold out entirely is simply dropped from the cart and the rest goes through).
Share your personal link (it carries your Telegram ID as the /start payload). When someone
who joined through it tops up, you earn REFERRAL_PERCENT% of that topâup. Selfâreferral is
blocked.
Purchases lists everything you've bought (you can reâview the delivered value). Operation history is your money ledger â topâups, purchases, and referral credits.
đïž Admin features (click to expand)
Every button below is gated by your permissions â you only see what your role allows.
The hub for admins. From here: statistics, user management, catalog management, and boughtâitem search (find a purchase by its unique ID for support).
Create/edit/delete categories and products. When adding stock you choose limited (paste one
value per unit â each is consumed on purchase) or unlimited (one is_infinity value
delivered on every purchase). You can also set a timeâlimited sale (a % off with an expiry);
the sale price is computed serverâside and a promo code stacks on top of it.
When you add stock, two things happen automatically:
- Waiting users are notified. Anyone subscribed to that product gets a single "back in stock" message and is unsubscribed. This fires whether the stock was added from the inâchat menu or from the web panel.
- The news channel can be posted to (
CHANNEL_ID), announcing the product and how many units were added. This one is inâchat only.
Open a user to view their profile, adjust balance (topâup/deduct â a separate BALANCE
permission), block/unblock, assign a role, and browse their referrals and purchases.
Create custom roles by toggling permission bits (the bot's role menu is clickâtoâtoggle).
Two rules keep this safe: you can never grant a permission you don't hold yourself, and the
builtâin USER/ADMIN/OWNER roles can't be deleted.
| Permission | Value | Grants |
|---|---|---|
USE |
1 | Basic bot access |
BROADCAST |
2 | Mass messaging |
SETTINGS |
4 | Maintenance mode |
USERS |
8 | View / block users, referrals, purchases |
CATALOG |
16 | Categories, products, stock |
ADMINS |
32 | Create roles, assign roles |
OWNER |
64 | Ownerâonly operations |
STATS |
128 | Statistics, logs, item search |
BALANCE |
256 | Topâup / deduct balance |
PROMO |
512 | Promoâcode management |
A role's permissions is the sum of the values it grants (e.g. USE + CATALOG + STATS =
1 + 16 + 128 = 145).
Send a message to all users with a live progress counter; view shop statistics; read recent
logs. Maintenance mode (the SETTINGS permission) temporarily blocks regular users while
admins keep working.
You can do all the same things in SQLAdmin! Edits made there are not secondâclass: they clear the caches they affect (user, role, product, stock, review rating) and adding stock notifies the users waiting for it, just like the inâchat flow.
958 tests, 76 % line coverage (pytest). The data layer runs against a
real inâmemory async SQLite database (real SQL, transactions, and constraints) â only external
services are mocked (Telegram Bot API, CryptoPay, Redis). What's covered:
- Transactions & money â purchase and cartâcheckout atomicity (balance deducted, stock removed, rollback on error), quantity checkout (one row per unit, partial stock aborts without charging, perâunit prices summing back to the charge), promo semantics at quantity, payment idempotency, topâups crediting exactly the invoiced amount for every payment method, atomic stock replacement (a rejected rename leaves the old stock intact), atomic admin balance changes, referral bonus calculation.
- Promo codes & sales â every validation path (buy, cart checkout, balance redeem, readâonly validate), scope enforcement (a promo whose bound category/product was deleted applies to nothing), what the cart displays matching what checkout charges, sale pricing, and promoâonâsale stacking.
- CRUD â users, roles (incl. custom create/edit/delete), categories, products, stock, cart (quantities, perâuser uniqueness), stock subscriptions, reviews, payments, operations; duplicate/blocking handling; stats queries. Bulk stock upload is checked both for what it reports (values already stored, repeats inside the pasted batch, blanks) and for what it costs â a guard asserts a 50âvalue upload stays a handful of statements rather than drifting back into a perâvalue loop.
- Statistics â the dashboard aggregates, which are issued as one statement each: totals per table, revenue summed from real purchases, and the daily figures respecting their day window so yesterday's numbers don't leak into today's.
- Security & middleware â rate limiting and bans (both backends reaching the same verdicts, an action overrun still counting against the global window, admins bypassing the windows but not a ban, and every mapped action having a limit), permissionâbitmask helpers, critical / replayâaction detection, authentication, the webâpanel login limiter, and roleâcache behavior (inâprocess tier served first, Redis consulted once it is dropped).
- Handlers â user flows (
/start, profile, shop, search, cart, referrals) and admin flows (user/role/balance management, catalog, paginated lists, profile views). - Admin FSM flows â promoâcode creation end to end (type, value, usage cap, expiry, and category/product binding) plus view/toggle/delete; adding a position and restocking one; editing a position and switching it between limited and unlimited stock, where a rejected rename must leave the old stock untouched; broadcast validation, its oneâatâaâtime guard, and cancellation.
- Data export â CSV streaming with keyset pagination, date filtering, an auth check on every endpoint, and formulaâinjection neutralization so a product name can't execute in a spreadsheet.
- Infrastructure â broadcast, restock notifications, payment recovery, metrics (including that userâsupplied callback data can't forge a metric line), caching & invalidation (webâpanel edits, a Redis outage deferring invalidations instead of dropping them, and the write path staying on named deletes so it never falls back to scanning the keyspace), paginator counts being reused across page turns and dropped when the user transacts, pagination, i18n, validators, and audit logging â including batching: rows land on flush, a full batch does not wait, shutdown drains the buffer, and a row enlisted in a caller's transaction is never buffered so it still rolls back with it.
- Keyboards & routing â every callback payload a keyboard generates fits Telegram's 64âbyte limit (a long or Cyrillic product name used to overflow it), and every pagination prefix a view produces has a handler registered for it, so an arrow button can't be a dead end.
pytest # full suite
pytest --cov=bot --cov-report=term-missing # with the coverage report



























