A Telegram bot that runs appointment booking for a small service business — a barber, a nail studio, a private tutor. Clients leave a short contact profile once, pick a service, see only the times that actually fit it, and book in a few taps. The master manages services, schedule (weekly hours or monthly open days, chosen at deploy) and time off, sees the client's name and phone on every card, and confirms or declines either from the Bookings screen or straight from the new-booking notification. Both sides get notified on every status change.
Navigation is a sticky inline hub opened by /start — there are no other slash
commands. Built on a layered architecture with the business logic isolated from
Telegram and SQL, and covered by unit tests.
| Technology | Purpose |
|---|---|
| Python 3.13 | Core language |
| aiogram 3.20 | Telegram Bot API framework |
| PostgreSQL 17 | Persistent storage for users, services, schedule and appointments |
| Redis 7.4 | FSM state storage for multi-step dialogs |
| psycopg 3 | Async PostgreSQL driver with connection pooling |
| Docker Compose | Runs the bot and all infrastructure services |
| pytest / pytest-asyncio | Unit tests for the domain layer |
| pgAdmin | Visual database management |
| environs | Typed environment variable parsing |
| aiohttp-socks | Optional HTTP/SOCKS5 proxy for the Telegram session |
The project is split into three layers with a strict dependency direction — outer layers know about inner ones, never the reverse.
app/
├── domain/ # Business logic. No aiogram, no SQL, no I/O.
│ ├── models/ # Immutable dataclasses: User, Service, Appointment
│ ├── enums/ # UserRole, AppointmentStatus
│ ├── exceptions.py # TimeConflict, WindowNotAvailable, ForbiddenBookingAction, ...
│ └── services/ # BookingService, AvailabilityService
│
├── infrastructure/ # Everything that talks to the outside world.
│ └── database/ # Connection pool and repositories (raw SQL only)
│
└── bot/ # Telegram presentation layer.
├── handlers/ # Hub leaves and callbacks, grouped by role
├── keyboards/ # Inline keyboards and typed CallbackData
├── middlewares/ # Transactions, user context, i18n, ban check
├── states/ # FSM state groups
├── utils/ # Notifications, sticky hub helpers, shared formatting
├── bot_commands.py # Telegram ☰ menu (/start only)
└── i18n/ # Locale resolution
Why it matters in practice. BookingService never imports aiogram or psycopg —
it depends only on repository objects passed into it. That is what makes the booking
rules testable without a database, a Redis instance or a Telegram token: the test
suite swaps in in-memory fakes and runs in well under a second.
Repositories hold only SQL. Handlers hold only dialog flow and formatting. A rule like "only free windows from the master's schedule are offered" is written once, in the domain, and applies no matter which handler triggers it.
Each update is wrapped in a single database transaction by DataBaseMiddleware, so a
failure halfway through a booking cannot leave a half-written appointment behind.
- Contact profile — first name, last name and phone collected once before the first booking, via a share-contact button or manual input; editable later from Profile
- Guided booking — Book: service → day → time → confirmation
- Services catalog — browse active services with description and photo (separate from booking; card opens as a new message)
- Only bookable times are shown — windows outside the master's open schedule (weekly hours or monthly open days), blocked by time off, already taken, or in the past are filtered out before the client sees them
- My bookings — sticky list of upcoming appointments; open a card to cancel
- Self-service cancellation — cancel your own booking; the master is notified
- Status notifications — a message arrives when the master confirms or declines (dismiss with OK)
- Bookings — sticky week view → day → appointment card (navigate weeks with ← / →)
- Client name and phone on every card and notification — not just a Telegram id, so the master can actually call the person
- One-tap confirm / decline — from a booking card or directly from the new-booking push; past slots are read-only (no action buttons), and a stale button is rejected server-side
- Services — catalogue with title, duration, price, description and photo; add, edit (including description/photo from the card) and soft deactivate
- Schedule → Working hours (when
SCHEDULE_MODE=weekly) — view / edit repeating weekly intervals - Schedule → Work days (when
SCHEDULE_MODE=monthly) — pick open days on a month calendar, set hours for newly selected days (other days keep their hours), close days with an optional warn if bookings exist; Show current schedule lists saved days and hours - Schedule → Time off — view / edit upcoming absences: full days / date ranges or hours in one day; past-only blocks are rejected because the list shows upcoming intervals only (in monthly mode, a full closed day is usually an untoggled work day; use time off for a partial-day block inside an open day)
- Schedule → Break between appointments — set
gap_minutes(pause after each visit before the next bookable start;0= back-to-back) - Schedule → Minimum lead time — set
min_lead_minutes(clients cannot book a start sooner than this many minutes from now;0= allow immediately) - Schedule → Slot grid step — set
slot_step_minutes(spacing between offered start times; default / reset = step equals the chosen service duration)
- Moderation on the hub — User card, Set role, Ban and Unban as root actions (no client booking or profile in the admin menu); flows ask for id/@ on the sticky hub message, then restore the menu and send a short result notice with OK
- Shadowban — banned users get no reply at all, so they cannot tell they were blocked and cannot probe the bot for a reaction
- Guard rails — an admin cannot ban themselves, demote themselves, or ban other staff
- Sticky hub —
/startopens (or reuses) one role-specific button menu; screens edit that message in place instead of flooding the chat - Bilingual interface — Russian and English, switchable at runtime under Settings → Language
- Language resolution chain — explicit choice → Telegram client language → default
- Profile gate — Book asks for the contact profile first; everything else stays available without it
- Inline Cancel — multi-step flows (booking, profile, services, schedule, time off, gap, min lead, slot step, admin) abort with a button, not a slash command
- Username sync — a changed Telegram
@usernameis picked up automatically, so admin lookups by username keep working - Concurrency safety — a database exclusion constraint, not an application check, guarantees two clients can never book overlapping times for the same master
- UTC everywhere — all timestamps stored as
TIMESTAMPTZ - Structured logging with a configurable level and rotating Docker log files
The Telegram ☰ menu exposes only /start (restart / open the hub). Everything
else is inline buttons on the sticky hub message.
| Hub path | Role | What it does |
|---|---|---|
| Book | client | Book an appointment (asks for the profile first if empty) |
| Services | client | Browse active services (description / photo) |
| My bookings | client | Upcoming appointments (open / cancel) |
| Profile → Show / Edit | client | View or update name and phone |
| Bookings | master | Week → day → card (confirm / cancel) |
| Services | master | List, add, edit, description/photo, deactivate |
| Schedule → Working hours | master | Weekly mode: view / edit repeating intervals |
| Schedule → Work days | master | Monthly mode: calendar open days, hours, schedule summary |
| Schedule → Time off | master | View / edit upcoming absences (full days or hours) |
| Schedule → Break between appointments | master | Set pause after each visit (gap_minutes) |
| Schedule → Minimum lead time | master | Set how soon clients may book (min_lead_minutes) |
| Schedule → Slot grid step | master | Set start-time grid (slot_step_minutes; NULL = duration) |
| User card / Set role / Ban / Unban | admin | Moderation flows (id or @username) |
| Settings → Language | everyone | Switch RU / EN |
| Settings → Help | everyone | Short role-specific help |
| ← Back / ⌂ Menu | everyone | Hub navigation |
| OK | everyone | Dismiss a result / status notice |
Three roles, all stored in the database — nothing is hardcoded in the source.
| Role | Gets |
|---|---|
client |
Contact profile, booking, and managing their own appointments. Default for new users. |
master |
Service catalogue, schedule (weekly or monthly by deploy mode), time off, and the weekly appointment list. |
admin |
User moderation only (lookup, roles, ban / unban) — no client booking features. |
A fresh database has no master, so nobody can create services yet. Set it up once:
- Put your own Telegram id in
ADMIN_IDSin.env. - Send
/start— you are registered as an admin and see the moderation hub. - Ask the master to send
/starttoo, then open User card and look them up by id or@username. - Open Set role, enter the same id/@, choose master.
- Put that same id in
MASTER_USER_IDin.envand restart the bot.
MASTER_USER_ID is the master whose services clients book via Book.
ADMIN_IDS only decides which accounts become admins on their first /start.
Don't know your Telegram id? Send any message to @userinfobot.
Requires Docker and Docker Compose. Everything, the bot included, runs in containers — no local Python installation needed.
git clone git@github.com:DigitalJacob/booking_bot.git
cd booking_botcp .env.example .envAt minimum set BOT_TOKEN (from @BotFather), ADMIN_IDS,
POSTGRES_PASSWORD and REDIS_PASSWORD. See Configuration below.
MASTER_USER_ID can stay as-is for now — you will fill it in after
bootstrapping the master.
docker compose up -d --buildThis starts PostgreSQL, Redis, pgAdmin and the bot. Pending schema migrations run
automatically on bot startup (python -m migrations.migrate).
docker compose logs -f botYou should see the bot configured and polling. Now send /start in Telegram.
docker compose logs -f bot # follow bot logs
docker compose restart bot # restart after an .env change
docker compose up -d --build bot # rebuild after a code change
docker compose down # stop everything (data is kept)pgAdmin is available at http://localhost:${PGADMIN_PORT} with the credentials from
.env. Connect to host postgres, port 5432.
The bot can also run on the host while the databases stay in containers:
docker compose up -d postgres redis
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python3 -m migrations.migrate
python3 main.pyKeep POSTGRES_HOST=localhost and REDIS_HOST=localhost in .env for this mode.
All settings come from .env. Start from .env.example.
| Variable | Description |
|---|---|
BOT_TOKEN |
Telegram bot token from @BotFather |
ADMIN_IDS |
Comma-separated Telegram ids granted the admin role on first /start |
MASTER_USER_ID |
Telegram id of the master whose services clients can book |
TIMEZONE |
IANA timezone for display and local schedule input (default Europe/Moscow); storage stays UTC |
SCHEDULE_MODE |
weekly or monthly — schedule shape for this deploy (pick once; switching is not supported) |
LOG_LEVEL |
DEBUG for development, INFO for production |
LOG_FORMAT |
Python logging format string |
POSTGRES_DB / POSTGRES_USER / POSTGRES_PASSWORD |
Database credentials |
POSTGRES_HOST / POSTGRES_PORT |
postgres / 5432 inside Compose |
REDIS_HOST / REDIS_PORT / REDIS_DATABASE |
Redis connection for FSM storage |
REDIS_USERNAME / REDIS_PASSWORD |
Redis credentials |
PGADMIN_DEFAULT_EMAIL / PGADMIN_DEFAULT_PASSWORD / PGADMIN_PORT |
pgAdmin access |
PROXY_* |
Optional proxy, disabled by default — see below |
Commented out in .env.example. Uncomment all five lines to route the Telegram
session through a proxy:
PROXY_TYPE=http
PROXY_IP=your_proxy_ip
PROXY_PORT=your_proxy_port
PROXY_LOGIN=your_proxy_login
PROXY_PASSWORD=your_proxy_passwordUse PROXY_TYPE=socks5 for SOCKS. Leave the lines commented to connect directly.
Core booking tables (plus schema_migrations, master_settings, working_hours,
work_dates and time_off).
Schema is applied by versioned SQL files in migrations/versions/, run via python -m migrations.migrate
on startup.
| Table | Purpose |
|---|---|
users |
Telegram id, username, language, role, ban flag, contact profile (first name, last name, phone) |
services |
Master's offerings: title, duration, price, description, photo file id, active flag |
appointments |
Client, service, status, and concrete time range (starts_at / ends_at) |
master_settings |
Per-master timezone, grid step, gap, lead time and booking horizon |
working_hours |
Weekly mode: weekday (ISO 1=Mon…7=Sun) and local time ranges per master |
work_dates |
Monthly mode: concrete open dates with local starts_time / ends_time (unique per master+day) |
time_off |
Absolute blocked intervals (day off, break, vacation) per master |
services.description (optional, up to 1000 characters) and photo_file_id (Telegram
photo file id) are set from the master's service card and shown in the client
Services catalog. Booking still uses title / duration / price only.
appointments.status is one of pending, confirmed, cancelled.
Appointments store starts_at / ends_at. Active appointments for the same master
cannot overlap in time: a GiST EXCLUDE on tstzrange(starts_at, ends_at, '[)')
enforces that.
Availability for Book is computed from the schedule for this deploy
(SCHEDULE_MODE): weekly uses working_hours by weekday; monthly uses
work_dates for concrete open days. In both modes, time_off and existing
appointments are subtracted (AvailabilityService), using master_settings for
step, gap, lead time and horizon. Switching mode does not migrate data — after a
change you must restart and fill the matching schedule tables.
master_settings.gap_minutes defaults to 0 (back-to-back) and is editable under
Schedule → Break between appointments. min_lead_minutes defaults to 0 and is
editable under Schedule → Minimum lead time. slot_step_minutes is NULL until
customized (editable under Schedule → Slot grid step, with a reset to “use service
duration”) and means “step equals the chosen service duration” (when a candidate
overlaps a busy block including gap, availability jumps to that block’s end so the
next start can land on ends_at + gap even with a coarser step).
Display/input timezone still comes from .env TIMEZONE until the bot reads this table.
working_hours stores repeating weekly intervals as local wall-clock TIME values;
the master's timezone (settings / .env) interprets them when computing availability.
Used when SCHEDULE_MODE=weekly.
work_dates stores concrete open calendar days with one local interval per day
(UNIQUE (master_user_id, work_date)). Used when SCHEDULE_MODE=monthly. Saving
hours upserts only the newly selected days; closing days deletes those rows (with a
confirm if pending/confirmed appointments fall on them — bookings are kept).
Day-off and breaks are intentionally kept out of the weekly template — they live in
the separate time_off table. In monthly mode, closing a full day is an untoggled
work day; use time_off for a partial-day block inside an open day.
time_off holds concrete TIMESTAMPTZ blocks that remove availability — full days
(midnight → next midnight) or same-day clock windows from the hub.
All timestamps are TIMESTAMPTZ and stored in UTC.
Schema changes live in migrations/versions/*.sql (ordered by filename:
001_…, 002_…, …). On startup the bot runs python -m migrations.migrate,
which applies only versions not yet recorded in schema_migrations.
Existing databases created before versioned migrations are handled automatically:
if the users table already exists and 001_initial is not in the journal, the
runner baselines it (marks applied without re-running CREATE TABLE).
The domain layer is covered by unit tests that use in-memory fake repositories, so no database, Redis or bot token is needed.
pip install -r requirements-dev.txt
pytest.......................... [100%]
26 passed in 0.16s
The suite covers BookingService and AvailabilityService: window booking rules
(including monthly open days), confirm and cancel transitions with permission checks,
and client appointment listing filters.
booking_bot/
├── app/
│ ├── bot/ # Telegram layer
│ │ ├── filters/ # Role and locale filters
│ │ ├── handlers/ # admin / client / master / common
│ │ ├── i18n/ # Locale resolution
│ │ ├── keyboards/ # Inline keyboards and typed CallbackData
│ │ ├── middlewares/ # DB transactions, user context, i18n, ban check
│ │ ├── states/ # FSM state groups
│ │ ├── utils/ # Notifications, hub helpers, shared formatting
│ │ ├── bot_commands.py # Telegram ☰ menu (/start only)
│ │ └── bot.py # Dispatcher setup and startup
│ ├── domain/ # Models, enums, exceptions, BookingService, AvailabilityService
│ └── infrastructure/ # Connection pool and repositories
├── config/ # Typed settings from .env
├── locales/ # ru / en message dictionaries
├── migrations/ # Versioned SQL migrations and runner
├── tests/ # Unit tests and fake repositories
├── docker-compose.yml
├── Dockerfile
├── main.py
├── requirements.txt
└── requirements-dev.txt
- Per-master timezone setting (currently: bot-wide
TIMEZONEin.env) - Multi-master support, letting clients pick a master first
- Appointment reminders ahead of the scheduled time
- Per-language service titles set by the master
- Fetching appointment details in a single joined query to remove N+1 reads
Have ideas or found a bug? Open a GitHub Issue.
MIT License — free to use, modify, and distribute. See LICENSE.
Made with ❤️ by DigitalJacob