A booking bot looks simple until two clients tap the same time slot in the same second. Here is the approach I used in a small appointment bot for barbershops and salons. It needs no external services, only SQLite.
1. Never store free slots
The tempting design is a slots table with a taken flag. It goes stale the moment working hours change, and it gives you two things to keep in sync.
Instead, store only bookings and compute free slots when asked:
def free_slots(self, master, day, duration_min, now):
hours = self.cfg["work_hours"].get(str(day.isoweekday()))
if not hours:
return [] # day off
step = timedelta(minutes=self.cfg.get("slot_step_min", 30))
need = timedelta(minutes=duration_min)
cur = datetime.combine(day, time.fromisoformat(hours[0]))
end = datetime.combine(day, time.fromisoformat(hours[1]))
earliest = now + timedelta(minutes=self.cfg.get("min_notice_min", 60))
busy = self._busy(master, day) # [(start, end), ...] of active bookings
slots = []
while cur + need <= end:
if cur >= earliest and all(cur + need <= b0 or cur >= b1 for b0, b1 in busy):
slots.append(cur)
cur += step
return slots
The overlap test is the whole trick: a candidate is free if, for every existing booking, it ends before that booking starts or starts after it ends. This also handles services of different lengths, so a 90-minute service will not be offered 30 minutes before a taken slot.
2. Check and insert in one transaction
The slot list a client sees can be seconds old. So the final step re-checks inside the same transaction that writes the booking:
def book(self, user_id, client_name, phone, service, master, start, now):
svc = self.service(service)
with self.db: # one transaction: check and insert together
if start not in self.free_slots(master, start.date(), svc["duration_min"], now):
return None
cur = self.db.execute("INSERT INTO bookings(...) VALUES (...)", (...))
return cur.lastrowid
If book returns None, the bot tells the client the slot was just taken and shows the menu again. With a single bot process and SQLite this is enough. If you ever run several workers, add a unique index on (master, start) for active bookings as a second line of defence.
3. Send each reminder exactly once
Reminders are a loop that runs every minute. Without care it sends the same reminder sixty times an hour. One flag per reminder type fixes that (simplified):
rows = db.execute(
"SELECT * FROM bookings WHERE status='active' AND reminded_day=0 "
"AND start>? AND start<=?", (now, now + timedelta(hours=24))).fetchall()
for r in rows:
db.execute("UPDATE bookings SET reminded_day=1 WHERE id=?", (r["id"],))
One edge case: someone books for two hours from now. Both the "day before" and the "two hours before" windows match at once. I mark both as sent but only deliver the short-notice one.
4. Test the logic without Telegram
Because the booking logic takes now as an argument and never touches the Telegram API, it runs against an in-memory database:
def test_same_slot_twice():
b = Booking(cfg, ":memory:")
start = datetime(2026, 11, 16, 12, 0)
assert b.book(1, "Ivan", "+100000000", "Haircut", "Alex", start, MON)
assert b.book(2, "Peter", "+100000001", "Haircut", "Alex", start, MON) is None
Passing the clock in as a parameter is what makes reminder and "minimum notice" rules testable at all.
The full bot
The snippets above come from a complete bot: service, specialist, day and time selection, client cancellations, owner notifications and admin commands. If you would rather start from working code than assemble it yourself, I sell the template here: https://payhip.com/b/VH20Y ($19, source code and setup guide).
Questions about any of the parts are welcome in the comments.
Top comments (0)