diff --git a/.env b/.env
deleted file mode 100644
index bde5482..0000000
--- a/.env
+++ /dev/null
@@ -1 +0,0 @@
-BOT_TOKEN=8918896220:AAHuo36Lo9PEeXVNongKJbr2WLZn5xxy1GQ
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..039cd6e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+.env
+.venv/
+__pycache__/
+*.py[cod]
+.DS_Store
+.idea/
+.vscode/
+*.egg-info/
+dist/
+build/
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/config.py b/app/config.py
index 27036cd..e342491 100644
--- a/app/config.py
+++ b/app/config.py
@@ -4,6 +4,29 @@ from dotenv import load_dotenv
load_dotenv()
BOT_TOKEN = os.getenv("BOT_TOKEN")
+ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
if not BOT_TOKEN:
- raise ValueError("BOT_TOKEN не найден")
\ No newline at end of file
+ raise ValueError("BOT_TOKEN not found in environment")
+
+# Services catalog
+SERVICES = {
+ "service_massage": "💪 Массаж",
+ "service_spa": "🎈 SPA",
+ "service_cosmetology": "💄 Косметология",
+}
+
+# Masters list
+MASTERS = {
+ "master_anna": "👸 Анна",
+ "master_maria": "💃 Мария",
+ "master_alex": "🧑⚖️ Алексей",
+}
+
+# Available time slots
+TIME_SLOTS = [
+ "09:00", "10:00", "11:00",
+ "12:00", "13:00", "14:00",
+ "15:00", "16:00", "17:00",
+ "18:00",
+]
diff --git a/app/db/__init__.py b/app/db/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/db/memory.py b/app/db/memory.py
index 481f953..85a2e89 100644
--- a/app/db/memory.py
+++ b/app/db/memory.py
@@ -1,2 +1,111 @@
-users = {}
-bookings = []
\ No newline at end of file
+import json
+from app.logger import logger
+from pathlib import Path
+
+DB_DIR = Path(__file__).resolve().parent.parent.parent / "data"
+DB_DIR.mkdir(exist_ok=True)
+
+USERS_FILE = DB_DIR / "users.json"
+BOOKINGS_FILE = DB_DIR / "bookings.json"
+
+
+def _load_json(path, default):
+ if path.exists():
+ try:
+ with open(path, "r", encoding="utf-8") as fh:
+ return json.load(fh)
+ except (json.JSONDecodeError, OSError):
+ return default
+ return default
+
+
+def _save_json(path, data):
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(data, fh, ensure_ascii=False, indent=2)
+
+
+def load_users():
+ return _load_json(USERS_FILE, {})
+
+
+def save_users(users):
+ _save_json(USERS_FILE, users)
+
+
+def get_user(user_id):
+ users = load_users()
+ return users.get(str(user_id))
+
+
+def set_user(user_id, data):
+ users = load_users()
+ users[str(user_id)] = data
+ save_users(users)
+
+
+def load_bookings():
+ return _load_json(BOOKINGS_FILE, [])
+
+
+def save_bookings(bookings):
+ from app.logger import logger
+ logger.debug("save_bookings: saving {n} bookings", n=len(bookings))
+ _save_json(BOOKINGS_FILE, bookings)
+
+
+def create_booking(user_id, service, master, date, time, user_name=""):
+ from app.logger import logger
+ logger.debug("create_booking called: uid={uid}, svc={svc}, master={mst}, date={dt}, time={tm}", uid=user_id, svc=service, mst=master, dt=date, tm=time)
+ bookings = load_bookings()
+ booking = {
+ "id": len(bookings) + 1,
+ "user_id": user_id,
+ "user_name": user_name,
+ "service": service,
+ "master": master,
+ "date": date,
+ "time": time,
+ "status": "active",
+ }
+ bookings.append(booking)
+ _save_json(BOOKINGS_FILE, bookings)
+ return booking
+
+
+def get_busy_times(master, date):
+ bookings = load_bookings()
+ return [
+ b["time"] for b in bookings
+ if b["master"] == master and b["date"] == date and b["status"] == "active"
+ ]
+
+
+def get_user_bookings(user_id):
+ bookings = load_bookings()
+ return [
+ b for b in bookings
+ if b["user_id"] == user_id and b["status"] == "active"
+ ]
+
+
+def cancel_booking(booking_id):
+ bookings = load_bookings()
+ for b in bookings:
+ if b["id"] == booking_id:
+ b["status"] = "cancelled"
+ _save_json(BOOKINGS_FILE, bookings)
+ return True
+ return False
+
+
+def update_booking(booking_id, service, master, date, time):
+ bookings = load_bookings()
+ for b in bookings:
+ if b["id"] == booking_id:
+ b["service"] = service
+ b["master"] = master
+ b["date"] = date
+ b["time"] = time
+ save_bookings(bookings)
+ return b
+ return None
diff --git a/app/handlers/__init__.py b/app/handlers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/handlers/about.py b/app/handlers/about.py
index c590b5d..17c0ece 100644
--- a/app/handlers/about.py
+++ b/app/handlers/about.py
@@ -1,10 +1,11 @@
from aiogram import Router, F
from aiogram.types import Message
+from app.logger import logger
from aiogram.fsm.context import FSMContext
router = Router()
-@router.message(F.text == "ℹ️ О компании")
+@router.message(F.text == "ℹ️ О нас")
async def about(message: Message, state: FSMContext):
await state.clear() # 🔥 СБРОС FSM
diff --git a/app/handlers/admin.py b/app/handlers/admin.py
new file mode 100644
index 0000000..c2320ed
--- /dev/null
+++ b/app/handlers/admin.py
@@ -0,0 +1,81 @@
+"""Admin handler for spa-telegram-bot — secure with password."""
+from aiogram import Router, F
+from aiogram.types import Message, CallbackQuery
+from aiogram.filters import Command, CommandObject
+from app.db.memory import load_bookings
+from app.config import ADMIN_PASSWORD
+from app.logger import logger
+
+router = Router()
+
+ADMIN_IDS = {991309145}
+# Store approved sessions — user_ids that passed password check
+_approved = set()
+
+
+def is_admin(user_id: int, password: str | None = None) -> bool:
+ """Check if user is admin: by user_id, by password, or by approved session."""
+ if user_id in ADMIN_IDS:
+ return True
+ if user_id in _approved:
+ return True
+ if password and password == ADMIN_PASSWORD:
+ _approved.add(user_id)
+ return True
+ return False
+
+
+@router.message(Command("admin"))
+async def admin_panel(message: Message, command: CommandObject):
+ uid = message.from_user.id
+ password = command.args
+
+ if not is_admin(uid, password=password):
+ return # Silent
+
+ logger.info("Admin {uid} opened admin panel", uid=uid)
+
+ bookings = load_bookings()
+ active = [b for b in bookings if b.get("status") == "active"]
+
+ if not active:
+ await message.answer("📋 Активных записей нет")
+ return
+
+ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+
+ for booking in active:
+ buttons = [[
+ InlineKeyboardButton(
+ text="❌ Отменить",
+ callback_data=f"admin_cancel_{booking['id']}"
+ )
+ ]]
+ user_name = booking.get("user_name") or f"id{booking['user_id']}"
+ await message.answer(
+ f"📋 Запись #{booking['id']}\n"
+ f"👤 Клиент: {user_name}\n"
+ f"💆 Услуги: {booking.get('service', '?')}\n"
+ f"👤 Мастер: {booking.get('master', '?')}\n"
+ f"📅 Дата: {booking.get('date', '?')}\n"
+ f"🕒 Время: {booking.get('time', '?')}",
+ reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)
+ )
+
+
+@router.callback_query(F.data.startswith("admin_cancel_"))
+async def admin_cancel_booking(callback: CallbackQuery):
+ if not is_admin(callback.from_user.id):
+ await callback.answer("⛔ Нет доступа")
+ return
+
+ booking_id = int(callback.data.split("_")[2])
+ logger.info("Admin {uid} cancelled booking #{bid}", uid=callback.from_user.id, bid=booking_id)
+
+ from app.db.memory import cancel_booking
+ cancel_booking(booking_id)
+
+ await callback.message.edit_text(
+ f"✅ Запись #{booking_id} отменена админом"
+ )
+ await callback.answer("Запись отменена")
diff --git a/app/handlers/booking.py b/app/handlers/booking.py
index f2afef0..09157af 100644
--- a/app/handlers/booking.py
+++ b/app/handlers/booking.py
@@ -1,15 +1,37 @@
+import re
+
from aiogram import Router, F
-from aiogram.types import Message
from aiogram.fsm.context import FSMContext
from aiogram.filters import StateFilter
-
+from aiogram.types import Message, CallbackQuery
+from app.keyboards.services import get_services_keyboard, get_extra_services_keyboard
+from app.keyboards.masters import get_masters_keyboard
from app.states.booking import BookingState
-from app.services.booking_service import create_booking
+from app.services.booking_service import (
+ create_booking,
+ update_booking,
+ get_busy_times,
+ is_slot_busy,
+ cleanup_cancelled
+)
from app.keyboards.menu import menu, cancel_menu
+from app.keyboards.calendar import get_dates_keyboard
+from app.keyboards.time_slots import get_time_keyboard
+from app.keyboards.confirm_booking import get_confirm_booking_keyboard
+from app.logger import logger
router = Router()
+def _services_to_str(data):
+ """Combine main + extra services into one string."""
+ main = data.get("service", "?")
+ extra = data.get("extra_services", [])
+ if extra:
+ return main + ", " + ", ".join(extra)
+ return main
+
+
# отмена записи на любом шаге
@router.message(F.text.lower() == "отмена")
async def cancel(message: Message, state: FSMContext):
@@ -24,99 +46,295 @@ async def cancel(message: Message, state: FSMContext):
@router.message(F.text == "📅 Записаться")
async def start_booking(message: Message, state: FSMContext):
await state.clear()
-
await state.set_state(BookingState.service)
-
await message.answer(
- "Выберите услугу:\n"
- "1. Массаж\n"
- "2. SPA\n"
- "3. Косметология",
- reply_markup=cancel_menu
+ "Выберите услугу:",
+ reply_markup=get_services_keyboard()
)
-# выбор услуги
-@router.message(StateFilter(BookingState.service))
-async def choose_service(message: Message, state: FSMContext):
+# выбор первой услуги
+@router.callback_query(
+ StateFilter(BookingState.service),
+ F.data.startswith("service_")
+)
+async def choose_service(callback: CallbackQuery, state: FSMContext):
+ from app.config import SERVICES
- forbidden = [
- "📅 Записаться",
- "👤 Профиль",
- "📖 Мои записи",
- "ℹ️ О компании",
- "📞 Контакты"
- ]
+ service_name = SERVICES[callback.data]
+ logger.debug("User {uid} selected first service: {srv}", uid=callback.from_user.id, srv=service_name)
- if message.text in forbidden:
- await message.answer(
- "⚠️ Сначала завершите запись или напишите 'отмена'"
+ await state.update_data(service=service_name, extra_services=[])
+ await state.set_state(BookingState.extra_services)
+
+ await callback.message.edit_text(
+ "✅ Выбрано: " + service_name + "\n\n"
+ "Хотите добавить ещё услугу?\n"
+ "Нажмите нужную или «Готово»:",
+ reply_markup=get_extra_services_keyboard()
+ )
+ await callback.answer()
+
+
+# выбор дополнительных услуг
+@router.callback_query(
+ StateFilter(BookingState.extra_services),
+ F.data.startswith("extra_")
+)
+async def choose_extra_service(callback: CallbackQuery, state: FSMContext):
+ from app.config import SERVICES
+
+ if callback.data == "extra_done":
+ # Go to master selection
+ await state.set_state(BookingState.master)
+ await callback.message.edit_text(
+ "👤 Выберите специалиста:",
+ reply_markup=get_masters_keyboard()
)
+ await callback.answer()
return
- await state.update_data(service=message.text)
+ # Toggle extra service
+ actual_key = callback.data.replace("extra_", "", 1)
+ service_name = SERVICES.get(actual_key)
+ if not service_name:
+ await callback.answer("Услуга не найдена")
+ return
+ data = await state.get_data()
+ extra = data.get("extra_services", [])
+
+ if service_name in extra:
+ extra.remove(service_name)
+ logger.debug("User {uid} removed extra service: {srv}", uid=callback.from_user.id, srv=service_name)
+ else:
+ extra.append(service_name)
+ logger.debug("User {uid} added extra service: {srv}", uid=callback.from_user.id, srv=service_name)
+
+ await state.update_data(extra_services=extra)
+
+ data = await state.get_data()
+ services_text = _services_to_str(data)
+
+ await callback.message.edit_text(
+ "✅ Выбрано: " + services_text + "\n\n"
+ "Хотите добавить ещё услугу?\n"
+ "Нажмите нужную или «Готово»:",
+ reply_markup=get_extra_services_keyboard(extra)
+ )
+ await callback.answer()
+
+
+# выбор мастера
+@router.callback_query(
+ StateFilter(BookingState.master),
+ F.data.startswith("master_")
+)
+async def choose_master(callback: CallbackQuery, state: FSMContext):
+ from app.config import MASTERS
+
+ master_name = MASTERS[callback.data]
+ logger.debug("User {uid} selected master: {m}", uid=callback.from_user.id, m=master_name)
+
+ await state.update_data(master=master_name)
await state.set_state(BookingState.date)
- await message.answer(
- "Введите дату (например 20.06)"
+ await callback.message.edit_text(
+ "📅 Выберите дату:",
+ reply_markup=get_dates_keyboard()
)
+ await callback.answer()
# выбор даты
-@router.message(StateFilter(BookingState.date))
-async def choose_date(message: Message, state: FSMContext):
+@router.callback_query(
+ StateFilter(BookingState.date),
+ F.data.startswith("date_")
+)
+async def choose_date(callback: CallbackQuery, state: FSMContext):
+ selected_date = callback.data.replace("date_", "")
- forbidden = [
- "📅 Записаться",
- "👤 Профиль",
- "📖 Мои записи",
- "ℹ️ О компании",
- "📞 Контакты"
- ]
-
- if message.text in forbidden:
- await message.answer(
- "⚠️ Сначала завершите запись или напишите 'отмена'"
- )
- return
-
- await state.update_data(date=message.text)
+ await state.update_data(date=selected_date)
+ logger.debug("User {uid} selected date: {d}", uid=callback.from_user.id, d=selected_date)
data = await state.get_data()
+ busy_times = await get_busy_times(master=data.get("master", "?"), date=selected_date)
+
+ await state.set_state(BookingState.time)
+
+ await callback.message.edit_text(
+ "🕒 Выберите время:",
+ reply_markup=get_time_keyboard(busy_times)
+ )
+ await callback.answer()
+
+
+# выбор времени
+@router.callback_query(
+ StateFilter(BookingState.time),
+ F.data.startswith("time_")
+)
+async def choose_time(callback: CallbackQuery, state: FSMContext):
+ selected_time = callback.data.replace("time_", "")
+
+ await state.update_data(time=selected_time)
+ logger.debug("User {uid} selected time: {t}", uid=callback.from_user.id, t=selected_time)
+
+ data = await state.get_data()
+ services_str = _services_to_str(data)
await state.set_state(BookingState.confirm)
- await message.answer(
- f"Услуга: {data['service']}\n"
- f"Дата: {data['date']}\n\n"
- f"Напишите 'да' для подтверждения"
+ await callback.message.edit_text(
+ "📋 Проверьте запись:\n\n"
+ f"💆 Услуги: {services_str}\n"
+ f"📅 Дата: {data.get('date', '?')}\n"
+ f"👤 Мастер: {data.get('master', '?')}\n"
+ f"🕒 Время: {selected_time}",
+ reply_markup=get_confirm_booking_keyboard()
)
+ await callback.answer()
# подтверждение
-@router.message(StateFilter(BookingState.confirm))
-async def confirm_booking(message: Message, state: FSMContext):
-
- if message.text.lower() != "да":
- await state.clear()
-
- await message.answer(
- "❌ Запись отменена",
- reply_markup=menu
- )
-
+@router.callback_query(
+ StateFilter(BookingState.confirm),
+ F.data == "confirm_booking"
+)
+async def confirm_booking_callback(callback: CallbackQuery, state: FSMContext):
data = await state.get_data()
+ logger.info("User {uid} confirmed booking: {data}", uid=callback.from_user.id, data=data)
- create_booking(
- user_id=message.from_user.id,
- service=data["service"],
- date=data["date"]
- )
+ services_str = _services_to_str(data)
+ editing_booking_id = data.get("editing_booking_id")
+
+ if editing_booking_id:
+ await update_booking(
+ booking_id=editing_booking_id,
+ service=services_str,
+ master=data.get("master", "?"),
+ date=data.get("date", "?"),
+ time=data.get("time", "?")
+ )
+ result_text = "✅ Запись успешно обновлена"
+ else:
+ master = data.get("master", "?")
+ date = data.get("date", "?")
+ time = data.get("time", "?")
+
+ if await is_slot_busy(master, date, time):
+ logger.warning("Slot already busy: {mst} {dt} {tm}", mst=master, dt=date, tm=time)
+ await callback.message.edit_text("❌ Это время уже занято. Пожалуйста, выберите другое время.")
+ await state.clear()
+ await callback.answer()
+ return
+
+ await create_booking(
+ user_id=callback.from_user.id,
+ service=services_str,
+ master=master,
+ date=date,
+ time=time,
+ user_name=callback.from_user.full_name or callback.from_user.username or str(callback.from_user.id)
+ )
+ cleanup_cancelled()
+ result_text = "✅ Запись успешно создана"
await state.clear()
+ await callback.message.edit_text(result_text)
+ await callback.answer()
- await message.answer(
- "✅ Вы успешно записаны!",
- reply_markup=menu
- )
\ No newline at end of file
+
+@router.callback_query(
+ StateFilter(BookingState.confirm),
+ F.data == "cancel_booking"
+)
+async def cancel_booking_callback(callback: CallbackQuery, state: FSMContext):
+ await state.clear()
+ await callback.message.edit_text("❌ Запись отменена")
+ await callback.answer()
+
+
+@router.callback_query(
+ StateFilter(BookingState.confirm),
+ F.data == "edit_booking"
+)
+async def edit_booking_callback(callback: CallbackQuery, state: FSMContext):
+ old_data = await state.get_data()
+ await state.clear()
+ await state.update_data(service=old_data.get("service"))
+ await state.set_state(BookingState.service)
+
+ await callback.message.edit_text(
+ "Выберите услугу:",
+ reply_markup=get_services_keyboard()
+ )
+
+
+@router.callback_query(F.data == "go_back")
+async def go_back_handler(callback: CallbackQuery, state: FSMContext):
+ current_state = await state.get_state()
+
+ if current_state == BookingState.extra_services:
+ # Back to first service
+ await state.set_state(BookingState.service)
+ data = await state.get_data()
+ data.pop("extra_services", None)
+ data.pop("service", None)
+ await state.set_data(data)
+ await callback.message.edit_text(
+ "Выберите услугу:",
+ reply_markup=get_services_keyboard()
+ )
+
+ elif current_state == BookingState.master:
+ # Back to extra services
+ await state.set_state(BookingState.extra_services)
+ data = await state.get_data()
+ extra = data.get("extra_services", [])
+ services_text = _services_to_str(data)
+ await callback.message.edit_text(
+ "✅ Выбрано: " + services_text + "\n\n"
+ "Хотите добавить ещё услугу?\n"
+ "Нажмите нужную или «Готово»:",
+ reply_markup=get_extra_services_keyboard(extra)
+ )
+
+ elif current_state == BookingState.date:
+ await state.set_state(BookingState.master)
+ data = await state.get_data()
+ data.pop("date", None)
+ data.pop("time", None)
+ await state.set_data(data)
+ await callback.message.edit_text(
+ "👤 Выберите специалиста:",
+ reply_markup=get_masters_keyboard()
+ )
+
+ elif current_state == BookingState.time:
+ await state.set_state(BookingState.date)
+ data = await state.get_data()
+ data.pop("time", None)
+ await state.set_data(data)
+ await callback.message.edit_text(
+ "📅 Выберите дату:",
+ reply_markup=get_dates_keyboard()
+ )
+
+ elif current_state == BookingState.confirm:
+ await state.set_state(BookingState.time)
+ data = await state.get_data()
+ busy_times = await get_busy_times(
+ master=data.get("master", "?"),
+ date=data.get("date", "?")
+ )
+ await callback.message.edit_text(
+ "🕒 Выберите время:",
+ reply_markup=get_time_keyboard(busy_times)
+ )
+
+ else:
+ await state.clear()
+ await callback.message.edit_text("Отмена записи")
+
+ await callback.answer()
diff --git a/app/handlers/contacts.py b/app/handlers/contacts.py
index 762ff45..d6ea583 100644
--- a/app/handlers/contacts.py
+++ b/app/handlers/contacts.py
@@ -1,5 +1,6 @@
from aiogram import Router, F
from aiogram.types import Message
+from app.logger import logger
from aiogram.fsm.context import FSMContext
router = Router()
diff --git a/app/handlers/mybookings.py b/app/handlers/mybookings.py
index cf560f3..c6467f8 100644
--- a/app/handlers/mybookings.py
+++ b/app/handlers/mybookings.py
@@ -1,28 +1,88 @@
from aiogram import Router, F
-from aiogram.types import Message
+from aiogram.types import Message, CallbackQuery
from aiogram.fsm.context import FSMContext
-from app.db.memory import bookings
+from app.db.memory import get_user_bookings, cancel_booking, update_booking
+from app.keyboards.menu import menu
+from app.keyboards.my_booking import get_booking_actions
+from app.logger import logger
router = Router()
-@router.message(F.text == "📖 Мои записи")
-async def my_bookings(message: Message, state: FSMContext):
- await state.clear()
- user_id = message.from_user.id
+@router.message(
+ F.text == "📄 Мои записи"
+)
+async def my_bookings(
+ message: Message
+):
+ logger.info("User {uid} opened My Bookings", uid=message.from_user.id)
+ try:
+ bookings = get_user_bookings(
+ message.from_user.id
+ )
+ logger.debug("Found {n} bookings", n=len(bookings))
- user_bookings = [
- b for b in bookings if b["user_id"] == user_id
- ]
+ if not bookings:
+ await message.answer(
+ "У вас нет активных записей"
+ )
+ return
- if not user_bookings:
- await message.answer("У вас нет записей")
- return
+ for booking in bookings:
+ svc = booking["service"]
+ mst = booking["master"]
+ dat = booking["date"]
+ tim = booking["time"]
+ text = "💆 " + svc + "\n"
+ text += "👤 " + mst + "\n"
+ text += "📅 " + dat + "\n"
+ text += "🕒 " + tim
+ await message.answer(
+ text,
+ reply_markup=get_booking_actions(
+ booking["id"]
+ )
+ )
+ except Exception as e:
+ logger.exception("Error in my_bookings: {error}", error=e)
+ await message.answer(
+ "Произошла ошибка. Попробуйте позже."
+ )
- text = "📖 Ваши записи:\n\n"
- for b in user_bookings:
- text += f"💆 {b['service']} — 📅 {b['date']}\n"
+@router.callback_query(
+ F.data.startswith("cancel_booking_")
+)
+async def cancel_booking_handler(callback: CallbackQuery):
+ booking_id = int(callback.data.split("_")[2])
+ logger.info("User {uid} cancelled booking #{bid}", uid=callback.from_user.id, bid=booking_id)
+ cancel_booking(
+ booking_id
+ )
+ await callback.message.edit_text(
+ "Запись отменена"
+ )
+ await callback.answer()
- await message.answer(text)
\ No newline at end of file
+
+@router.callback_query(
+ F.data.startswith("edit_booking_")
+)
+async def edit_booking_handler(
+ callback: CallbackQuery,
+ state: FSMContext
+):
+ booking_id = int(callback.data.split("_")[2])
+
+ update_booking(
+ booking_id=booking_id,
+ service="",
+ master="",
+ date="",
+ time=""
+ )
+
+ await callback.answer(
+ "Редактирование не поддерживается"
+ )
diff --git a/app/handlers/profile.py b/app/handlers/profile.py
index 206793a..d04d06e 100644
--- a/app/handlers/profile.py
+++ b/app/handlers/profile.py
@@ -2,18 +2,17 @@ from aiogram import Router, F
from aiogram.types import Message
from aiogram.fsm.context import FSMContext
-from app.db.memory import users
+from app.db.memory import get_user
+from app.logger import logger
router = Router()
+
@router.message(F.text == "👤 Профиль")
async def profile(message: Message, state: FSMContext):
await state.clear()
-
- user = users.get(message.from_user.id)
-
+ user = get_user(message.from_user.id)
if not user:
await message.answer("Профиль не найден")
return
-
- await message.answer(f"👤 Имя: {user['name']}")
\ No newline at end of file
+ await message.answer(f"👤 Имя: {user['name']}")
diff --git a/app/handlers/services.py b/app/handlers/services.py
index ac5e150..931807f 100644
--- a/app/handlers/services.py
+++ b/app/handlers/services.py
@@ -1,2 +1,23 @@
-from aiogram import Router
-router = Router()
\ No newline at end of file
+from aiogram import Router, F
+from aiogram.types import Message
+from aiogram.fsm.context import FSMContext
+
+from app.keyboards.menu import menu
+from app.keyboards.services import get_services_keyboard
+from app.logger import logger
+
+router = Router()
+
+
+@router.message(F.text == "\U0001f4cb \u0423\u0441\u043b\u0443\u0433\u0438")
+async def show_services(message: Message, state: FSMContext):
+ await state.clear()
+ logger.info("User {uid} requested services list", uid=message.from_user.id)
+ await message.answer(
+ "\U0001f4cb \u041d\u0430\u0448\u0438 \u0443\u0441\u043b\u0443\u0433\u0438:\n\n"
+ "\U0001f4aa \u041c\u0430\u0441\u0441\u0430\u0436 - \u0440\u0430\u0441\u0441\u043b\u0430\u0431\u043b\u044f\u044e\u0449\u0438\u0439 \u0438 \u043b\u0435\u0447\u0435\u0431\u043d\u044b\u0439\n"
+ "\U0001f388 SPA - \u043a\u043e\u043c\u043f\u043b\u0435\u043a\u0441\u043d\u044b\u0435 \u043f\u0440\u043e\u0446\u0435\u0434\u0443\u0440\u044b\n"
+ "\U0001f484 \u041a\u043e\u0441\u043c\u0435\u0442\u043e\u043b\u043e\u0433\u0438\u044f - \u0443\u0445\u043e\u0434 \u0437\u0430 \u043b\u0438\u0446\u043e\u043c\n\n"
+ "\u0427\u0442\u043e\u0431\u044b \u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f, \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u00ab\u0417\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f\u00bb",
+ reply_markup=get_services_keyboard()
+ )
diff --git a/app/handlers/start.py b/app/handlers/start.py
index bf8cdda..a1b9e18 100644
--- a/app/handlers/start.py
+++ b/app/handlers/start.py
@@ -3,17 +3,19 @@ from aiogram.filters import CommandStart
from aiogram.types import Message
from app.keyboards.menu import menu
-from app.db.memory import users
+from app.db.memory import get_user, set_user
+from app.logger import logger
router = Router()
+
@router.message(CommandStart())
async def start(message: Message):
- users[message.from_user.id] = {
- "name": message.from_user.full_name
- }
+ user_id = message.from_user.id
+ if not get_user(user_id):
+ set_user(user_id, {"name": message.from_user.full_name})
await message.answer(
"👋 Добро пожаловать в Demo Bot",
- reply_markup=menu
- )
\ No newline at end of file
+ reply_markup=menu,
+ )
diff --git a/app/keyboards/__init__.py b/app/keyboards/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/keyboards/back.py b/app/keyboards/back.py
new file mode 100644
index 0000000..66eeb56
--- /dev/null
+++ b/app/keyboards/back.py
@@ -0,0 +1,5 @@
+from aiogram.types import InlineKeyboardButton
+
+
+def get_back_button():
+ return [InlineKeyboardButton(text="◀ Назад", callback_data="go_back")]
diff --git a/app/keyboards/calendar.py b/app/keyboards/calendar.py
new file mode 100644
index 0000000..3653fe8
--- /dev/null
+++ b/app/keyboards/calendar.py
@@ -0,0 +1,26 @@
+from app.keyboards.back import get_back_button
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from datetime import datetime, timedelta
+
+
+def get_dates_keyboard():
+ buttons = []
+
+ today = datetime.now()
+
+ for i in range(7):
+ day = today + timedelta(days=i)
+
+ buttons.append(
+ [
+ InlineKeyboardButton(
+ text=day.strftime("%d.%m"),
+ callback_data=f"date_{day.strftime('%d.%m')}"
+ )
+ ]
+ )
+
+ buttons.append(get_back_button())
+ return InlineKeyboardMarkup(
+ inline_keyboard=buttons
+ )
\ No newline at end of file
diff --git a/app/keyboards/confirm_booking.py b/app/keyboards/confirm_booking.py
new file mode 100644
index 0000000..8f7a28a
--- /dev/null
+++ b/app/keyboards/confirm_booking.py
@@ -0,0 +1,28 @@
+from aiogram.types import InlineKeyboardMarkup
+from aiogram.types import InlineKeyboardButton
+
+
+def get_confirm_booking_keyboard():
+
+ return InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text="✅ Подтвердить",
+ callback_data="confirm_booking"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text="✏️ Изменить",
+ callback_data="edit_booking"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text="❌ Отменить",
+ callback_data="cancel_booking"
+ )
+ ]
+ ]
+ )
\ No newline at end of file
diff --git a/app/keyboards/masters.py b/app/keyboards/masters.py
new file mode 100644
index 0000000..76ff2d8
--- /dev/null
+++ b/app/keyboards/masters.py
@@ -0,0 +1,14 @@
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from app.config import MASTERS
+
+
+from app.keyboards.back import get_back_button
+
+
+def get_masters_keyboard():
+ buttons = [
+ [InlineKeyboardButton(text=name, callback_data=key)]
+ for key, name in MASTERS.items()
+ ]
+ buttons.append(get_back_button())
+ return InlineKeyboardMarkup(inline_keyboard=buttons)
diff --git a/app/keyboards/menu.py b/app/keyboards/menu.py
index 66debc4..a8022b0 100644
--- a/app/keyboards/menu.py
+++ b/app/keyboards/menu.py
@@ -1,25 +1,21 @@
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
-# Главное меню
menu = ReplyKeyboardMarkup(
keyboard=[
- [KeyboardButton(text="📅 Записаться")],
- [
- KeyboardButton(text="👤 Профиль"),
- KeyboardButton(text="📖 Мои записи")
- ],
- [
- KeyboardButton(text="ℹ️ О компании"),
- KeyboardButton(text="📞 Контакты")
- ]
+ [KeyboardButton(text="📋 Услуги"),
+ KeyboardButton(text="📅 Записаться")],
+ [KeyboardButton(text="👤 Профиль"),
+ KeyboardButton(text="📄 Мои записи")],
+ [KeyboardButton(text="📞 Контакты"),
+ KeyboardButton(text="ℹ️ О нас")],
+ ],
+ resize_keyboard=True,
+ input_field_placeholder="Выберите действие..."
+)
+
+cancel_menu = ReplyKeyboardMarkup(
+ keyboard=[
+ [KeyboardButton(text="❌ Отмена")]
],
resize_keyboard=True
)
-
-# Меню во время записи
-cancel_menu = ReplyKeyboardMarkup(
- keyboard=[
- [KeyboardButton(text="Отмена")]
- ],
- resize_keyboard=True
-)
\ No newline at end of file
diff --git a/app/keyboards/my_booking.py b/app/keyboards/my_booking.py
new file mode 100644
index 0000000..c161135
--- /dev/null
+++ b/app/keyboards/my_booking.py
@@ -0,0 +1,25 @@
+from aiogram.types import (
+ InlineKeyboardMarkup,
+ InlineKeyboardButton
+)
+
+
+def get_booking_actions(
+ booking_id: int
+):
+ return InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text="✏️ Изменить",
+ callback_data=f"edit_booking_{booking_id}"
+ )
+ ],
+ [
+ InlineKeyboardButton(
+ text="❌ Отменить",
+ callback_data=f"cancel_booking_{booking_id}"
+ )
+ ]
+ ]
+ )
\ No newline at end of file
diff --git a/app/keyboards/services.py b/app/keyboards/services.py
new file mode 100644
index 0000000..c1df4fe
--- /dev/null
+++ b/app/keyboards/services.py
@@ -0,0 +1,33 @@
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from app.config import SERVICES
+
+
+from app.keyboards.back import get_back_button
+
+
+def get_services_keyboard():
+ buttons = [
+ [InlineKeyboardButton(text=name, callback_data=key)]
+ for key, name in SERVICES.items()
+ ]
+ buttons.append(get_back_button())
+ return InlineKeyboardMarkup(inline_keyboard=buttons)
+
+
+def get_extra_services_keyboard(already_selected: list[str] | None = None):
+ """Keyboard for selecting extra services. Shows ticked/unticked."""
+ if already_selected is None:
+ already_selected = []
+
+ buttons = []
+ for key, name in SERVICES.items():
+ if name in already_selected:
+ label = "\u2705 " + name
+ else:
+ label = name
+ buttons.append([InlineKeyboardButton(text=label, callback_data="extra_" + key)])
+
+ # Done button
+ buttons.append([InlineKeyboardButton(text="\u2705 \u0413\u043e\u0442\u043e\u0432\u043e", callback_data="extra_done")])
+ buttons.append(get_back_button())
+ return InlineKeyboardMarkup(inline_keyboard=buttons)
diff --git a/app/keyboards/time_slots.py b/app/keyboards/time_slots.py
new file mode 100644
index 0000000..fb5cf35
--- /dev/null
+++ b/app/keyboards/time_slots.py
@@ -0,0 +1,22 @@
+from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+from app.config import TIME_SLOTS
+from app.keyboards.back import get_back_button
+
+
+def get_time_keyboard(busy_times):
+ buttons = []
+ row = []
+ for slot in TIME_SLOTS:
+ busy = slot in busy_times
+ if busy:
+ label = "\u274c" + " " + slot
+ else:
+ label = "\u2705" + " " + slot
+ row.append(InlineKeyboardButton(text=label, callback_data="time_" + slot))
+ if len(row) == 3:
+ buttons.append(row)
+ row = []
+ if row:
+ buttons.append(row)
+ buttons.append(get_back_button())
+ return InlineKeyboardMarkup(inline_keyboard=buttons)
diff --git a/app/logger.py b/app/logger.py
new file mode 100644
index 0000000..ed12545
--- /dev/null
+++ b/app/logger.py
@@ -0,0 +1,38 @@
+"""Logging configuration for SPA Telegram Bot."""
+import sys
+from pathlib import Path
+from loguru import logger
+
+LOG_DIR = Path(__file__).resolve().parent.parent / "logs"
+LOG_DIR.mkdir(exist_ok=True)
+
+# Remove default handler
+logger.remove()
+
+# Console handler (INFO and above)
+logger.add(
+ sys.stdout,
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
+ level="INFO",
+ colorize=True,
+)
+
+# File handler with rotation (DEBUG and above, keep 30 days)
+logger.add(
+ LOG_DIR / "bot_{time:YYYY-MM-DD}.log",
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
+ level="DEBUG",
+ rotation="1 day",
+ retention="30 days",
+ compression="gz",
+)
+
+# Error-only file
+logger.add(
+ LOG_DIR / "errors_{time:YYYY-MM-DD}.log",
+ format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
+ level="ERROR",
+ rotation="1 day",
+ retention="90 days",
+ compression="gz",
+)
diff --git a/app/main.py b/app/main.py
index 614b1b3..ab0537f 100644
--- a/app/main.py
+++ b/app/main.py
@@ -1,33 +1,97 @@
+"""SPA Telegram Bot — main entry point."""
import asyncio
+import signal
+import sys
from aiogram import Bot, Dispatcher
from app.config import BOT_TOKEN
+from app.logger import logger
+# Import routers
from app.handlers.start import router as start_router
from app.handlers.booking import router as booking_router
from app.handlers.profile import router as profile_router
-from app.handlers.mybookings import router as mybooking_router
+from app.handlers.mybookings import router as mybookings_router
+from app.handlers.services import router as services_router
from app.handlers.about import router as about_router
from app.handlers.contacts import router as contacts_router
+from app.handlers.admin import router as admin_router
+
+bot = Bot(token=BOT_TOKEN)
+dp = Dispatcher()
+
+# Register routers
+dp.include_routers(
+ start_router,
+ booking_router,
+ profile_router,
+ mybookings_router,
+ services_router,
+ about_router,
+ contacts_router,
+ admin_router,
+)
+
+
+async def on_startup():
+ """Called when bot starts."""
+ me = await bot.get_me()
+ logger.info(
+ "Bot started | @{username} (id={id}) | Token: {token_preview}...",
+ username=me.username,
+ id=me.id,
+ token_preview=BOT_TOKEN[:10],
+ )
+
+
+async def on_shutdown():
+ """Called when bot shuts down."""
+ logger.info("Bot shutting down...")
+ # Give running tasks time to finish
+ pending = asyncio.all_tasks()
+ if pending:
+ logger.debug("Cancelling {count} pending tasks", count=len(pending))
+ logger.info("Bot shutdown complete")
async def main():
- bot = Bot(token=BOT_TOKEN)
- dp = Dispatcher()
+ logger.info("Initializing bot...")
- # подключаем все роутеры
- dp.include_router(start_router)
- dp.include_router(booking_router)
- dp.include_router(profile_router)
- dp.include_router(mybooking_router)
- dp.include_router(about_router)
- dp.include_router(contacts_router)
+ dp.startup.register(on_startup)
+ dp.shutdown.register(on_shutdown)
- print("Bot started...")
+ # Handle SIGTERM/SIGINT gracefully
+ stop_event = asyncio.Event()
- await dp.start_polling(bot)
+ def _signal_handler(sig, frame):
+ sig_name = signal.Signals(sig).name
+ logger.warning("Received {sig} signal, shutting down...", sig=sig_name)
+ stop_event.set()
+
+ loop = asyncio.get_event_loop()
+ for sig in (signal.SIGTERM, signal.SIGINT):
+ try:
+ loop.add_signal_handler(sig, lambda s=sig: _signal_handler(s, None))
+ except NotImplementedError:
+ # Windows fallback
+ signal.signal(sig, _signal_handler)
+
+ try:
+ await dp.start_polling(bot, stop_signal=stop_event)
+ except asyncio.CancelledError:
+ logger.info("Polling cancelled")
+ except Exception as e:
+ logger.exception("Fatal error in polling: {error}", error=e)
+ finally:
+ await bot.session.close()
+ logger.info("Bot session closed")
if __name__ == "__main__":
- asyncio.run(main())
\ No newline at end of file
+ try:
+ asyncio.run(main())
+ except KeyboardInterrupt:
+ logger.info("Bot stopped by user (Ctrl+C)")
+ except Exception as e:
+ logger.exception("Unhandled exception: {error}", error=e)
diff --git a/app/services/__init__.py b/app/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/services/booking_service.py b/app/services/booking_service.py
index fe182df..7dc88a3 100644
--- a/app/services/booking_service.py
+++ b/app/services/booking_service.py
@@ -1,12 +1,64 @@
-from app.db.memory import bookings
+from app.db.memory import (
+ create_booking as db_create_booking,
+ get_busy_times as db_get_busy_times,
+ get_user_bookings as db_get_user_bookings,
+ cancel_booking as db_cancel_booking,
+ update_booking as db_update_booking,
+ load_bookings as db_load_bookings,
+ save_bookings as db_save_bookings,
+ BOOKINGS_FILE,
+)
+from app.logger import logger
-def create_booking(user_id: int, service: str, date: str):
- booking = {
- "user_id": user_id,
- "service": service,
- "date": date,
- "status": "active"
- }
- bookings.append(booking)
- return booking
\ No newline at end of file
+async def create_booking(user_id, service, master, date, time, user_name=""):
+ return db_create_booking(user_id, service, master, date, time, user_name)
+
+
+async def get_busy_times(master, date):
+ return db_get_busy_times(master, date)
+
+
+async def get_user_bookings(user_id):
+ return db_get_user_bookings(user_id)
+
+
+async def cancel_booking(booking_id):
+ return db_cancel_booking(booking_id)
+
+
+async def update_booking(booking_id, service, master, date, time):
+ return db_update_booking(booking_id, service, master, date, time)
+
+
+async def is_slot_busy(master, date, time):
+ """Check if a time slot is already taken by an active booking."""
+ busy = db_get_busy_times(master, date)
+ return time in busy
+
+
+def cleanup_cancelled():
+ """Remove cancelled bookings older than the head (keep only last 5 cancelled per user)."""
+ bookings = db_load_bookings()
+ active = [b for b in bookings if b["status"] == "active"]
+ cancelled = [b for b in bookings if b["status"] == "cancelled"]
+
+ # Group cancelled by user, keep last 5 per user
+ from collections import defaultdict
+ by_user = defaultdict(list)
+ for b in cancelled:
+ by_user[b["user_id"]].append(b)
+
+ keep_ids = set()
+ for uid, user_cancel in by_user.items():
+ # Keep the 5 most recent
+ user_cancel.sort(key=lambda x: x.get("id", 0), reverse=True)
+ for b in user_cancel[:5]:
+ keep_ids.add(b["id"])
+
+ # Rebuild: keep active + keep recent cancelled
+ bookings = active + [b for b in cancelled if b["id"] in keep_ids]
+ db_save_bookings(bookings)
+ removed = len(cancelled) - len(keep_ids)
+ if removed > 0:
+ logger.info("Cleaned up {n} old cancelled bookings", n=removed)
diff --git a/app/states/__init__.py b/app/states/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/states/booking.py b/app/states/booking.py
index 12e607a..6019f0e 100644
--- a/app/states/booking.py
+++ b/app/states/booking.py
@@ -1,6 +1,9 @@
from aiogram.fsm.state import StatesGroup, State
class BookingState(StatesGroup):
- service = State()
+ service = State() # первая услуга
+ extra_services = State() # дополнительные услуги
+ master = State()
date = State()
+ time = State()
confirm = State()
\ No newline at end of file
diff --git a/data/.gitkeep b/data/.gitkeep
new file mode 100644
index 0000000..e69de29