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