A production-ready Telegram bot built with Aiogram 3.x that requires users to join a configurable set of channels before they can use any bot functionality. Built with a clean, layered architecture on top of PostgreSQL + SQLAlchemy 2.x (async) + Alembic.
- Force-subscription gate for any number of channels, configured in one place
- Continuous re-validation: an already-verified user's membership is re-checked with Telegram on a TTL cache (default 10 min), so someone who leaves a channel is caught without calling the Telegram API on every single interaction
- Resilient to flaky connections: every outgoing Telegram API call -- not just verification checks -- automatically retries on a dropped connection or flood control before giving up
- Verification protects the entire bot by default -- new commands are gated automatically, with no per-handler boilerplate required
- Edits the verification message in place on failure/retry; deletes it and sends exactly one welcome message on success -- never spams the chat
- Repository pattern over an async SQLAlchemy engine, connection pooling,
race-safe upsert-on-
/start - Structured logging, global error handler, per-user callback throttling
- Owner-only
/statscommand
- Python 3.12+
- PostgreSQL 14+
- A Telegram bot token from @BotFather
- The bot added as an administrator to every required channel (see Troubleshooting -- this is a hard Telegram API requirement, not a bug)
git clone <your-repo-url> nextgen-bot
cd nextgen-bot
python3.12 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # then edit .env, see belowAll configuration lives in .env and is loaded centrally by
bot/config/settings.py -- nothing else in the codebase reads environment
variables directly.
| Variable | Required | Description |
|---|---|---|
BOT_TOKEN |
yes | Token from @BotFather |
OWNER_ID |
yes | Your numeric Telegram user ID; unlocks /stats |
DATABASE_URL |
yes | postgresql+asyncpg://user:pass@host:5432/dbname |
REQUIRED_CHANNELS |
no | Comma-separated channel usernames (default: the four NextGen channels) |
VERIFICATION_CACHE_TTL_SECONDS |
no | How long a verified user's membership stays trusted before re-checking (default 600 = 10 min) |
NETWORK_RETRY_MAX_ATTEMPTS |
no | Retries per outgoing API call on connection failure (default 2) |
NETWORK_RETRY_DELAY_SECONDS |
no | Delay between those retries (default 1.0) |
LOG_LEVEL |
no | DEBUG / INFO / WARNING / ERROR (default INFO) |
AUTO_CREATE_TABLES |
no | true to create tables from ORM metadata on startup (dev only) |
To change the required channels later, edit REQUIRED_CHANNELS and restart
the bot -- no code changes needed.
Apply migrations (recommended for any real deployment):
alembic upgrade headFor quick local testing without Postgres migrations, set
AUTO_CREATE_TABLES=true in .env instead and skip the step above -- the
bot will create the schema itself on startup.
When you change bot/models/user.py, generate a new migration with:
alembic revision --autogenerate -m "describe the change"
alembic upgrade headpython main.pyThe bot polls Telegram for updates; no public URL or webhook is required.
nextgen-bot/
├── main.py # Entry point: wiring + polling
├── bot/
│ ├── config/ # Settings (env vars) + logging setup
│ ├── models/ # SQLAlchemy ORM models
│ ├── database/ # Engine/session factory + base
│ │ └── repositories/ # Repository pattern (DB access only)
│ ├── services/ # Business logic (user, verification)
│ ├── middlewares/ # DB session, access control, throttling
│ │ # + network_retry.py (client session, not dispatch)
│ ├── filters/ # e.g. owner-only filter
│ ├── keyboards/ # Inline keyboard builders
│ ├── handlers/ # Telegram-facing routers
│ ├── states/ # Reserved for future FSM flows
│ └── utils/ # Constants, message templates
├── alembic/ # Migrations
├── logs/ # Rotating log files
├── requirements.txt
├── .env.example
├── Dockerfile
└── docker-compose.yml
Adding a real feature: create a new router in bot/handlers/, register
it in bot/handlers/__init__.py (before menu.router, which is the
catch-all and must stay last). It will be verification-gated automatically.
To exempt a handler from the gate, add flags={"skip_verification": True}
to its registration, the same way /start and the Verify button do.
sudo apt update && sudo apt install -y python3.12 python3.12-venv postgresql
# ... create DB/user, then follow Installation above inside e.g. /opt/nextgen-botCreate /etc/systemd/system/nextgen-bot.service:
[Unit]
Description=NextGen Verification Bot
After=network.target postgresql.service
[Service]
Type=simple
User=nextgen
WorkingDirectory=/opt/nextgen-bot
EnvironmentFile=/opt/nextgen-bot/.env
ExecStart=/opt/nextgen-bot/.venv/bin/python main.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now nextgen-bot
journalctl -u nextgen-bot -fcp .env.example .env # edit it, and set DATABASE_URL to use host "postgres"
docker compose up -d --build
docker compose exec bot alembic upgrade head- Push the repo, create a new Web/Background Worker service pointing at it (Render: choose Background Worker, not Web Service, since this bot polls rather than listening on a port).
- Add a managed PostgreSQL instance on the same platform and copy its connection string into
DATABASE_URL(adjust the scheme topostgresql+asyncpg://). - Set
BOT_TOKEN,OWNER_ID, and any other variables from the table above. - Set the start command to
alembic upgrade head && python main.py(or run the migration once from a shell/one-off job, then usepython main.pyas the persistent start command).
Verification always fails even though I joined every channel.
The bot must be an administrator of each required channel -- Telegram's
getChatMember does not return reliable data to bots that are only regular
members. Add the bot as admin (no special permissions needed beyond default)
in every channel listed in REQUIRED_CHANNELS.
Verification silently fails for one specific channel.
Check the logs -- VerificationService logs an error per channel it can't
read, distinguishing "bot isn't admin" from "bot was removed/blocked" from
other API errors.
"message is not modified" errors. Already handled -- this happens when a user presses Verify twice with the same missing channels; the bot suppresses it silently rather than crashing.
I changed REQUIRED_CHANNELS but nothing changed.
Restart the bot process; environment variables are read once at startup.
High memory/connection usage under load.
Tune pool_size / max_overflow in bot/database/engine.py against your
Postgres server's max_connections, and consider AUTO_CREATE_TABLES=false
plus proper indexes (already included) for large user counts.
- Network retries happen once, centrally -- not per call site.
bot/middlewares/network_retry.pyis a client session middleware (bot.session.middleware(...)), not a dispatch middleware like the others in that folder: it wraps every single outgoing Bot API call (sendMessage, editMessageText, getChatMember, all of it) and retries onTelegramNetworkError(connection resets/timeouts) andTelegramRetryAfter(flood control) before the exception ever reaches calling code. This replaced an earlier version that retried only insideVerificationService._check_channel-- that only protected membership checks, and a plainmessage.answer()failing from the same connection reset showed the gap. TuneNETWORK_RETRY_MAX_ATTEMPTS/NETWORK_RETRY_DELAY_SECONDSfor a flakier or steadier environment. This only reduces how often a transient blip surfaces as a failure; it doesn't change what a failure means to either caller (fail-closed for the Verify button, fail-open for re-validation) -- retries happen first, and only a request that fails even after retrying reaches that logic at all. - Re-validation fails open, not closed. If a periodic re-check can't
reach Telegram (network blip, bot temporarily lost admin rights, etc.),
VerificationService.revalidate()keeps the user verified and retries on their next interaction -- it only revokes on an explicit LEFT/KICKED/ RESTRICTED status. This is intentional: the alternative (revoking on any failure) would mass-deverify your user base during a Telegram or network outage.MembershipResult.missing_channels(used by the Verify button) keeps the opposite, fail-closed behavior, since an unconfirmed channel shouldn't let someone in for the first time. - Middleware ordering is load-bearing, not stylistic.
DatabaseMiddlewareis registered as outer;AccessControlMiddlewareis registered as inner (bot/middlewares/access_control.pyexplains why). aiogram only resolves a handler'sflagsafter filter-matching, which happens between the outer and inner stages -- ifAccessControlMiddlewarewere outer, it would never see theskip_verificationflag on/startor the Verify button, and no one could ever complete verification. Don't "simplify" this by moving it back to outer. - FSM storage is in-memory (
MemoryStorage). Fine for a single bot instance; if you scale to multiple workers, switch to Redis storage and moveThrottlingMiddleware's cache to Redis too, so both are shared across processes. - Polling vs. webhooks: this bot uses long polling for simplicity and
portability across all four deployment targets. For very high throughput,
a webhook-based
aiohttpserver is a drop-in alternative. - Required channels are configured via env var with a sensible default,
not a database table. That satisfies "changeable without code changes";
if you want channels editable without a restart (e.g. via an admin
command), move
ChannelConfigstorage into Postgres and cache it with a short TTL.
- Admin commands to add/remove required channels at runtime (DB-backed config)
- Webhook mode for high-throughput deployments
- Redis-backed FSM storage and throttling for multi-instance scaling
- Per-channel "last checked" caching to reduce
getChatMembercalls further - i18n for multi-language verification messages (
language_codeis already stored)